Domain-Driven Design na Prática com TypeScript e Prisma
O Prisma gera tipos a partir do schema. Esses tipos vazam para controllers, services, validações e até para o frontend. Em pouco tempo, o modelo do banco de dados vira o modelo do negócio: uma coluna renomeada quebra 40 arquivos. Esse é o sintoma de domínio acoplado à infraestrutura.
Domain-Driven Design resolve exatamente isso: isola as regras de negócio em objetos que não sabem (e não precisam saber) que o Prisma existe. O problema é que a maioria dos tutoriais de DDD em TypeScript cria 15 camadas de abstração para um CRUD de TODO app. O resultado é uma arquitetura astronauta que ninguém quer manter.
Este post aplica DDD de forma pragmática: Value Objects, Entities, Aggregates e Repositories com TypeScript e Prisma, sem inventar camadas desnecessárias, com código que compila e roda.
Onde o Prisma termina e o domínio começa
O Prisma faz duas coisas muito bem: gera tipos a partir do schema e abstrai queries SQL. O erro é usar esses tipos gerados como modelos de negócio. Quando você passa um Prisma.OrderGetPayload<{include: {items: true}}> para dentro de uma função de cálculo de desconto, está dizendo que a regra de desconto depende da estrutura do banco.
A separação mínima viável é:
| Camada | Responsabilidade | Conhece o Prisma? |
|---|---|---|
| Domain (Entities, Value Objects, Aggregates) | Regras de negócio, invariantes, validações | Não |
| Application (Use Cases / Services) | Orquestra fluxo, chama repositórios | Não |
| Infrastructure (Repositories, Controllers) | Persiste, recebe HTTP, envia email | Sim |
Se seu projeto tem menos de 5 entidades e zero regras de negócio complexas, DDD é over-engineering. Use Prisma direto no service e siga em frente. DDD compensa quando existem invariantes reais: "pedido não pode ter valor negativo", "CPF precisa ser válido", "estoque não pode ficar abaixo de zero após reserva".
Value Objects: validação que não vaza
Value Objects são objetos imutáveis definidos pelo valor, não por identidade. CPF, Email, Money, Quantity: se dois têm o mesmo valor, são iguais. A validação mora dentro do Value Object, não espalhada em controllers e middlewares.
// src/domain/value-objects/email.ts
export class Email {
private constructor(private readonly value: string) {}
static create(raw: string): Email {
const trimmed = raw.trim().toLowerCase();
// Regex simples: validação real de email é feita por confirmação, não por regex perfeita
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
throw new InvalidEmailError(raw);
}
return new Email(trimmed);
}
toString(): string {
return this.value;
}
equals(other: Email): boolean {
return this.value === other.value;
}
}
export class InvalidEmailError extends Error {
constructor(raw: string) {
super(`Email inválido: "${raw}"`);
this.name = "InvalidEmailError";
}
}
// src/domain/value-objects/money.ts
export class Money {
// Armazena em centavos para evitar floating point (R$ 10,50 = 1050)
private constructor(private readonly cents: number) {}
static fromCents(cents: number): Money {
if (!Number.isInteger(cents) || cents < 0) {
throw new Error(`Valor monetário inválido: ${cents} centavos`);
}
return new Money(cents);
}
static fromReais(reais: number): Money {
return Money.fromCents(Math.round(reais * 100));
}
add(other: Money): Money {
return Money.fromCents(this.cents + other.cents);
}
multiply(factor: number): Money {
return Money.fromCents(Math.round(this.cents * factor));
}
isGreaterThan(other: Money): boolean {
return this.cents > other.cents;
}
toCents(): number {
return this.cents;
}
toReais(): number {
return this.cents / 100;
}
equals(other: Money): boolean {
return this.cents === other.cents;
}
}
O construtor é privado. A única forma de criar um Email ou Money é passando pela factory create ou fromCents. Isso garante que qualquer instância que exista no si
Leia o artigo completo em https://www.vivodecodigo.com.br/backend/ddd-pratica-typescript-prisma-domain-driven-design