Domain-Driven Design na Prática com TypeScript e Prisma

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
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 sistema já foi validada. Não existe Email inválido circulando pelo código.
Entity e Aggregate: o pedido que protege suas próprias regras
Uma Entity tem identidade (ID). Um Aggregate é uma Entity raiz que controla a consistência de um grupo de objetos. O exemplo clássico: Order é o Aggregate Root, OrderItem é uma Entity filha. Ninguém adiciona item ao pedido por fora do Order.
// src/domain/entities/order-item.ts
import { Money } from "../value-objects/money";
export class OrderItem {
constructor(
public readonly id: string,
public readonly productId: string,
public readonly productName: string,
private _quantity: number,
public readonly unitPrice: Money
) {
if (_quantity <= 0) {
throw new Error("Quantidade precisa ser maior que zero");
}
}
get quantity(): number {
return this._quantity;
}
get subtotal(): Money {
return this.unitPrice.multiply(this._quantity);
}
}// src/domain/entities/order.ts
import { OrderItem } from "./order-item";
import { Email } from "../value-objects/email";
import { Money } from "../value-objects/money";
import { randomUUID } from "node:crypto";
export type OrderStatus = "draft" | "confirmed" | "cancelled";
export class Order {
private _items: OrderItem[] = [];
private _status: OrderStatus = "draft";
private constructor(
public readonly id: string,
public readonly customerEmail: Email,
public readonly createdAt: Date
) {}
static create(customerEmail: Email): Order {
return new Order(randomUUID(), customerEmail, new Date());
}
// Reconstitui a partir do banco sem revalidar (os dados já foram validados na criação)
static reconstitute(
id: string,
customerEmail: Email,
status: OrderStatus,
items: OrderItem[],
createdAt: Date
): Order {
const order = new Order(id, customerEmail, createdAt);
order._status = status;
order._items = items;
return order;
}
addItem(productId: string, productName: string, quantity: number, unitPrice: Money): void {
if (this._status !== "draft") {
throw new OrderAlreadyConfirmedError(this.id);
}
// Regra de negócio: máximo 20 itens por pedido (limite operacional de logística)
if (this._items.length >= 20) {
throw new Error("Pedido não pode ter mais de 20 itens");
}
const existing = this._items.find((i) => i.productId === productId);
if (existing) {
throw new Error(`Produto ${productId} já está no pedido. Remova e adicione novamente.`);
}
this._items.push(new OrderItem(randomUUID(), productId, productName, quantity, unitPrice));
}
confirm(): void {
if (this._items.length === 0) {
throw new Error("Não é possível confirmar pedido sem itens");
}
this._status = "confirmed";
}
get total(): Money {
return this._items.reduce(
(sum, item) => sum.add(item.subtotal),
Money.fromCents(0)
);
}
get status(): OrderStatus {
return this._status;
}
get items(): ReadonlyArray<OrderItem> {
return [...this._items];
}
}
export class OrderAlreadyConfirmedError extends Error {
constructor(orderId: string) {
super(`Pedido ${orderId} já foi confirmado e não aceita alterações`);
this.name = "OrderAlreadyConfirmedError";
}
}Repare: Order não importa nada do Prisma. Ela não sabe se vai ser salva em Postgres, MongoDB ou arquivo JSON. As regras de negócio (máximo de itens, não adicionar em pedido confirmado, pedido vazio não confirma) vivem dentro do Aggregate.
Repository: a ponte entre domínio e Prisma
O Repository é uma interface definida no domínio e implementada na infraestrutura. O domínio diz "preciso salvar e buscar pedidos". A infraestrutura diz "eu faço isso com Prisma".
// src/domain/repositories/order-repository.ts
import { Order } from "../entities/order";
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
findByCustomerEmail(email: string): Promise<Order[]>;
}A implementação com Prisma faz a tradução entre o modelo de domínio e o modelo de persistência. Esse é o único lugar onde os tipos do Prisma aparecem.
// src/infrastructure/repositories/prisma-order-repository.ts
import { PrismaClient } from "@prisma/client";
import { OrderRepository } from "../../domain/repositories/order-repository";
import { Order } from "../../domain/entities/order";
import { OrderItem } from "../../domain/entities/order-item";
import { Email } from "../../domain/value-objects/email";
import { Money } from "../../domain/value-objects/money";
export class PrismaOrderRepository implements OrderRepository {
constructor(private readonly prisma: PrismaClient) {}
async save(order: Order): Promise<void> {
// Upsert para lidar tanto com criação quanto atualização
await this.prisma.order.upsert({
where: { id: order.id },
create: {
id: order.id,
customerEmail: order.customerEmail.toString(),
status: order.status,
createdAt: order.createdAt,
items: {
create: order.items.map((item) => ({
id: item.id,
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
unitPriceCents: item.unitPrice.toCents(),
})),
},
},
update: {
status: order.status,
items: {
// deleteMany + create é simples e correto para aggregates pequenos
deleteMany: {},
create: order.items.map((item) => ({
id: item.id,
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
unitPriceCents: item.unitPrice.toCents(),
})),
},
},
});
}
async findById(id: string): Promise<Order | null> {
const data = await this.prisma.order.findUnique({
where: { id },
include: { items: true },
});
if (!data) return null;
return this.toDomain(data);
}
async findByCustomerEmail(email: string): Promise<Order[]> {
const records = await this.prisma.order.findMany({
where: { customerEmail: email },
include: { items: true },
});
return records.map((r) => this.toDomain(r));
}
// Método privado de mapeamento: toda tradução Prisma -> Domain fica aqui
private toDomain(data: any): Order {
const items = data.items.map(
(i: any) =>
new OrderItem(
i.id,
i.productId,
i.productName,
i.quantity,
Money.fromCents(i.unitPriceCents)
)
);
return Order.reconstitute(
data.id,
Email.create(data.customerEmail),
data.status,
items,
data.createdAt
);
}
}O deleteMany + create nos itens é uma simplificação deliberada. Para aggregates com poucos itens (como um pedido com até 20 itens), essa abordagem é mais simples e menos propensa a bugs do que rastrear diffs entre itens novos, removidos e atualizados. Se o aggregate tiver centenas de filhos, aí sim vale implementar change tracking. Sobre como lidar com migrations nesse schema, veja Database Migrations Seguras em Produção com Prisma.
Use Case: orquestrando sem lógica de negócio
O Use Case (ou Application Service) orquestra: busca dados, chama métodos do domínio, persiste. Não contém regras de negócio.
// src/application/use-cases/create-order.ts
import { OrderRepository } from "../../domain/repositories/order-repository";
import { Order } from "../../domain/entities/order";
import { Email } from "../../domain/value-objects/email";
import { Money } from "../../domain/value-objects/money";
interface CreateOrderInput {
customerEmail: string;
items: Array<{
productId: string;
productName: string;
quantity: number;
unitPriceCents: number;
}>;
}
interface CreateOrderOutput {
orderId: string;
totalCents: number;
}
export class CreateOrderUseCase {
constructor(private readonly orderRepo: OrderRepository) {}
async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
const email = Email.create(input.customerEmail);
const order = Order.create(email);
for (const item of input.items) {
order.addItem(
item.productId,
item.productName,
item.quantity,
Money.fromCents(item.unitPriceCents)
);
}
order.confirm();
await this.orderRepo.save(order);
return {
orderId: order.id,
totalCents: order.total.toCents(),
};
}
}Perceba que o Use Case não faz validação de email (o Value Object faz), não verifica limite de itens (o Aggregate faz), não calcula total (a Entity faz). Ele só conecta as peças. Essa separação facilita testes unitários: você testa o domínio sem banco, sem HTTP, sem nada de infraestrutura.
Para expor esse use case via API HTTP, a camada de controller recebe o request, monta o input DTO e chama o use case. Se você está construindo APIs com Node.js, o post Node.js Backend 2026: APIs reais que não quebram em produção cobre a estruturação da camada HTTP. E para garantir que a comunicação entre camadas seja tipada de ponta a ponta, veja API Layers em Aplicações Fullstack.
O que NÃO fazer
Anti-pattern 1: regra de negócio no repository
// ERRADO: o repository decide se o pedido pode ser confirmado
async confirmOrder(orderId: string): Promise<void> {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { items: true },
});
if (!order) throw new Error("Pedido não encontrado");
if (order.items.length === 0) throw new Error("Sem itens");
if (order.status !== "draft") throw new Error("Já confirmado");
await this.prisma.order.update({
where: { id: orderId },
data: { status: "confirmed" },
});
}Esse código funciona, mas a regra "pedido sem itens não confirma" agora vive no repository. Quando outro desenvolvedor precisar da mesma regra em outro contexto (uma fila de processamento, um job de background), vai duplicar a lógica ou, pior, esquecer de aplicá-la.
// CORRETO: o domínio decide, o repository só persiste
const order = await this.orderRepo.findById(orderId);
if (!order) throw new Error("Pedido não encontrado");
order.confirm(); // toda validação está aqui dentro
await this.orderRepo.save(order);Anti-pattern 2: usar tipos do Prisma como modelo de domínio
// ERRADO: o service recebe e retorna tipos do Prisma
import { Order as PrismaOrder } from "@prisma/client";
function calculateDiscount(order: PrismaOrder & { items: PrismaOrderItem[] }): number {
// agora a regra de desconto depende da estrutura do banco
return order.items.reduce((sum, i) => sum + i.unitPriceCents * i.quantity, 0) * 0.1;
}Renomear unitPriceCents para priceInCents no schema do Prisma quebra essa função e todas as outras que usam o tipo gerado. Com um Value Object Money e uma Entity Order, a mudança no schema afeta apenas o repository.
Anti-pattern 3: Value Object anêmico
// ERRADO: Value Object que é só um wrapper sem validação
class Email {
constructor(public readonly value: string) {}
// nenhuma validação, nenhum comportamento
// isso é um type alias disfarçado, não um Value Object
}Se o Value Object não valida e não tem comportamento, use um branded type ou um simples string. Criar uma classe vazia adiciona complexidade sem benefício.
Quando DDD com Prisma compensa (e quando não compensa)
| Cenário | Recomendação |
|---|---|
| CRUD simples, poucas regras, equipe pequena | Use Prisma direto no service. DDD é overhead. |
| Regras de negócio complexas com invariantes | DDD no domínio, Prisma só no repository. |
| Múltiplas fontes de dados (Prisma + API externa + fila) | DDD isola o domínio de todas as integrações. |
| Projeto com testes unitários sérios | DDD facilita: teste o domínio sem banco. |
| Microserviço com 2-3 endpoints e lógica trivial | Não. O custo de mapeamento não se paga. |
O custo real do DDD com Prisma é o mapeamento manual entre modelo de domínio e modelo de persistência. Cada campo novo no schema exige atualização no repository, no toDomain e no save. Esse custo é fixo e previsível, mas existe. Aceite-o conscientemente ou não use DDD.
Se você está usando feature flags para liberar funcionalidades gradualmente, o Use Case é o lugar natural para checar a flag: antes de executar a lógica de domínio, não dentro dela.
FAQ
DDD obriga a usar classes em TypeScript?
Não. Você pode implementar Value Objects como funções factory que retornam objetos congelados (Object.freeze) e Entities como plain objects com funções que operam sobre eles. Classes são convenientes por encapsular estado e comportamento, mas não são requisito. O que importa é que a validação e as regras vivam no domínio, não na infraestrutura.
Preciso de um Domain Event para cada operação?
Domain Events são úteis quando uma ação no domínio precisa disparar efeitos colaterais (enviar email, atualizar outro aggregate, publicar em fila). Se o fluxo é linear e síncrono (cria pedido, salva, retorna), eventos adicionam complexidade sem ganho. Comece sem eventos, adicione quando o primeiro efeito colateral real aparecer.
O Prisma schema deve espelhar o modelo de domínio?
Não necessariamente. O schema do Prisma reflete a estrutura do banco, que pode divergir do domínio por questões de performance (desnormalização, índices compostos, colunas JSON). O repository faz a tradução entre os dois modelos. Forçar o banco a espelhar o domínio 1:1 é tão ruim quanto forçar o domínio a espelhar o banco.
Como testar o domínio sem banco de dados?
Crie uma implementação in-memory do repository para testes. Como OrderRepository é uma interface, você pode implementar InMemoryOrderRepository que armazena em um Map<string, Order>. Os testes do domínio e dos use cases rodam em milissegundos, sem Docker, sem migrations, sem seed.
DDD funciona com Prisma em projetos serverless?
Funciona. A separação de camadas não depende do modelo de deploy. Em ambientes serverless como Cloudflare Workers ou funções em Postgres serverless com Neon, o cold start do Prisma Client é a preocupação real, não o DDD em si. As classes de domínio são objetos JavaScript leves que não adicionam overhead mensurável.
A posição que defendo
DDD não é uma arquitetura que você "adota" inteira ou descarta. É um conjunto de padrões. Pegue o que resolve seu problema e ignore o resto. Value Objects para validação e imutabilidade? Útil em qualquer projeto com mais de dois formulários. Aggregate Root para proteger invariantes de negócio? Só se as invariantes existirem de verdade. Bounded Contexts e Context Maps? Só em sistemas com múltiplos domínios e equipes.
O erro mais caro não é implementar DDD errado. É acoplar regras de negócio ao ORM e só descobrir o problema quando precisar trocar de banco, adicionar uma fila de processamento ou escrever o primeiro teste unitário que não depende de um container Postgres rodando. Separar domínio de infraestrutura é uma decisão que custa pouco hoje e economiza refatorações inteiras depois.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Guias de integração relacionados
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


