Add a new feature (bounded context)
End-to-end walkthrough creating a new module — domain, use case, repo, controller, route, contract, frontend.
This walks through adding a new bounded context end-to-end. We'll use a fictional projects module: an organization owns Projects; members can create, list, archive them.
0. Decide it deserves a module
A new bounded context is justified when:
- It has its own aggregate(s) and invariants distinct from existing modules.
- It has its own lifecycle (create / mutate / archive separate from existing entities).
- Cross-module coordination would happen via events, not foreign-key joins.
If it's just another endpoint on iam or tenancy, skip this guide and read add an endpoint instead.
1. Scaffold the module
apps/server/src/modules/projects/
domain/
project.ts Aggregate.
project-repository.ts Interface.
application/
use-cases/
create-project.ts
list-projects.ts
archive-project.ts
infrastructure/
prisma-project.repository.ts
interfaces/
http/
projects.controller.ts
projects.routes.ts
projects.schemas.ts Local Zod (re-exports from @app/contracts).2. Domain first
domain/project.ts:
import { AggregateRoot } from '@/shared/aggregate-root.js';
import { Result, ok, err } from '@app/shared';
import type { OrganizationId, ProjectId } from '@app/shared/types';
export class Project extends AggregateRoot<ProjectId> {
private constructor(
id: ProjectId,
public readonly orgId: OrganizationId,
public readonly name: string,
public readonly archivedAt: Date | null,
public readonly createdAt: Date,
) {
super(id);
}
static create(input: {
id: ProjectId;
orgId: OrganizationId;
name: string;
}): Result<Project, DomainError> {
if (input.name.trim().length === 0) {
return err(new DomainError('PROJECT_NAME_EMPTY', 'Project name is required'));
}
const project = new Project(input.id, input.orgId, input.name, null, new Date());
project.addDomainEvent({
name: 'project.created',
aggregateId: input.id,
occurredAt: new Date(),
payload: { orgId: input.orgId, name: input.name },
});
return ok(project);
}
archive(): Result<void, DomainError> {
if (this.archivedAt) return err(new DomainError('PROJECT_ALREADY_ARCHIVED'));
// mutate via reflection or rebuild — pick your style and be consistent
this.addDomainEvent({ name: 'project.archived', aggregateId: this.id, /* ... */ });
return ok();
}
}No import express. No import @prisma/client. ESLint enforces this.
domain/project-repository.ts:
export interface ProjectRepository {
findById(id: ProjectId): Promise<Project | null>;
listByOrg(orgId: OrganizationId): Promise<readonly Project[]>;
save(project: Project): Promise<void>;
}3. Use cases
application/use-cases/create-project.ts:
import { ulid } from 'ulid';
import type { UseCase } from '@/shared/usecase.js';
import type { IEventBus } from '@/infrastructure/events/event-bus.js';
export interface CreateProjectInput {
orgId: OrganizationId;
name: string;
}
export class CreateProject implements UseCase<CreateProjectInput, Project> {
constructor(
private readonly projects: ProjectRepository,
private readonly bus: IEventBus,
) {}
async execute(input: CreateProjectInput): Promise<Result<Project, DomainError>> {
const result = Project.create({
id: ulid() as ProjectId,
orgId: input.orgId,
name: input.name,
});
if (result.isErr()) return result;
await this.projects.save(result.value);
await this.bus.publish(result.value.pullEvents());
return result;
}
}4. Infrastructure
infrastructure/prisma-project.repository.ts implements the repository against Prisma, mapping rows ↔ aggregates. Add the Prisma model to prisma/schema.prisma and run a migration.
5. Contract
In packages/contracts/src/project.ts:
import { z } from 'zod';
export const CreateProjectSchema = z.object({
name: z.string().min(1).max(120),
});
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
export const ProjectSchema = z.object({
id: z.string(),
orgId: z.string(),
name: z.string(),
archivedAt: z.string().datetime().nullable(),
createdAt: z.string().datetime(),
});Re-export from packages/contracts/src/index.ts.
6. HTTP
interfaces/http/projects.routes.ts:
import { Router } from 'express';
import { authMiddleware } from '@/modules/iam/interfaces/http/auth.middleware.js';
import { writeLimiter } from '@/infrastructure/http/rate-limit.js';
import { requirePermission } from '@/infrastructure/http/require-permission.js';
import { validateRequest } from '@/infrastructure/http/validate-request.js';
import { apiRoute } from '@/infrastructure/http/openapi.js';
import { CreateProjectSchema, ProjectSchema } from '@app/contracts';
apiRoute({
method: 'post',
path: '/projects',
tags: ['projects'],
body: CreateProjectSchema,
responses: { 201: { description: 'Created', schema: ProjectSchema } },
security: 'session',
});
export const buildProjectsRouter = (container) => {
const r = Router();
const ctrl = container.resolve('projectsController');
r.post(
'/',
authMiddleware,
writeLimiter,
requirePermission('projects:create'),
validateRequest({ body: CreateProjectSchema }),
(req, res) => ctrl.create(req, res),
);
return r;
};Mount in bootstrap/app.ts under /api/projects.
7. Wire DI
In bootstrap/container.ts, register the repo, use cases, and controller. The container is typed — typos fail the build.
8. Permissions
Add to the projects resource in packages/shared/src/permissions/index.ts:
export const RESOURCES = [
/* ... */,
'projects',
] as const;The catalog auto-generates projects:create, projects:read, projects:update, projects:delete. Map them to roles in the seed (see seed and test).
9. Regenerate the API surface
bun run generate:apiInspect the diff. Commit both regenerated files.
10. Frontend
// apps/client/lib/api/projects.ts
import { api } from './client';
export const createProject = (name: string) =>
api.POST('/api/projects', { body: { name } });Schemas for forms come from @app/contracts. Don't redefine.
11. Test
- Domain: pure unit tests against
Project.create,Project.archivecovering invariants. - Use case: in-memory fake repo +
InMemoryEventBus, assert events are published. - HTTP: supertest against the live router with a Prisma test database (or sqlite container if you have one).
See seed and test.
12. Commit
Conventional commit, scope = the new context:
feat(projects): add Project aggregate, CRUD endpoints, contracts (#NNN)If your edit triggered an OpenAPI diff, the regen lives in the same commit.