SaaS Starter

Agregar una nueva feature (bounded context)

Walkthrough end-to-end creando un nuevo módulo — domain, use case, repo, controller, route, contract, frontend.

Esto recorre cómo agregar un nuevo bounded context end-to-end. Vamos a usar un módulo ficticio projects: una organización es dueña de Projects; los miembros pueden crearlos, listarlos, archivarlos.

0. Decidir si merece un módulo

Un nuevo bounded context se justifica cuando:

  • Tiene su(s) propio(s) aggregate(s) e invariantes distintos de los módulos existentes.
  • Tiene su propio lifecycle (create / mutate / archive separado de las entities existentes).
  • La coordinación cross-module ocurriría vía eventos, no vía joins de foreign-key.

Si es sólo otro endpoint en iam o tenancy, salteá esta guía y leé agregar un endpoint en su lugar.

1. Scaffold del 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 desde @app/contracts).

2. Domain primero

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 vía reflection o reconstruir — elegí tu estilo y mantené coherencia
    this.addDomainEvent({ name: 'project.archived', aggregateId: this.id, /* ... */ });
    return ok();
  }
}

Sin import express. Sin import @prisma/client. ESLint lo 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 el repository contra Prisma, mapeando filas ↔ aggregates. Agregá el modelo Prisma a prisma/schema.prisma y corré una migration.

5. Contract

En 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á desde 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;
};

Montá en bootstrap/app.ts bajo /api/projects.

7. Cablear DI

En bootstrap/container.ts, registrá el repo, los use cases, y el controller. El container es tipado — los typos fallan el build.

8. Permissions

Agregá al recurso projects en packages/shared/src/permissions/index.ts:

export const RESOURCES = [
  /* ... */,
  'projects',
] as const;

El catálogo auto-genera projects:create, projects:read, projects:update, projects:delete. Mapealos a roles en el seed (ver seed and test).

9. Regenerar la API surface

bun run generate:api

Inspeccioná el diff. Commiteá ambos archivos regenerados.

10. Frontend

// apps/client/lib/api/projects.ts
import { api } from './client';

export const createProject = (name: string) =>
  api.POST('/api/projects', { body: { name } });

Los schemas para forms vienen de @app/contracts. No redefinas.

11. Test

  • Domain: tests unitarios puros contra Project.create, Project.archive cubriendo invariantes.
  • Use case: fake repo en memoria + InMemoryEventBus, asegurate que los eventos se publican.
  • HTTP: supertest contra el router vivo con un Prisma test database (o un container sqlite si tenés uno).

Ver seed and test.

12. Commit

Conventional commit, scope = el contexto nuevo:

feat(projects): add Project aggregate, CRUD endpoints, contracts (#NNN)

Si tu edit disparó un diff de OpenAPI, la regen va en el mismo commit.

En esta página