Há algumas semanas, eu defini a meta de aprender TypeScript e desenvolver um produto do zero utilizando tudo o que a linguagem de programação tem a oferecer. Sendo alguém com experiência trabalhando com orientação a objetos, migrar para um ambiente multiparadigma me pareceu bastante interessante. Um dos desafios foi trazer os conceitos aplicados em aplicações C# e .NET para o mundo do Node.js, e um desses conceitos foi o Domain-Driven Design.
Explicando de forma breve, Domain-Driven Design, ou DDD, é uma abordagem de desenvolvimento de software que foca na complexidade do domínio de negócio, trazendo-o para o centro do desenvolvimento e buscando aproximar os especialistas desse domínio do processo de criação dos sistemas. Ele te fornece algumas ferramentas para lidar melhor com essa complexidade.
Dentro dessa abordagem, você encontra o Design Estratégico: ele lida com a visão macro, com a estrutura do domínio, com os limites do negócio e com o vocabulário dos especialistas. É aqui que se trabalha com Mapas de Contexto, Contextos Delimitados, subdomínios, Domínio Básico, Domínio de Suporte, Domínio Genérico e Linguagem Ubíqua. Já o Design Tático entra na parte “concreta” da modelagem dentro de cada Bounded Context. É aqui que lidamos com Entidades, Objetos de Valor, Eventos de Domínio, entre outros.
Os padrões estratégicos se adaptam bem a qualquer linguagem, por isso, o foco do texto será exclusivamente no Design Tático.
O TypeScript fornece suporte a classes — não tão ricas quanto no C#, mas bastante flexíveis de trabalhar. E, enquanto desenvolvia um projeto eu pensei: utilizando em uma linguagem multiparadigma, é possível trabalhar com DDD incorporando elementos da programação funcional? E a resposta é sim!
Um dos meu livros de referência Implementando Domain-Driven Design, o foco do DDD Tático fica em linguagens orientadas a objetos, o que me fez buscar outras fontes. Dentre vários artigos lidos (referências ao final da página), cheguei a um projeto simples, mas bastante didático, adaptando conceitos e tentando criar algo a partir disso para quem, assim como eu, também tem essa dúvida.
Entidades e Regras de Negócio
No DDD orientado a objetos, as entidades são normalmente classes possuindo estado e comportamento. Regras e invariantes tendem a ficar dentro das classes de domínio. Esse estilo usa construtores, métodos de instância e encapsulamento por visibilidade (private). Em uma abordagem funcional, a ideia é separar os dados (tipos imutáveis) de comportamentos (funções puras). Em vez de métodos que mutam, temos funções que recebem dados e retornam novos dados ou outcomes.
type BaseEntity<T> = T & { Id: number, IsActive: boolean };
export type User = BaseEntity<{
Username: string,
Email: string,
Password: string,
PhoneNumber: string,
Address: Adderss
}>;
Quando falamos de funções puras, um conceito importante são as Invariants. Em matemática, um invariante é uma propriedade de um objeto matemático que permanece inalterada após a aplicação de operações ou transformações de determinado tipo sobre esse objeto. No nosso caso, esse conceito se aplica a funções pequenas (predicates) que respondem perguntas atômicas do domínio, ex.: isPasswordStrong, isAddressInCoverage
export const IsValidEmail = (email: string) : boolean => emailPattern.test(email);
export const IsValidPassWord = (password: string) : boolean => passwordPattern.test(password);
export const IsUserActive = (isActive: boolean) : boolean => isActive === true;
Outro conceito importante são os Derivers. Eles formam o núcleo funcional do sistema, concentrando as regras de negócio puras. São compostos por invariantes e têm como resultado um Outcome (uma discriminated union) ou um novo delta/novo. Eles não realizam I/O, não criam entidades finais e não fazem persistência.
O Deriver faz apenas duas coisas (dependendo do caso):
Decidir: responde à pergunta “pode/como essa operação acontecer?” retornando um Outcome (ex.: INVALID_EMAIL, WEAK_PASSWORD, SUCCEEDED).
Derivar estado: calcular um novo estado/delta a partir dos dados (ex.: calcular passwordHash, normalizar email, calcular novo carrinho) — Transform Deriver.
const DeriveUser = async (dto: UserDtoType) : CreateUserOutcome => {
if(IsValidEmail(dto.Email))
return { outcome: "INVALID_EMAIL" }
if(IsValidPassWord(dto.Password))
return { outcome: 'INVALID_PASSWORD' }
return {
outcome: 'SUCCEEDED',
payload: {
normalizedEmail: dto.Email,
hashedPassword: await HashPassword(dto.Password),
username: dto.Username
}
}
}
Quando falo em “derivar um novo estado” não quero dizer “aplicar esse novo estado ao banco/sistema”. O Deriver retorna um valor (novo objeto em memória) que representa como o estado deveria ficar. Persistir esse novo estado é responsabilidade do Handler.
Outcome: union discriminado que descreve todas as possibilidades de resultado. É o contrato pelo qual o Deriver comunica decisões ao Handler. Exemplo:
export type UserOutcome =
| { outcome: "INVALID_EMAIL" }
| { outcome: "INVALID_PASSWORD" }
| { outcome: "INVALID_PHONE" }
| { outcome: "INVALID_USERNAME" }
| { outcome: "SUCCEEDED" }
Delta/Novo estado: objeto contendo os valores derivados (ex.: passwordHash, normalizedEmail, objetos VO prontos). Pode ser:
- O objeto completo que representa a entidade pronta para criar, ou
- Apenas as mudanças (delta) para aplicar ao estado atual.
Ambas formas são puras.
A partir disso, a Factory, uma função pura, recebe os dados validados/derivados (delta) e monta a entidade/VO final de forma imutável.
O Orquestrador entre Domínio e Infraestrutura
Após entendermos que o Deriver decide as regras de negócio puras, precisamos de uma camada que orquestre o caso de uso. É aqui que entra o Handler (às vezes também chamado de Use Case ou Shell).
O Handler não contém regras de negócio profundas: ele coordena o fluxo de uma operação. Ele recebe um DTO já validado, chama o Deriver para obter um Outcome e, dependendo desse resultado, decide o próximo passo.
Se o Deriver retornar uma falha, o Handler devolve essa falha diretamente para o controller. Se retornar um “SUCCEEDED” com um delta/novo estado, então o Handler chama a Factory para montar a entidade final e delega ao repositório o trabalho de persistência.
Todo esse conhecimento adquirido se traduziu neste projeto simples, onde busquei seguir um modelo de arquitetura Package by Feature, fazendo algumas adaptações para o cenário no qual estou mais acostumado. Para entender mais da arquitetura, deixo o ótimo artigo do Waldemar Neto que serve como guia e que eu utilizei como base para desenvolver meu projeto.
Aos poucos vou me aprofundando em TypeScript e programação funcional, mas esse primeiro passo foi bem divertido e já me mostra ótimas possibilidades para o futuro.
Referências:
Arquiteturas Emergentes Que Você Precisa Conhecer: Package by Feature, Vertical Slice e Modular
Domain-driven design in functional programming
Domain-Driven Design (DDD) com Programação Funcional?!?!
Functional Domain Driven Design: Simplified
Domain Driven Design implemented by functional programming
Top comments (0)