modular-architecture について
modular-architectureは、TypeScriptプロジェクトでAPIやバックエンド機能を構成する開発者向けのコーディングスキルです。厳密なレイヤーの責務、機能単位のモジュール、リポジトリの配置、明示的な公開エクスポートを定めています。
主な機能
- 01ルート、コントローラー、サービス、リポジトリを分離
- 02機能単位でコードをモジュール化
- 03リポジトリの注入と登録方法を規定
- 04スキーマ、型、ヘルパーの責務を定義
- 05モジュールの公開エクスポートを明示
活用シーン
- 01明確なレイヤー構成でバックエンド機能を追加
- 02コードを機能モジュールへリファクタリング
- 03データベースアクセスをリポジトリに集約
- 04モジュール間のインポートとエクスポートを確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ surajkuushwaha/ skills --skill modular-architectureインストール後、Claude Code で/modular-architectureを入力して読み込まれたか確認します。
詳細
使用するタイミング
- 新しい API/バックエンド機能
- クリーンなレイヤー構造へのリファクタリング
- 関心事の分離の徹底
アーキテクチャ
フロー:route → controller → service → repository。レイヤーは厳密に分け、重複させません。
機能ごとのサイドファイル:schemas(バリデーション)、types(TS の型/インターフェース)、config(機能の定数/設定)、utils または helpers(純粋なヘルパー)、crons(スケジュールされたジョブ)、index.ts(バレル)。
フォルダー名は複数形にします:controllers/、services/、schemas/、helpers/、utils/、crons/。モジュールごとに utils/ または helpers/ を選び、一貫性を保ってください。
すべてのリポジトリは packages/cx-datastore/src/repositories/ に配置します。src/modules/ の中には配置しないでください。
フォルダー構成
レイヤーではなく、機能ごとにグループ化します。1 つのモジュールにつき 1 つのフォルダーです。
src/
└── modules/
├── user/
│ ├── index.ts # public barrel — export only what's needed
│ ├── controllers/
│ │ └── user.controller.ts
│ ├── services/
│ │ └── user.service.ts
│ ├── schemas/
│ │ └── user.schema.ts
│ ├── types/
│ │ └── user.types.ts
│ ├── config/
│ │ └── user.config.ts
│ ├── crons/ # scheduled jobs — repo + helper only
│ │ └── user.cron.ts
│ └── utils/ # also named helpers/ — both valid
│ └── user.util.ts
└── order/
└── ... (same shape)
packages/
└── cx-datastore/
└── src/
└── repositories/
├── index.ts # aggregates all domain repos
├── user/
│ ├── index.ts # exports domain repo classes
│ └── user.repository.ts
└── campaign_report/
├── index.ts
└── campaign_report.repository.ts
src/shared/db/
├── index.ts # initializes models via initModels()
├── sequelize.ts # sequelize connection
└── repositories.ts # instantiates all repo classes with models
命名規則:
- リポジトリ:
packages/cx-datastore/src/repositories/<domain>/<feature>.repository.ts <feature>:snake_case、単数形(userProfile/user-profile/usersではなくuser_profile)<domain>:関連するリポジトリをまとめる snake_case のフォルダー(campaign_report、analytics)- フォルダー名:常に snake_case(
campaign_report/、user_profile/) - ファイル名:常に snake_case(
user_profile.repository.ts、user_profile.service.ts) - 例外 — model ファイルのみ:PascalCase(
UserProfile.model.ts、CampaignReport.model.ts)
レイヤーの責務
Route
RESTful エンドポイントを定義します。ロジックは含めません。controller のみを呼び出します。
router.post("/users", createUserController);
Controller
req/res を処理します。入力を検証します。route と説明を jsDoc で記述します。ロジックは service に委譲します。複数の route がある場合は、すべてを記載します。
/**
* @route POST /users
* @description Create a new user
*/
export const createUserController = async (req, res) => {
const result = await createUserService(req.body);
res.json(result);
};
Service
ビジネスロジックを担当します。HTTP は扱いません。データ取得には repository を呼び出します。util を使用することもできます。
export const createUserService = async (data) => {
return await userRepository.create(data);
};
Repository
DB 操作のみを行います。ビジネスロジックは含めません。req/res は扱いません。すべてのリポジトリは packages/cx-datastore/src/repositories/<domain>/ に配置します。
- クラスをエクスポートします(インスタンスではありません) —
export default FooRepository - コンストラクターは
models: ModelsType(および任意でsequelize: Sequelize)を受け取ります ModelsType = Pick<InitializedModels, "Model1" | "Model2">— このリポジトリで必要なモデルのみを指定します- リポジトリファイル自体ではインスタンス化しません
src/shared/db/repositories.tsで一度だけインスタンス化します
手順 1 — repository クラスを作成します:
// packages/cx-datastore/src/repositories/user/user.repository.ts
import { Model, Transaction } from "sequelize";
import type { InitializedModels } from "../../index";
type ModelsType = Pick<InitializedModels, "User">;
class UserRepository {
private models: ModelsType;
constructor(models: ModelsType) {
this.models = models;
}
async create(
data: Record<string, unknown>,
transaction?: Transaction,
): Promise<Model> {
return this.models.User.create(data, {
...(transaction ? { transaction } : {}),
});
}
async findById(id: number): Promise<Model | null> {
return this.models.User.findByPk(id);
}
}
export default UserRepository;
手順 2 — domain の index.ts に追加します:
// packages/cx-datastore/src/repositories/user/index.ts
import UserRepository from "./user.repository";
const userRepositories = {
UserRepository,
};
export default userRepositories;
手順 3 — ルートの repositories/index.ts に登録します:
// packages/cx-datastore/src/repositories/index.ts
import { default as campaignReportRepositories } from "./campaign_report";
const repositories = {
campaignReport: campaignReportRepositories,
};
export default repositories;
手順 4 — src/shared/db/repositories.ts でインスタンス化します:
// src/shared/db/repositories.ts
import { repositories } from "@culturex-art/datastore";
import { models } from "./index";
import { sequelize } from "./sequelize";
export const userRepository = new repositories.user.UserRepository({
User: models.User,
});
// Pass sequelize as 2nd arg when repo needs raw queries or transactions:
export const campaignReportRepository =
new repositories.campaignReport.CampaignReportRepository(
{ Campaign: models.Campaign, CampaignReport: models.CampaignReport },
sequelize,
);
手順 5 — service でインポートします:
import { userRepository } from "../../../shared/db/repositories";
Schema
バリデーションスキーマ、DTO、共有型を定義します。ランタイムロジック、I/O、副作用は含めません。controller(検証用)、service(型用)、repository(エンティティの形状用)からインポートします。
import { z } from "zod";
export const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
Types
機能にスコープを限定した TypeScript の型とインターフェースを定義します。ランタイムロジックや I/O は含めません。schema とは分けます。schema はバリデーションと推論される型、types はバリデーターに紐付かない単独のインターフェース/enum です。
// user/types/user.types.ts
export interface UserSummary {
id: number;
displayName: string;
role: UserRole;
}
export enum UserRole {
Admin = "admin",
Member = "member",
}
Config
機能にスコープを限定した定数と設定値を定義します。ランタイムロジック、I/O、ビジネス上の判断は含めません。
// user/config/user.config.ts
export const USER_CONFIG = {
maxNameLength: 100,
defaultRole: "member",
sessionTtlSeconds: 3600,
} as const;
Cron
スケジュールされたジョブを扱います。service と同様の構造で、定期タスクのビジネスロジックを担います。 インポートできるのは repository、helper/util のみです。 HTTP、controller、service、route は使用できません。 service のロジックが必要ですか?helper/util に切り出すか、両方で使える共通部分を helper に移してください。
// user/crons/user.cron.ts
import { userRepository } from "../../../shared/db/repositories";
import { formatUserDisplayName } from "../utils/user.util";
export const purgeStaleUsersCron = async () => {
const stale = await userRepository.findStale();
for (const u of stale) {
await userRepository.softDelete(u.id);
}
};
Util / Helper
フォルダー名は utils/ または helpers/ のどちらでもかまいません。モジュールごとに一方を選び、一貫性を保ってください。
純粋でステートレスな、機能にスコープを限定した処理を記述します。HTTP、DB、ビジネス上の判断は扱いません。 Service → Util は可。Util → Service/Controller/Repository/Route は禁止です。 Helper にロジックや I/O が必要ですか?service に移してください。
export const formatUserDisplayName = (firstName: string, lastName: string) => {
return `${firstName} ${lastName}`.trim();
};
モジュールのエクスポート(index.ts)
各モジュールフォルダーには index.ts があり、他のモジュールが必要とするものだけを再エクスポートします。無差別に export * することはありません。内部要素(utils、crons、内部ヘルパー、プライベート型)は、他のモジュールで正当に必要とされない限り、エクスポートしません。
// src/modules/user/index.ts — named, explicit, minimal
export { userRoutes } from "./routes/user.routes";
export { createUserService, getUserService } from "./services/user.service";
export { createUserSchema, type CreateUserInput } from "./schemas/user.schema";
// controllers, crons, utils, config NOT exported — internal
ルール:
- 他のモジュールが使用する公開インターフェースだけをエクスポートします。無差別な
export *による再エクスポートは禁止です - コントローラー、crons、utils/ヘルパー、config はデフォルトで内部扱いです
- モジュール間のインポートには
index.tsを経由します(import { createUserService } from "@/modules/user") - モジュール内のインポートには相対パスを使用します
- 新たに公開する必要があるものは、名前付きエクスポートとして意図的に追加します
実装手順
src/modules/<feature>/を作成しますtypes/<feature>.types.ts— 単独の TS 型/インターフェース/列挙型(必要な場合)config/<feature>.config.ts— 機能の定数(必要な場合)schemas/<feature>.schema.ts— バリデーションスキーマと推論された型packages/cx-datastore/src/repositories/<domain>/<feature>.repository.ts— クラス、コンストラクターインジェクション、export default ClassNamepackages/cx-datastore/src/repositories/<domain>/index.ts— ドメインオブジェクトに追加しますpackages/cx-datastore/src/repositories/index.ts— 新しいドメインの場合は追加しますsrc/shared/db/repositories.ts—new repositories.<domain>.FooRepository({ ...models })でインスタンスを生成しますservices/<feature>.service.ts—shared/db/repositoriesから名前付きインスタンスをインポートしますutils/<feature>.util.tsまたはhelpers/<feature>.helper.ts— 純粋なヘルパー(必要な場合)crons/<feature>.cron.ts— スケジュールされたジョブ。リポジトリとヘルパーのみ(必要な場合)controllers/<feature>.controller.ts— jsDocroutes/<feature>.routes.ts— RESTful エンドポイントindex.ts— 公開インターフェースのみを再エクスポートします(名前付きエクスポート。無差別なexport *は禁止)
ルール(厳守)
- DB アクセス:リポジトリ経由のみ。必ず
packages/cx-datastore/src/repositories/に配置します src/modules/内にリポジトリを配置してはいけません- リポジトリ:モデルをコンストラクターインジェクションし、クラスをエクスポートします(インスタンスではありません)。インスタンスの生成場所は
src/shared/db/repositories.tsのみに限定します - コントローラーにビジネスロジックを記述しません
- ルートからリポジトリを直接呼び出してはいけません
- サービスは HTTP に依存せず、再利用可能にします
- Util/ヘルパーからサービス/コントローラー/リポジトリ/ルートをインポートすることは禁止です(一方向のみ:サービス → Util/ヘルパー)
- Util フォルダーには
utils/またはhelpers/を使用します。モジュールごとにどちらか一方を選び、統一してください - フォルダー名は常に複数形にします:
controllers/、services/、schemas/、helpers//utils/、crons/ - Cron がインポートできるのはリポジトリとヘルパー/Util のみです。 サービス/コントローラー/ルートは禁止です
- スキーマはバリデーションと推論された型のみを扱い、実行時ロジックを含めません
- 型は単独のインターフェース/列挙型のみを扱い、実行時ロジックや I/O を含めません
- Config は定数のみを扱い、実行時ロジックや I/O を含めません
- モジュール間のアクセスには
index.tsのバレルのみを経由します index.tsからエクスポートするのは必要なものだけにします。名前付きエクスポートを使用し、無差別なexport *は禁止です- コードでは async/await を一貫して使用し、モジュール化して読みやすくします
アンチパターン
- ロジックを内包する肥大化したコントローラー
- req/res に触れるサービス
- ビジネスルールを含むリポジトリ
- レイヤー間の密結合
- 別のモジュールの内部ファイルに直接アクセスすること(
index.tsを迂回する) - リポジトリファイル内の
export default new FooRepository()— ここでインスタンスを生成してはいけません src/modules/内に置かれたリポジトリ — 必ず cx-datastore に配置します- サービス/コントローラーを呼び出す Cron — 代わりに共通ロジックをヘルパーに切り出します
- 単数形のフォルダー名(
controller/、service/)— 必ず複数形にします - モジュールの
index.tsにあるexport *— 内部要素が漏れるため、名前付きエクスポートを使用します
相性の良いもの
- cx-tdd
- cx-improve-architecture
- cx-domain-model