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
FooServicenã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)
- Escolha um caso de uso crítico (ex.:
RegisterUser). - 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.
- Dado e-mail ainda não cadastrado, quando registro, então usuário é criado e evento
- Escreva testes de domínio para as regras (ex.:
PasswordPolicy). - Faça fakes/mocks para repositórios (ports).
- Implemente o mínimo para passar o teste.
- 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_EMAILsurgem na criação do VO, não emifs 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:
- Escreva teste que verifica que o evento foi publicado.
- 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
- Criar
docs/ubiquitous-language.md(mínimo viável). - Esqueleto de pastas por camadas (Domain, Application, Infrastructure, Interface).
- Definir um caso de uso prioritário e escrever teste de aplicação.
- Escrever VOs essenciais (e-mail, senha, etc.) com testes de domínio.
- Implementar o caso de uso com repositório fake e depois o adaptador real (Prisma/TypeORM).
- Conectar o caso de uso ao controller Nest.
- Adicionar testes de integração para o repositório real.
- 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.