mcpField
M
modular-architecture

modular-architecture

github.com/surajkuushwahaDevelopment037 installsUpdated 12w ago
Repository

About modular-architecture

modular-architecture is a coding skill for developers structuring API and backend features in a TypeScript project. It defines strict layer responsibilities, feature-based modules, repository placement, and explicit public exports.

Key features

  1. 01Separates routes, controllers, services, and repositories
  2. 02Groups code into feature-based modules
  3. 03Defines repository injection and registration
  4. 04Sets rules for schemas, types, and helpers
  5. 05Limits module barrels to explicit exports

Use cases

  1. 01Adding a backend feature with clear layers
  2. 02Refactoring code into feature modules
  3. 03Keeping database access in repositories
  4. 04Reviewing cross-module imports and exports

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/surajkuushwaha/skills --skill modular-architecture

After installing, type/modular-architecturein Claude Code to confirm it loaded.

Details

From the project README

When to use

  • New API/backend feature
  • Refactor into clean layered structure
  • Enforce separation of concerns

Architecture

Flow: route → controller → service → repository. Layers strict, no overlap. Side files per feature: schemas (validation), types (TS types/interfaces), config (feature constants/config), utils or helpers (pure helpers), crons (scheduled jobs), index.ts (barrel).

Folder names plural: controllers/, services/, schemas/, helpers/, utils/, crons/. Pick utils/ or helpers/ per module — stay consistent.

All repositories live in packages/cx-datastore/src/repositories/. Never inside src/modules/.

Folder Structure

Group by feature, not layer. One module = one folder.

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

Naming:

  • Repository: packages/cx-datastore/src/repositories/<domain>/<feature>.repository.ts
  • <feature>: snake_case, singular (user_profile, not userProfile/user-profile/users)
  • <domain>: snake_case folder grouping related repos (campaign_report, analytics)
  • Folder names: snake_case always (campaign_report/, user_profile/)
  • File names: snake_case always (user_profile.repository.ts, user_profile.service.ts)
  • Exception — model files only: PascalCase (UserProfile.model.ts, CampaignReport.model.ts)

Layer Responsibilities

Route

Define RESTful endpoints. No logic. Delegate to controller only.

router.post("/users", createUserController);

Controller

Handle req/res. Validate input. jsDoc with route(s) + description. Delegate logic to service. Multi-route → document all.

/**
 * @route POST /users
 * @description Create a new user
 */
export const createUserController = async (req, res) => {
  const result = await createUserService(req.body);
  res.json(result);
};

Service

Business logic. No HTTP. Calls repository for data. May use util.

export const createUserService = async (data) => {
  return await userRepository.create(data);
};

Repository

DB ops only. No business logic. No req/res. All repos live in packages/cx-datastore/src/repositories/<domain>/.

  • Class exported (NOT instance) — export default FooRepository
  • Constructor accepts models: ModelsType (and optionally sequelize: Sequelize)
  • ModelsType = Pick<InitializedModels, "Model1" | "Model2"> — only models this repo needs
  • Never instantiated in the repo file itself
  • Instantiated once in src/shared/db/repositories.ts

Step 1 — Write the repository class:

// 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;

Step 2 — Add to domain index.ts:

// packages/cx-datastore/src/repositories/user/index.ts
import UserRepository from "./user.repository";

const userRepositories = {
  UserRepository,
};

export default userRepositories;

Step 3 — Register in root repositories/index.ts:

// packages/cx-datastore/src/repositories/index.ts
import { default as campaignReportRepositories } from "./campaign_report";

const repositories = {
  campaignReport: campaignReportRepositories,
};

export default repositories;

Step 4 — Instantiate in 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,
  );

Step 5 — Import in service:

import { userRepository } from "../../../shared/db/repositories";

Schema

Validation schemas, DTOs, shared types. No runtime logic, no I/O, no side effects. Imported by controller (validate), service (types), repository (entity shape).

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

Feature-scoped TypeScript types and interfaces. No runtime logic, no I/O. Separate from schema — schema = validation + inferred types, types = standalone interfaces/enums not tied to a validator.

// user/types/user.types.ts
export interface UserSummary {
  id: number;
  displayName: string;
  role: UserRole;
}

export enum UserRole {
  Admin = "admin",
  Member = "member",
}

Config

Feature-scoped constants and config values. No runtime logic, no I/O, no business decisions.

// user/config/user.config.ts
export const USER_CONFIG = {
  maxNameLength: 100,
  defaultRole: "member",
  sessionTtlSeconds: 3600,
} as const;

Cron

Scheduled jobs. Service-like structure — business logic for a recurring task. May import: repository, helper/util only. No HTTP, no controller, no service, no route. Need service logic? Extract into helper/util or move shared piece into a helper both can use.

// 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

Folder name: utils/ or helpers/ — both valid, pick one per module, stay consistent.

Pure, stateless, feature-scoped. No HTTP, no DB, no business decisions. Service → Util OK. Util → Service/Controller/Repository/Route FORBIDDEN. Helper need logic or I/O? Move to service.

export const formatUserDisplayName = (firstName: string, lastName: string) => {
  return `${firstName} ${lastName}`.trim();
};

Module Exports (index.ts)

Every module folder has index.ts re-exporting only what other modules need. Not a blanket export *. Internals (utils, crons, internal helpers, private types) stay unexported unless another module legitimately needs them.

// 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

Rules:

  • Export only the public surface other modules consume — no export * blanket re-exports
  • Controllers, crons, utils/helpers, config: internal by default
  • Cross-module imports go through index.ts (import { createUserService } from "@/modules/user")
  • Intra-module imports use relative paths
  • Need to expose something new? Add the named export deliberately

Implementation Steps

  1. Create src/modules/<feature>/
  2. types/<feature>.types.ts — standalone TS types/interfaces/enums (if needed)
  3. config/<feature>.config.ts — feature constants (if needed)
  4. schemas/<feature>.schema.ts — validation schemas + inferred types
  5. packages/cx-datastore/src/repositories/<domain>/<feature>.repository.ts — class, constructor injection, export default ClassName
  6. packages/cx-datastore/src/repositories/<domain>/index.ts — add to domain object
  7. packages/cx-datastore/src/repositories/index.ts — add domain if new
  8. src/shared/db/repositories.ts — instantiate with new repositories.<domain>.FooRepository({ ...models })
  9. services/<feature>.service.ts — imports named instance from shared/db/repositories
  10. utils/<feature>.util.ts or helpers/<feature>.helper.ts — pure helpers (if needed)
  11. crons/<feature>.cron.ts — scheduled jobs, repo + helper only (if needed)
  12. controllers/<feature>.controller.ts — jsDoc
  13. routes/<feature>.routes.ts — RESTful endpoints
  14. index.ts — re-export only public surface (named exports, no blanket export *)

Rules (Strict)

  • DB access: repository only, always in packages/cx-datastore/src/repositories/
  • Never put a repository inside src/modules/
  • Repository: constructor-injected models, exports class (not instance), instantiated only in src/shared/db/repositories.ts
  • No business logic in controller
  • Route never calls repository directly
  • Services HTTP-independent + reusable
  • Util/helper forbidden from importing service/controller/repository/routes (one-way: service → util/helper)
  • Util folder: name utils/ or helpers/ — pick one per module, stay consistent
  • Folder names plural always: controllers/, services/, schemas/, helpers//utils/, crons/
  • Cron may import: repository + helper/util only. No service/controller/route
  • Schema = validation + inferred types only, no runtime logic
  • Types = standalone interfaces/enums only, no runtime logic, no I/O
  • Config = constants only, no runtime logic, no I/O
  • Cross-module access through index.ts barrel only
  • index.ts exports only what's required — named exports, no export * blanket
  • Code: async/await consistent, modular, readable

Anti-patterns

  • Fat controllers with embedded logic
  • Services touching req/res
  • Repository containing business rules
  • Tight coupling between layers
  • Reaching into another module's internal files (bypass index.ts)
  • export default new FooRepository() in repo file — never instantiate there
  • Repository living in src/modules/ — always belongs in cx-datastore
  • Cron calling service/controller — extract shared logic to helper instead
  • Singular folder names (controller/, service/) — always plural
  • export * in module index.ts — leaks internals, use named exports

Works well with

  • cx-tdd
  • cx-improve-architecture
  • cx-domain-model

Comments

Markdown supported