Trilha 1 · Fundamentos · ~20 min
Num sistema de logística que peguei, existia uma classe Order de 2.300 linhas. Ela era usada pelo checkout, pelo faturamento, pela expedição e pelo suporte. Cada time queria um campo diferente nela. O time fiscal precisava de taxRegime; a expedição, de palletCount; o suporte, de refundReason. Toda mudança para um time quebrava os outros três. A Order não era um objeto — era um campo de batalha.
O erro não era técnico. Era conceitual: estávamos tratando quatro conceitos diferentes como se fossem um só, porque compartilhavam o nome "pedido". Para o faturamento, um pedido é um documento fiscal. Para a expedição, é uma lista de volumes. São modelos diferentes do mesmo negócio. DDD estratégico dá nome a essa percepção — e a Arquitetura Hexagonal dá a ela uma forma no código.
DDD estratégico: fronteiras antes de código
DDD tem duas metades. A tática (aggregates, entities, value objects, domain events) é a famosa, e fica para a trilha bônus. A estratégica é a que muda arquitetura, e é sobre uma pergunta só: onde ficam as fronteiras entre os pedaços do sistema?
O conceito central é o bounded context: uma fronteira dentro da qual um modelo e uma linguagem são consistentes. "Assinatura" dentro de Billing significa uma coisa (um contrato de cobrança recorrente, com ciclo, status, método de pagamento). "Assinatura" não existe em Content — lá o que importa é entitlement: "esse usuário pode assistir isso?". São contextos diferentes.
A ferramenta prática para achar fronteiras é a Linguagem Ubíqua: preste atenção em como o negócio fala. Quando a mesma palavra muda de significado dependendo de quem fala, você achou uma fronteira. Quando um grupo de conceitos só faz sentido junto e é discutido pelas mesmas pessoas, achou um contexto.
No FakeFlix, ouvindo o negócio, três linguagens emergem:
- Identity fala de "usuário", "login", "token", "sessão".
- Billing fala de "assinatura", "plano", "cobrança", "inadimplência".
- Content fala de "vídeo", "catálogo", "episódio", "resolução", "processamento".
Três linguagens → três bounded contexts → três pastas em src/module/. A estrutura de diretórios não é organização estética; é a materialização das fronteiras do negócio. É por isso que a árvore do FakeFlix começa assim:
src/module/
├── identity/
├── billing/
└── content/
Insight que vale a aula: as fronteiras dos seus módulos deveriam espelhar as fronteiras do seu negócio, não as camadas técnicas. Pastas
controllers/,services/,repositories/no topo agrupam por tecnologia. Pastasidentity/,billing/,content/agrupam por domínio. A segunda organização é a que resiste ao crescimento, porque o negócio muda por domínio, não por camada.
Subdomínios: coesão dentro do contexto
Dentro de um contexto pode haver subdivisões. Content tem admin/ (gestão do catálogo), catalog/ (o que o usuário navega) e video-processor/ (transcodificação). São subdomínios: coesos entre si, com responsabilidades distintas. Isso permite, lá na frente, escalar só o video-processor (que é pesado) sem tocar no resto (aula Subdomínios e comunicação assíncrona (BullMQ): mensagens vs. eventos).
Arquitetura Hexagonal: isolar o domínio dos detalhes
Achadas as fronteiras externas (entre módulos), falta organizar o dentro de cada módulo. Aqui entra a Hexagonal, também chamada Ports & Adapters (Alistair Cockburn).
A ideia é uma frase: a regra de negócio não deve depender de detalhe de infraestrutura. O banco, o framework web, a fila, o provedor de e-mail — tudo isso são detalhes. Eles mudam (você troca REST por gRPC, Postgres por Mongo, um gateway de pagamento por outro). A regra de negócio ("uma assinatura vencida há mais de 7 dias é cancelada") não muda quando você troca o banco. Então ela não pode estar amarrada ao banco.
O hexágono tem três zonas, e é exatamente o que a estrutura de cada módulo do FakeFlix expressa:
billing/
├── core/ # o hexágono: regra de negócio pura, zero dependência externa
├── http/ # adaptador de ENTRADA (driving): REST chama o core
└── persistence/ # adaptador de SAÍDA (driven): o core chama o banco por uma porta
core/— o domínio. Não importa NestJS, não importa TypeORM, não sabe o que é HTTP. Só regra de negócio e as portas (interfaces) de que precisa.http/— adaptador driving (quem aciona o core). Um controller REST traduz um request HTTP numa chamada ao serviço de domínio. Amanhã, um adaptador GraphQL ou gRPC entraria aqui sem tocar nocore.persistence/— adaptador driven (quem o core aciona). Implementa a porta de repositório usando o ORM. Amanhã, trocar TypeORM por Prisma mexe só aqui.
A regra da dependência
A seta aponta sempre para dentro. http depende de core. persistence depende de core. core não depende de ninguém. Ele define interfaces (portas) e o mundo externo as implementa. Isso se chama inversão de dependência, e é o coração da coisa.
Código: o hexágono no Billing
A porta (definida pelo core, no core):
// billing/core/port/subscription.repository.ts
// O CORE dita o contrato. Note: nenhuma menção a ORM, SQL ou tabela.
export interface SubscriptionRepository {
findByUserId(userId: string): Promise<Subscription | null>;
save(subscription: Subscription): Promise<void>;
}
O domínio puro (core):
// billing/core/service/subscription.service.ts
@Injectable()
export class SubscriptionService {
constructor(
// depende da PORTA (interface), não da implementação concreta
private readonly repo: SubscriptionRepository,
) {}
async isUserSubscriptionActive(userId: string): Promise<boolean> {
const sub = await this.repo.findByUserId(userId);
if (!sub) return false;
// regra de negócio pura — testável sem banco, sem HTTP, sem framework
return sub.status === 'active' && sub.currentPeriodEnd > new Date();
}
}
O adaptador de saída (persistence) implementa a porta:
// billing/persistence/repository/typeorm-subscription.repository.ts
@Injectable()
export class TypeOrmSubscriptionRepository implements SubscriptionRepository {
constructor(
@InjectRepository(SubscriptionEntity)
private readonly orm: Repository<SubscriptionEntity>,
) {}
async findByUserId(userId: string): Promise<Subscription | null> {
const row = await this.orm.findOne({ where: { userId } });
return row ? this.toDomain(row) : null; // traduz linha do banco → objeto de domínio
}
async save(sub: Subscription): Promise<void> {
await this.orm.save(this.toEntity(sub));
}
// toDomain / toEntity isolam o schema do banco do modelo de domínio
}
O adaptador de entrada (http):
// billing/http/rest/controller/subscription.controller.ts
@Controller('subscriptions')
export class SubscriptionController {
constructor(private readonly service: SubscriptionService) {}
@Get('active')
async isActive(@Req() req): Promise<{ active: boolean }> {
// só traduz HTTP → chamada de domínio. Zero regra de negócio aqui.
return { active: await this.service.isUserSubscriptionActive(req.user.id) };
}
}
A amarração (o módulo liga a porta à implementação):
// billing/billing.module.ts
@Module({
controllers: [SubscriptionController],
providers: [
SubscriptionService,
// aqui está a inversão de dependência acontecendo:
{ provide: 'SubscriptionRepository', useClass: TypeOrmSubscriptionRepository },
],
})
export class BillingModule {}
Repare no ganho concreto: para testar a regra de negócio, você injeta um repositório fake em memória — sem subir banco, sem HTTP. O teste roda em milissegundos e testa a regra, não a infraestrutura:
it('considera inativa uma assinatura vencida', async () => {
const fakeRepo: SubscriptionRepository = {
findByUserId: async () => ({ status: 'active', currentPeriodEnd: new Date('2020-01-01') } as Subscription),
save: async () => {},
};
const service = new SubscriptionService(fakeRepo);
expect(await service.isUserSubscriptionActive('u1')).toBe(false);
});
Antes → Depois
| Antes (regra amarrada à infra) | Depois (hexagonal) |
|---|---|
Service faz SELECT direto |
Service chama uma porta |
| Testar exige subir banco | Testa com fake em memória, em ms |
| Trocar ORM = reescrever o service | Trocar ORM = mexer só em persistence/ |
| Trocar REST por gRPC = mexer no domínio | Novo adaptador em http/, domínio intacto |
Tradeoffs: quando o hexágono NÃO compensa
Honestidade acima de tudo — o hexágono tem custo (mais arquivos, indireção via portas):
- CRUD sem regra de negócio. Se o módulo só faz "salva e lê" sem nenhuma lógica, o hexágono vira cerimônia: você cria uma porta e um adaptador para nada. Aí um repositório direto basta. Não invente domínio onde não há.
- Protótipo/MVP. Volte à aula Por que não existe arquitetura perfeita: na Dose 0, o hexágono é over-engineering. Ele entra quando o módulo tem regra de negócio que vale isolar e sobreviver a trocas de infra.
- Cuidado com o zelo de "porta para tudo". Porta com uma única implementação que nunca vai variar (ex.: uma interface sobre
Date.now()) é o Vilão B da aula Por que não existe arquitetura perfeita. Crie porta onde há valor real de isolamento (banco, gateways externos, filas), não por reflexo.
O critério: o hexágono compensa na proporção da regra de negócio que o módulo protege. Muito domínio → vale isolar. Zero domínio → não invente hexágono.
Cola mental
- DDD estratégico: ache fronteiras ouvindo a Linguagem Ubíqua; uma palavra que muda de sentido = uma fronteira. Um bounded context = um módulo = uma pasta.
- Organize por domínio (identity/billing/content), não por camada técnica.
- Hexagonal:
core(regra pura, define portas) ←http(adaptador de entrada) epersistence(adaptador de saída). Dependência aponta para dentro. - Ganho concreto: regra testável sem infra; trocar detalhe (banco, protocolo) não toca no domínio.
- Não crie hexágono/porta onde não há regra de negócio a proteger.
Referências para Aprofundamento
As fontes originais
- Domain-Driven Design — Eric Evans — O livro que nomeou bounded context e linguagem ubíqua. A parte estratégica (a que esta aula usa) é a mais valiosa e a menos lida.
- Hexagonal Architecture — Alistair Cockburn — O artigo original de quem cunhou o termo. Curto, e deixa claro que "portas e adaptadores" nasceu para resolver testabilidade, não para virar organograma de pastas.
- BoundedContext — Martin Fowler — Duas páginas com o essencial: por que um modelo único para a empresa inteira sempre desaba.
Para aplicar
- Implementing Domain-Driven Design — Vaughn Vernon — O DDD de Evans traduzido em prática. Os capítulos de contexto e context mapping são os que interessam aqui.
- Context Mapping — DDD Community — Repositório com os padrões de relação entre contextos (Customer/Supplier, Anticorruption Layer, Shared Kernel) em formato de referência rápida.
- DDD Starter Modelling Process — DDD Crew — Um roteiro passo a passo para descobrir fronteiras com o negócio, útil quando a conversa da aula precisa virar workshop.
Exercícios
Exercício 1 — Ache a fronteira
No FakeFlix surge a necessidade de "recomendações". Ouvindo o negócio: recomendação usa histórico de visualização, gostos e catálogo; é discutida por um time de dados; "relevância" só significa algo ali. É um novo bounded context ou parte de Content? Justifique.
Ver resposta
✓ Resposta: Novo bounded context ("Recommendations"). Sinais de DDD estratégico presentes: linguagem própria ("relevância" só significa algo ali), time próprio (dados) e modelo próprio (histórico + gostos + catálogo formam um modelo distinto do catálogo puro). Ele consome dados de Content (catálogo) e de um histórico de visualização, mas via contrato (Public API/eventos, aulas Comunicação entre módulos: o padrão Public API Provider e Subdomínios e comunicação assíncrona (BullMQ): mensagens vs. eventos), não misturando o modelo. Enfiar recomendação dentro de Content recriaria a Order de 2.300 linhas da história de abertura: dois modelos brigando na mesma classe.
Exercício 2 — Classifique a camada
Para cada arquivo, diga se pertence a core, http ou persistence:
(a) GraphqlSubscriptionResolver; (b) CancelSubscriptionService com a regra "só cancela se não houver fatura em aberto"; (c) PrismaVideoRepository; (d) a interface VideoRepository.
Ver resposta
✓ Resposta: (a) http — resolver GraphQL é adaptador de entrada. (b) core — regra de negócio pura. (c) persistence — adaptador de saída com ORM. (d) core — a porta é definida pelo domínio (o core dita o contrato; a implementação vive fora).
Exercício 3 — Detecte a violação
O código abaixo está em billing/core/. O que há de errado com a Hexagonal?
// billing/core/service/subscription.service.ts
import { Repository } from 'typeorm';
import { SubscriptionEntity } from '../../persistence/entity/subscription.entity';
@Injectable()
export class SubscriptionService {
constructor(private orm: Repository<SubscriptionEntity>) {}
async isActive(userId: string) {
const row = await this.orm.findOne({ where: { userId } });
return row?.status === 'active';
}
}
Ver resposta
✓ Resposta: O core está importando typeorm (Repository) e a SubscriptionEntity de persistence. Isso inverte a regra da dependência: o domínio passou a depender do detalhe de infraestrutura. Consequências: não dá para testar sem TypeORM; trocar de ORM obriga a mexer no domínio; o core "sabe" do banco. Correção: o core declara a interface SubscriptionRepository (porta) e depende dela; a implementação TypeOrmSubscriptionRepository fica em persistence/ e é ligada no module — exatamente o exemplo da seção 3.
Exercício 4 — Projete a porta
Content precisa enviar e-mail quando um vídeo termina de processar. Defina a porta (interface) que o core de Content deveria declarar, e diga onde ficaria a implementação concreta (ex.: SendGrid).
Ver resposta
✓ Resposta: Porta no core:
// content/core/port/notification.port.ts
export interface NotificationPort {
notifyVideoReady(userId: string, videoId: string): Promise<void>;
}
O core (ex.: VideoProcessingService) depende de NotificationPort e nunca sabe que é SendGrid. A implementação concreta SendGridNotificationAdapter implements NotificationPort fica em content/http/ ou numa pasta de adaptadores de saída (ex.: content/notification/), e é ligada no ContentModule. Assim, trocar SendGrid por SES não toca no domínio — só no adaptador.
Exercício 5 — Desafio: o adaptador que virou domínio
Você encontra este método no core de Identity, e o time diz que "está seguindo a Hexagonal porque tem interface":
// identity/core/service/user.service.ts
export interface UserNotifier {
send(to: string, subject: string, html: string, smtpRetries: number): Promise<void>;
}
@Injectable()
export class UserService {
constructor(private notifier: UserNotifier) {}
async register(dto: CreateUserDto): Promise<void> {
const user = await this.repo.save(User.from(dto));
await this.notifier.send(
user.email,
'Bem-vindo!',
`<h1>Olá ${user.name}</h1><p>Confirme em <a href="...">este link</a></p>`,
3,
);
}
}
A interface está declarada no core, como manda a Hexagonal. Ainda assim a fronteira está furada. Onde, e como corrigir?
Ver resposta
✓ Resposta: Ter interface não basta — o que importa é quem dita o vocabulário do contrato. Aqui a porta foi desenhada com a linguagem do adaptador, não a do domínio, e vazam três detalhes de infraestrutura para dentro do core: (1) subject e html assumem que a notificação é e-mail com corpo HTML — se amanhã for push ou SMS, a assinatura não serve; (2) smtpRetries é política de transporte SMTP, um conceito que o domínio de Identity não deveria conhecer nem saber nomear; (3) o template HTML montado dentro do service coloca apresentação na regra de negócio — mudar o texto de boas-vindas vira commit no domínio. O sintoma clássico: a interface tem o formato do SendGrid com outro nome. Correção: a porta fala o que o domínio quer que aconteça, não como. interface UserRegisteredNotifier { notifyRegistered(user: User): Promise<void>; } — e a implementação em identity/notification/ decide que isso significa um e-mail, escolhe o template, e trata retry como configuração do adaptador. Assunto, HTML e retries somem do core. O teste que confirma o acerto é o da própria aula: com a porta correta, o UserService pode ser testado com um fake de duas linhas, sem saber que e-mail existe. Com a porta original, o fake precisa fabricar HTML e um número de retries que não significam nada para a regra.