Adicionar uma nova feature (bounded context)
Walkthrough end-to-end criando um novo módulo — domain, use case, repo, controller, route, contract, frontend.
Isto percorre como adicionar um novo bounded context de ponta a ponta. Vamos usar um módulo fictício projects: uma organização é dona de Projects; membros podem criar, listar, arquivar.
0. Decidir se merece um módulo
Um novo bounded context se justifica quando:
- Tem seu(s) próprio(s) aggregate(s) e invariantes distintos dos módulos existentes.
- Tem seu próprio lifecycle (create / mutate / archive separado das entities existentes).
- Coordenação cross-module aconteceria via eventos, não via joins de foreign-key.
Se é apenas mais um endpoint em iam ou tenancy, pule este guia e leia adicionar um endpoint.
1. Scaffold do módulo
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 Zod local (re-exporta de @app/contracts).2. Domain primeiro
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'));
// mutar via reflection ou reconstruir — escolha seu estilo e seja consistente
this.addDomainEvent({ name: 'project.archived', aggregateId: this.id, /* ... */ });
return ok();
}
}Sem import express. Sem import @prisma/client. ESLint aplica.
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 implementa o repository contra o Prisma, mapeando linhas ↔ aggregates. Adicione o model Prisma em prisma/schema.prisma e rode uma migration.
5. Contract
Em 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-exporte de 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;
};Monte em bootstrap/app.ts em /api/projects.
7. Cabear DI
Em bootstrap/container.ts, registre o repo, os use cases, e o controller. O container é tipado — typos fazem o build falhar.
8. Permissions
Adicione ao recurso projects em packages/shared/src/permissions/index.ts:
export const RESOURCES = [
/* ... */,
'projects',
] as const;O catálogo auto-gera projects:create, projects:read, projects:update, projects:delete. Mapeie aos roles no seed (veja seed and test).
9. Regenerar a API surface
bun run generate:apiInspecione o diff. Commit ambos os arquivos regenerados.
10. Frontend
// apps/client/lib/api/projects.ts
import { api } from './client';
export const createProject = (name: string) =>
api.POST('/api/projects', { body: { name } });Schemas para forms vêm de @app/contracts. Não redefina.
11. Test
- Domain: testes unitários puros contra
Project.create,Project.archivecobrindo invariantes. - Use case: fake repo em memória +
InMemoryEventBus, garanta que os eventos são publicados. - HTTP: supertest contra o router vivo com um Prisma test database (ou um container sqlite se você tem um).
Veja seed and test.
12. Commit
Conventional commit, scope = o context novo:
feat(projects): add Project aggregate, CRUD endpoints, contracts (#NNN)Se seu edit disparou um diff de OpenAPI, o regen vai no mesmo commit.