SaaS Starter
How-to

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:api

Inspect 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.archive covering 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.

On this page