Executando verificação de segurança...
3

Iniciando um projeto TDD + DDD com SRP em NestJS (guia teórico com pitadas práticas)

Resumo: Neste artigo explico, de forma mais conceitual do que prática, como começo projetos em NestJS guiados por TDD (Test-Driven Development), DDD (Domain-Driven Design) e SRP (Single Responsibility Principle). Trago os “porquês”, vantagens, desvantagens, trade-offs e um fio condutor com exemplos mínimos para quem quer aplicar com calma — sem transformar o projeto em um ritual pesado.


Por que TDD, DDD e SRP juntos?

  • TDD força feedback rápido, clareza do contrato e reduz regressões. O valor real não é “ter testes”, mas pensar no design via testes.
  • DDD foca no coração do negócio. Em vez de modelar o framework, modelamos o domínio com linguagem ubíqua, agregados, entidades e casos de uso.
  • SRP evita classes/arquivos “faz-tudo”. Cada componente tem um motivo único para mudar, facilitando refatorações e testes.

Sinergia: TDD conduz o design; DDD organiza o código ao redor do domínio; SRP mantém as peças pequenas. Em NestJS isso resulta em camadas claras: Domain ➝ Application ➝ Infrastructure ➝ Interface (HTTP/CLI/etc.).


Desvantagens e cuidados (a honestidade que quase ninguém escreve)

  • TDD pode parecer lento no início e desmotivador se a equipe não entende o propósito. Testes mal escritos viram custo.
  • DDD pode ser exagerado em domínios simples. Criar agregados e bounded contexts para CRUD trivial é desperdício.
  • SRP pode ser hiperfragmentado se levado ao extremo, gerando “tempestade de arquivos” e navegação difícil.
  • Em NestJS, é comum cair no antipadrão de “service gigantesco” e achar que isso é DDD. Não é: um FooService não é “domínio”; é só um contêiner de métodos.

A chave é proporcionalidade: aplique mais DDD onde há complexidade de negócio; menos onde é I/O e boilerplate.


Organização de camadas no NestJS (sem dogmas)

Um arranjo que costuma funcionar:

src/
  domain/
    user/
      entities/
      value-objects/
      services/           # Regras de negócio puras (domain services)
      events/
      repositories/       # Interfaces (ports)
      errors/
  application/
    user/
      use-cases/          # Orquestram regras do domínio
      dto/                # Contratos de entrada/saída dos casos de uso
  infrastructure/
    persistence/
      prisma/             # Ou TypeORM/Knex/etc.
        user/
          user.prisma.repository.ts  # Adapta a interface do domínio
    mappers/
    mail/
    cache/
  interface/
    http/
      user/
        user.module.ts
        user.controller.ts
        user.presenter.ts
    cli/
  shared/
    utils/
    types/
    testing/

Regras:

  • Domain não importa Nest. É Typescript puro. Sem decorators, sem HttpException.
  • Application orquestra casos de uso e conversa com o domínio. Também sem Nest.
  • Infrastructure adapta ferramentas (ORM, cache, e-mail) às interfaces (ports) do domínio.
  • Interface é onde entra o Nest (controllers, modules, guards, interceptors).

Linguagem ubíqua e limites de contexto

Antes de qualquer linha de código, nomeie bem:

  • Usuário: é “Cliente”, “Jogador”, “Administrador”? Cada papel pode virar um bounded context distinto.
  • Cadastro: é Registro? Onboarding? Essas palavras moldam entidades e eventos.

Dica prática: comece o repositório com um docs/ubiquitous-language.md simples. Isso evita debates infinitos depois.


SRP na prática (e como evitar o “service gordo”)

Em Nest é comum ver:

// antipadrão comum
@Injectable()
export class UsersService {
  create() {}
  update() {}
  delete() {}
  resetPassword() {}
  sendWelcomeEmail() {}
  // ... 1000 linhas depois
}

SRP sugere separar motivos de mudança:

  • Casos de uso (Application): RegisterUser, ChangePassword, DeactivateUser.
  • Regras puras (Domain services): PasswordPolicy, EmailUniquenessChecker.
  • Infra: PrismaUserRepository, MailerSendgrid.

Assim o “service” vira composição no módulo e o controller só chama um caso de uso. Cada classe pequena, clara, com testes fáceis.


Como eu inicio com TDD (ciclo pequeno e concreto)

  1. Escolha um caso de uso crítico (ex.: RegisterUser).
  2. Escreva um teste de unidade de Application para o caso de uso:
    • Dado e-mail ainda não cadastrado, quando registro, então usuário é criado e evento UserRegistered é publicado.
  3. Escreva testes de domínio para as regras (ex.: PasswordPolicy).
  4. Faça fakes/mocks para repositórios (ports).
  5. Implemente o mínimo para passar o teste.
  6. Refatore nomes, extraia value objects, limpe o ruído.

Exemplo mínimo (Application test-first)

// src/application/user/use-cases/register-user.spec.ts
import { RegisterUser } from './register-user';
import { InMemoryUserRepo } from '../../../shared/testing/in-memory-user-repo';
import { Email } from '../../../domain/user/value-objects/email';

describe('RegisterUser', () => {
  it('cria usuário quando e-mail é novo e respeita política de senha', async () => {
    const repo = new InMemoryUserRepo();
    const useCase = new RegisterUser(repo /*, passwordPolicy, eventBus */);

    const result = await useCase.execute({
      email: '[email protected]',
      password: 'Str0ng!Pass',
      name: 'Alice',
    });

    expect(result.isOk()).toBe(true);
    const saved = await repo.findByEmail(Email.create('[email protected]'));
    expect(saved).toBeDefined();
  });
});

Implementação guiada (mínima)

// src/application/user/use-cases/register-user.ts
import { IUserRepository } from '../../../domain/user/repositories/user-repository';
import { Email } from '../../../domain/user/value-objects/email';
import { User } from '../../../domain/user/entities/user';

export class RegisterUser {
  constructor(private readonly users: IUserRepository) {}

  async execute(input: { email: string; password: string; name: string }) {
    const email = Email.create(input.email);             // VO valida formato
    const exists = await this.users.findByEmail(email);
    if (exists) return { isOk: () => false, error: 'EMAIL_TAKEN' };

    const user = User.register({ email, password: input.password, name: input.name });
    await this.users.save(user);
    return { isOk: () => true, value: user };
  }
}

Domínio enxuto (sem Nest)

// src/domain/user/value-objects/email.ts
export class Email {
  private constructor(public readonly value: string) {}
  static create(raw: string) {
    if (!/^\S+@\S+\.\S+$/.test(raw)) throw new Error('INVALID_EMAIL');
    return new Email(raw.toLowerCase());
  }
}

Note: Sem decorators, sem exceptions HTTP. O domínio fala a linguagem do negócio.


Onde entram os testes de integração e end-to-end?

  • Unidade (rápidos): domínio e casos de uso. Sem DB real.
  • Integração (médios): adaptadores de infra com DB/cache, para validar mapeamentos e transações.
  • E2E (mais lentos): controladores Nest + pipeline real (auth, pipes, filters). Apenas fluxos críticos.

Pense em pirâmide de testes: muito unitário, menos integração, poucos E2E. Evite “globo de neve” de E2E que quebram por qualquer detalhe.


Repositórios como portas (Ports & Adapters)

Defina interfaces no domínio:

// src/domain/user/repositories/user-repository.ts
import { Email } from '../value-objects/email';
import { User } from '../entities/user';

export interface IUserRepository {
  findByEmail(email: Email): Promise<User | null>;
  save(user: User): Promise<void>;
}

Implemente na infraestrutura:

// src/infrastructure/persistence/prisma/user/user.prisma.repository.ts
import { PrismaClient } from '@prisma/client';
import { IUserRepository } from '../../../../domain/user/repositories/user-repository';

export class PrismaUserRepository implements IUserRepository {
  constructor(private readonly prisma: PrismaClient) {}

  async findByEmail(email) {
    const row = await this.prisma.user.findUnique({ where: { email: email.value } });
    return row ? /* map row -> domain User */ null : null;
  }

  async save(user) {
    await this.prisma.user.upsert({
      where: { email: user.email.value },
      create: { email: user.email.value, name: user.name, password_hash: user.passwordHash },
      update: { name: user.name, password_hash: user.passwordHash },
    });
  }
}

Vantagem: o domínio não sabe que Prisma existe. Trocar ORM é chato, mas não é traumático.


Controllers e Modules (o mínimo necessário)

No controller, chame um caso de uso por endpoint, converta DTOs e projete a resposta:

// src/interface/http/user/user.controller.ts
@Post()
async register(@Body() dto: RegisterUserDto) {
  const result = await this.registerUser.execute(dto);
  if (!result.isOk()) throw new ConflictException('Email already taken');
  return this.presenter.present(result.value);
}

SRP aqui: o controller não contém regra de negócio. Ele traduz a fronteira HTTP.


Erros, validação e consistência

  • No domínio, prefira tipos/VOs que impedem estado inválido. Erros como INVALID_EMAIL surgem na criação do VO, não em ifs espalhados.
  • Na aplicação, converta erros do domínio para result types (Ok/Err) ou exceções de aplicação.
  • Na interface, traduza tudo para HTTP (422, 409, 404, 401).

Isso separa as preocupações e estabiliza testes: não é o domínio que sabe de 422/409.


Eventos de domínio e consistência eventual

Casos de uso podem publicar eventos (UserRegistered) para reagir (enviar e-mail, indexar em busca). Em TDD:

  1. Escreva teste que verifica que o evento foi publicado.
  2. Implemente usando um EventBus abstrato no domínio/app e um adaptador na infra (Nest EventEmitter, RabbitMQ, etc.).

Trade-off: mais indireção, porém menos acoplamento e melhor escalabilidade.


Quando não usar DDD/TDD/SRP com tanta força?

  • Admins simples, CRUD de catálogo, protótipos descartáveis.
  • Times sem maturidade de testes (comece pequeno: um caso de uso crítico).
  • Prazos ultra-curtos sem espaço para aprendizado.

Aplique gradiente de rigor: módulos complexos recebem DDD/TDD “completo”; módulos triviais, abordagem lean.


Medindo o que importa

  • Cobertura é métrica imperfeita. Melhor focar em casos de uso críticos cobertos, bugs evitados e tempo de refatoração reduzido.
  • Monitore Flaky Tests e ajuste a pirâmide (talvez E2E demais).
  • Revise periodicamente se arquitetura reflete a linguagem do domínio. Se o vocabulário do negócio mudou e o código não, há dívida.

Checklist rápido para começar

  1. Criar docs/ubiquitous-language.md (mínimo viável).
  2. Esqueleto de pastas por camadas (Domain, Application, Infrastructure, Interface).
  3. Definir um caso de uso prioritário e escrever teste de aplicação.
  4. Escrever VOs essenciais (e-mail, senha, etc.) com testes de domínio.
  5. Implementar o caso de uso com repositório fake e depois o adaptador real (Prisma/TypeORM).
  6. Conectar o caso de uso ao controller Nest.
  7. Adicionar testes de integração para o repositório real.
  8. Adicionar poucos E2E para os fluxos críticos.

Conclusão

Começar um projeto NestJS com TDD + DDD + SRP não é sobre seguir rituais; é sobre escolhas conscientes para manter o software maleável. TDD dá feedback e melhora o design, DDD mantém o foco no negócio, SRP evita monstros de acoplamento. Isso tem custo inicial — documentação mínima, testes, disciplina de nomes —, mas a velocidade sustentável no médio prazo compensa.

Se o seu domínio é realmente desafiador (regras mutantes, invariantes fortes, múltiplos contextos), esse trio forma uma base sólida. E se não for… aplique na dose certa. O melhor design é o que serve ao produto — com clareza, simplicidade e espaço para crescer.

Carregando publicação patrocinada...