DEV Community

Cover image for Uma Abordagem Funcional para Domain-Driven Design
Igor Oliveira
Igor Oliveira

Posted on

Uma Abordagem Funcional para Domain-Driven Design

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
}>;

Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
        }
     }
}
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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)