O Domain-Driven Design no Node.js, ou DDD, organiza software complexo ao redor do conhecimento do negócio. Em vez de modelar apenas tabelas, endpoints e serviços técnicos, a equipe constrói uma linguagem comum, delimita contextos e cria objetos que representam regras reais.
DDD não é uma estrutura de pastas nem exige microserviços. Ele é uma abordagem de colaboração e modelagem. Técnicas como entidades, value objects, agregados, repositories e eventos ajudam a proteger invariantes e tornar o código mais próximo da forma como especialistas descrevem o problema.
Neste guia, você aprenderá linguagem ubíqua, bounded contexts, entidades, value objects, agregados, serviços de domínio, repositories, eventos, factories, testes e integração com arquitetura hexagonal.
O que é Domain-Driven Design?
DDD foi sistematizado por Eric Evans no livro Domain-Driven Design. A abordagem é especialmente útil quando regras, exceções e processos do negócio são mais difíceis que a tecnologia usada.
O objetivo não é criar classes sofisticadas para CRUD simples. DDD concentra esforço nas partes que diferenciam o produto e exigem entendimento profundo.
Para organizar dependências, consulte Arquitetura Hexagonal no Node.js. Para casos de uso, veja Service Layer no Node.js.
Domínio e subdomínios
O domínio é a área de conhecimento do sistema. Ele pode ser dividido em:
- Core Domain: capacidade que diferencia o negócio.
- Supporting Subdomain: necessário, mas não diferencial.
- Generic Subdomain: problema comum, como autenticação ou envio de e-mail.
Invista mais modelagem no core domain. Soluções prontas podem atender partes genéricas.
Linguagem ubíqua
Desenvolvedores e especialistas usam os mesmos termos em reuniões, documentação, testes e código.
Se o negócio diz “reservar estoque”, o código pode possuir:
inventory.reserve(order.items);Evite nomes técnicos vagos como processData ou updateRecord.
Glossário vivo
Registre termos, exemplos e diferenças:
- pedido versus carrinho;
- cancelamento versus estorno;
- cliente versus conta;
- reserva versus venda;
- pagamento autorizado versus capturado.
O glossário evolui com o modelo.
Bounded Context
Um bounded context delimita onde um modelo e uma linguagem são válidos. A palavra “produto” pode significar item comercial em catálogo e unidade física em estoque.
catalog.Product
inventory.StockItemNão force uma única classe universal para conceitos diferentes.
Context Map
O mapa mostra relações entre contextos:
- Customer/Supplier;
- Conformist;
- Anti-Corruption Layer;
- Shared Kernel;
- Open Host Service;
- Published Language.
Ele ajuda a decidir contratos e responsabilidades.
Entidade
Entidade possui identidade que permanece ao longo do tempo:
export class Order {
private constructor(
readonly id: OrderId,
private status: OrderStatus,
private items: OrderItem[]
) {}
addItem(product: ProductId, quantity: Quantity) {
if (this.status !== 'draft') {
throw new OrderAlreadySubmittedError();
}
this.items.push(
OrderItem.create(product, quantity)
);
}
}Duas entidades com o mesmo conteúdo ainda são diferentes se possuem IDs distintos.
Value Object
Value Object é definido pelos valores e normalmente é imutável:
export class Money {
private constructor(
readonly cents: number,
readonly currency: string
) {}
static create(cents: number, currency: string) {
if (!Number.isSafeInteger(cents)) {
throw new InvalidMoneyError();
}
return new Money(cents, currency);
}
add(other: Money): Money {
if (other.currency !== this.currency) {
throw new CurrencyMismatchError();
}
return Money.create(
this.cents + other.cents,
this.currency
);
}
}Consulte o artigo específico sobre Value Objects desta mesma sequência quando publicado.
Agregado
Agregado é um conjunto de objetos tratados como unidade de consistência. Uma entidade é a raiz e controla alterações internas.
order.addItem(productId, quantity);
order.removeItem(productId);
order.submit();Não altere order.items diretamente.
Aggregate Root
Somente a raiz é referenciada externamente. Outros agregados usam IDs:
class Order {
readonly customerId: CustomerId;
}Evite carregar o objeto Customer inteiro dentro de Order se pertencem a agregados separados.
Invariantes
Invariante é uma condição que deve permanecer verdadeira:
- pedido enviado possui pelo menos um item;
- quantidade é positiva;
- total não pode ser negativo;
- pagamento capturado não pode ser capturado novamente;
- estoque reservado não supera disponibilidade.
A raiz protege essas regras.
Tamanho do agregado
Agregados grandes aumentam contenção e carregamento. Prefira fronteiras pequenas e consistência forte somente onde é necessária.
Consistência entre agregados
Use eventos e processos assíncronos quando a atualização imediata não é obrigatória. Pedido e envio podem evoluir em transações separadas.
Repository por agregado
export interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
}Repository trabalha com raízes, não com cada tabela interna. Consulte Repository Pattern no Node.js.
Factory
Uma factory encapsula criação complexa:
export class OrderFactory {
constructor(
private readonly ids: IdGenerator,
private readonly clock: Clock
) {}
create(input: NewOrderInput): Order {
return Order.create({
id: OrderId.from(this.ids.next()),
createdAt: this.clock.now(),
customerId: input.customerId,
items: input.items
});
}
}Criação simples pode ficar em método estático.
Serviço de domínio
Use quando uma regra não pertence naturalmente a uma entidade:
export class ShippingPriceService {
calculate(
order: Order,
destination: Address,
policy: ShippingPolicy
): Money {
// regra entre conceitos
}
}Evite serviços de domínio que apenas chamam repositories.
Serviço de aplicação
Coordena o caso de uso:
export class SubmitOrder {
constructor(
private readonly orders: OrderRepository,
private readonly uow: UnitOfWork
) {}
execute(input: SubmitOrderInput) {
return this.uow.run(async tx => {
const order = await tx.orders.findById(
OrderId.from(input.orderId)
);
if (!order) throw new OrderNotFoundError();
order.submit();
await tx.orders.save(order);
await tx.outbox.addMany(order.pullEvents());
});
}
}O serviço não implementa a invariante; chama a entidade.
Evento de domínio
export class OrderSubmitted {
constructor(
readonly orderId: string,
readonly customerId: string,
readonly occurredAt: Date
) {}
}O evento descreve algo que aconteceu no domínio.
Registro de eventos
private events: DomainEvent[] = [];
private record(event: DomainEvent) {
this.events.push(event);
}
pullEvents(): DomainEvent[] {
const events = [...this.events];
this.events = [];
return events;
}Outbox
Persista evento e agregado na mesma transação. Consulte Outbox Pattern no Node.js.
Domain Event versus Integration Event
Evento de domínio é interno ao modelo. Um adapter pode convertê-lo em evento de integração estável:
OrderSubmitted -> orders.order-submitted.v1Isso evita expor detalhes internos para outros serviços.
Anti-Corruption Layer
Uma ACL traduz um sistema externo para conceitos internos:
class LegacyCustomerAdapter {
toCustomerProfile(response: LegacyResponse) {
return CustomerProfile.create({
customerId: response.cod_cliente,
riskLevel: mapRisk(response.classe)
});
}
}O domínio não passa a usar os nomes do legado.
Modelo anêmico
Uma classe com apenas getters e setters, enquanto todas as regras vivem em services, perde parte do valor de DDD.
// evitar
order.status = 'approved';Prefira:
order.approve();Persistência e domínio
Entidades não precisam ser modelos do ORM. Um mapper converte entre representação do banco e objetos do domínio.
Reconstituição
Order.restore({
id,
status,
items,
version
});O método de restore não deve executar regras de criação novamente.
Concorrência
Agregados podem usar versão para lock otimista:
UPDATE orders
SET status = $1,
version = version + 1
WHERE id = $2
AND version = $3;Consulte Lock Otimista no Node.js.
Unit of Work
Vários repositories e outbox podem compartilhar transação. Consulte Unit of Work no Node.js.
Especificações
Uma Specification representa regra combinável:
const eligible = activeCustomer
.and(noOverdueInvoices)
.and(withinCreditLimit);Use quando a regra é reutilizada e merece nome; não transforme toda condição simples em classe.
Policy
Policies representam decisões variáveis:
interface DiscountPolicy {
calculate(order: Order, customer: Customer): Percentage;
}Implementações podem variar por campanha ou segmento.
Módulos
src/
├── ordering/
├── billing/
├── inventory/
└── shipping/Cada módulo pode representar um bounded context em um monólito modular.
DDD não exige microserviços
Bounded contexts podem existir no mesmo processo. Separar em serviços adiciona rede, consistência eventual e operação. Faça isso somente quando houver benefício organizacional ou técnico.
Contextos em microserviços
Quando separados, use contratos explícitos, eventos versionados e anti-corruption layers. Não compartilhe o mesmo schema de banco.
Descoberta do modelo
Use conversas com especialistas, Event Storming, exemplos e análise de decisões. O código é resultado do aprendizado, não o ponto inicial.
Event Storming
Mapeie:
- eventos;
- comandos;
- atores;
- policies;
- sistemas externos;
- hotspots;
- read models.
O exercício revela linguagem e limites.
Testes de domínio
test('pedido vazio não pode ser enviado', () => {
const order = Order.draft({ items: [] });
assert.throws(
() => order.submit(),
EmptyOrderError
);
});Testes são rápidos e não usam banco.
Testes com exemplos do negócio
Use nomes e números reais:
test('cliente premium recebe 10% acima de 500 reais')Exemplos fortalecem a linguagem ubíqua.
Testes de repository
Confirme que reconstituição preserva invariantes, versionamento e tipos. Use banco real para SQL e transações.
Observabilidade por contexto
Nomeie logs e métricas com linguagem do domínio, como orders.submitted e payments.captured, sem transformar IDs em labels.
Quando DDD ajuda
- regras mudam frequentemente;
- muitos termos possuem significado específico;
- várias equipes trabalham em áreas diferentes;
- existem processos longos e exceções;
- erros de negócio são caros;
- o modelo é vantagem competitiva.
Quando simplificar
Um CRUD administrativo pequeno pode usar camadas simples. Aplicar agregados, factories e eventos sem complexidade real cria cerimônia.
Erros comuns
- DDD como pastas: não há colaboração com especialistas.
- Uma entidade por tabela: banco define o modelo.
- Agregado gigante: contenção aumenta.
- Modelo anêmico: regras ficam em services genéricos.
- Eventos para tudo: complexidade cresce sem necessidade.
- Microserviços prematuros: operação domina o domínio.
- Linguagem inconsistente: código e negócio divergem.
Boas práticas
- Invista no core domain.
- Construa linguagem ubíqua.
- Delimite contextos.
- Proteja invariantes nos agregados.
- Use value objects.
- Defina repositories por raiz.
- Separe domínio e infraestrutura.
- Use eventos com propósito.
- Teste exemplos do negócio.
- Simplifique subdomínios genéricos.
Conclusão
O Domain-Driven Design no Node.js aproxima o software do conhecimento do negócio. Linguagem ubíqua, bounded contexts, agregados e value objects tornam regras explícitas e reduzem ambiguidades.
DDD traz mais valor em domínios complexos, não em todo CRUD. Quando combinado com arquitetura hexagonal, repositories, Unit of Work e eventos transacionais, ele protege o core sem prender a aplicação a frameworks ou bancos específicos.


