SaaS Starter

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

Inspecione 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.archive cobrindo 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.

Nesta página