# Modular Architecture

> Scaffold backend features using a modular layered architecture (route → controller → service → repository) where each feature is a self-contained module. Use when creating a new API/backend feature, refactoring into a clean structure, or enforcing separation of concerns.

- Skill: `surajkuushwaha/modular-architecture` (Agent Skill)
- Install (CLI): `npx skillmds@latest add surajkuushwaha/modular-architecture`
- Raw SKILL.md: https://api.skillmd.com/api/skills/surajkuushwaha/modular-architecture/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: surajkuushwaha (https://skillmd.com/u/surajkuushwaha)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/surajkuushwaha/modular-architecture

---


# 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.

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

## Controller

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

```ts
/**
 * @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.

```ts
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:**

```ts
// 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:**

```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:**

```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:**

```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:**

```ts
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).

```ts
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.

```ts
// 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.

```ts
// 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.

```ts
// 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.

```ts
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.

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

