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, notuserProfile/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 optionallysequelize: 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
- Create
src/modules/<feature>/ types/<feature>.types.ts— standalone TS types/interfaces/enums (if needed)config/<feature>.config.ts— feature constants (if needed)schemas/<feature>.schema.ts— validation schemas + inferred typespackages/cx-datastore/src/repositories/<domain>/<feature>.repository.ts— class, constructor injection,export default ClassNamepackages/cx-datastore/src/repositories/<domain>/index.ts— add to domain objectpackages/cx-datastore/src/repositories/index.ts— add domain if newsrc/shared/db/repositories.ts— instantiate withnew repositories.<domain>.FooRepository({ ...models })services/<feature>.service.ts— imports named instance fromshared/db/repositoriesutils/<feature>.util.tsorhelpers/<feature>.helper.ts— pure helpers (if needed)crons/<feature>.cron.ts— scheduled jobs, repo + helper only (if needed)controllers/<feature>.controller.ts— jsDocroutes/<feature>.routes.ts— RESTful endpointsindex.ts— re-export only public surface (named exports, no blanketexport *)
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/orhelpers/— 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.tsbarrel only index.tsexports only what's required — named exports, noexport *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 moduleindex.ts— leaks internals, use named exports
Works well with
- cx-tdd
- cx-improve-architecture
- cx-domain-model