Versão revisada em inglês aqui: REPLACEME

Fala, turma! Espero que estejam bem.

Mais um artigo para continuarmos os estudos em DDD, dessa vez focado no design tático e nos padrões que o compõem.

Deixando claro que é uma continuação do nosso primeiro artigo Estudos em DDD: Entre Pescadores e Desenvolvedores, então a leitura dele é obrigatória para ter um entendimento completo.

O que é, o que é

  • parecem ter uma identidade;
  • não são os atributos que necessariamente importam;
  • possuem continuidade dentro do sistema;
  • podem ser diferenciadas;

Exatamente, são os objetos que chamamos de Entidades!

Normalmente, quando vamos atribuir uma identidade a algo ou alguém, é a soma de um ou mais de seus atributos (ou comportamentos) que a tornam única. E é de suma importáncia que seja possível que dois objetos com identidades diferentes sejam facilmente distinguidos pelo sistema e que dois objetos com a mesma identidade sejam considerados iguais. Se não conseguirmos garantir essa consistência, vamos esbarrar em dados corrompidos e muita dor de cabeça.

As Entidades são importantes objetos do nosso Modelo de Domínio e devem ser consideradas desde o início da nossa modelagem.

Olhando os contextos que desenhamos, cada um possuía seu próprio modelo do Domínio que servia para uma necessidade. Uma vara de pescar é simultaneamente um equipamento de pesca (Catálogo), um componente de desempenho (Recomendação), um produto para venda (Vendas) e um item de estoque (Inventário), mas esses são modelos diferentes, não visões diferentes do mesmo modelo!

Vamos focar primeiro no Contexto do Catálogo de Equipamentos:

Preocupação: Gerenciar especificações e compatibilidade de equipamentos de pesca
Possuem:
- Equipamentos de Pesca (não "Produto")
- Regras de Compatibilidade
- Especificações Técnicas
- Categorias de Equipamento

Entendemos melhor sobre isso quando simulamos algumas conversas e tentamos absorver o conhecimento dos nossos Especialistas, mas é bom lembrar mais uma vez que esse trabalho nunca acaba. A preocupação principal agora é conseguir “Gerenciar especificações e compatibilidades que os equipamentos de pesca possuem”.

Então começamos a desenhar algo em mais alto nível:

// EquipmentCatalog.Domain

class FishingEquipment
- string EquipmentIdentifier // precisamos identificar cada equipamento
- object TechnicalSpecifications // gerenciar as especificações
- object EquipmentCategory // categoria do equipamento
- object compatibilityRules // regras de compatibilidades, me soa mais como um comportamento talvez? 
...

Agora, precisamos então criar Entidades para todos os objetos que gostariamos de ter controle? Nós precisamos realmente poder identificar cada objeto com uma identidade única? Me parece trabalhoso demais ter que manter identificadores únicos (ou compostos) para todos eles (e realmente é).

Objetos valorosos

Então, para quais casos nós queremos apenas focar nos valores e não precisamos nos preocupar em mantermos suas singularidades? Chamamos de Objetos de Valor, esse nos quais carregam essa característica e é muito importante entender a diferença deles com as nossas Entidades.

Uma característica importante desses nossos objetos é os manter imutáveis, eles precisam ser criados por um construtor e nunca modificados durante seu período de vida. Se por acaso quer um novo valor, é preciso criar um novo objeto, simples assim. Dessa forma, não possuindo identificadores únicos e garantindo que nunca irão mudar, eles podem ser compartilhados sem efeitos colaterais.

Eles podem possuir outros Objetos de Valor e até referências para Entidades, mas é bom os mantermos sempre com um conceito em mente.

Com essas duas novas ferramentas na nossa caixa de pesca, podemos finalmente nos aventurarmos com algum código. Eu decidi unir o útil ao agradável, eu vou criar os exemplos enquanto estudo o Effect. Não vou me aprofundar em nada em relação a ele, a única coisa importante pra saber é uma biblioteca TypeScript com superpoderes.

Nesse momento, vamos considerar 4 tipos de Equipamentos: Varas, Carretéis, Linhas e Iscas. Acredito que todos eles possuem atributos que podem ser compartilhados.

Acompanhe o código aqui The Fisherman - Pull 1

// domain/EquipmentCatalog/_BaseEquipment.ts
export class BaseEquipment extends Schema.Class<BaseEquipment>("BaseEquipment")({
  id: EquipmentId, // Identificador Único
  manufacturer: Schema.instanceOf(ManufacturerInfo), // Fabricante
  modelName: Schema.NonEmptyString, // Nome
  partNumber: Schema.NonEmptyString, // Número do equipamento
  catalogAddedDate: Schema.DateFromSelf // Quando foi adicionado ao catálogo
}) {}

Perceberam que o Fabricante é possívelmente um objeto com outros atributos, mas vamos manter as coisas simples nesse momento, talvez ele não precise ter um identificador único e se tornar uma Entidade, por hora ele pode ser apenas um Objeto de Valor.

// domain/EquipmentCatalog/valueObjects/ManufacturerInfoVo.ts
export class ManufacturerInfo extends Data.TaggedClass("ManufacturerInfo")<{
  readonly name: string
  readonly countryOfOrigin?: string // País de Origem
  readonly website?: string
}> {}

Agora vamos iniciar a modelagem do que seria nossas Varas de Pescas:

// domain/EquipmentCatalog/entities/FishingRodEntity.ts
export class FishingRod extends BaseEquipment.extend<FishingRod>("FishingRod")(
  power: Schema.instanceOf(RodPower), // Potência
  action: Schema.instanceOf(RodAction), // Ação
  length: RodLength, // Comprimento
  rodType: Schema.instanceOf(RodType), // Tipo
  pieces: Schema.Int.pipe(Schema.positive()), // Seções
  materialComposition: Schema.String // Material
...

Temos muitas informações aqui, vamos pelo caminho mais fácil nesse momento. Os Especialistas nos disseram que o Comprimento de uma Vara, deve ser sempre entre a faixa de 4 até 15 pés, então temos que garantir que um estado inválido para esse atributo não seja possível.

https://norrik.com/choosing-a-fishing-rod-which-type-is-best-for-you/

// domain/EquipmentCatalog/valueObjects/Rod/RodLengthVo.ts
export const RodLength = Schema.Number.pipe(
  Schema.between(4, 15, { message: () => "Rod length must be between 4 and 15 feet" }),
  ...

Certo, garantimos que na criação de uma Vara, seja impossível instanciar uma Vara de Pesca que não possua um Comprimento igual o qual descrevemos.

Vamos complicar um pouco agora, olhando as anotações da nossa última conversa com os Especialistas, tínhamos a seguinte regra: Especialista: “Uma vara com potência ultraleve não funciona bem com ação rápida e, uma com potência pesada precisa de pelo menos uma ação moderada…” Você: “Certo! E como definimos como uma vara possui uma potência ultraleve ou pesada?” Especialista: “Temos faixas de valores para representar cada tipo. Vou te passar como temos elas cadastradas…”

Certo, é uma regra de compatibilidade que precisamos aplicar em relação a Potência da Vara de Pesca, nesse momento podemos adicionar essa regra no Objeto de Valor que a representa:

// domain/EquipmentCatalog/valueObjects/Rod/RodPowerVo.ts
export class RodPower extends Data.TaggedClass("RodPower")<{
  readonly name: string
  readonly powerRating: number
  ...
}> {
  isCompatibleWith(action: RodAction): boolean {
    // Domain rule: Ultra-light power doesn't work well with fast action
    // Potência menor ou igual a 2 significa ultraleve
    if (this.powerRating <= 2 && action.flexPoint === "tip") return false

    // Domain rule: Heavy power needs at least moderate action
    // Potência maior ou igual a 8 significa pesada
    if (this.powerRating >= 8 && action.flexPoint === "throughout") return false

    return true
  }
}

Agora vamos garantir que essa regra seja aplicada quando uma Vara de Pesca for instânciada.

// domain/EquipmentCatalog/entities/FishingRodEntity.ts
export class FishingRod extends BaseEquipment.extend<FishingRod>("FishingRod")(
...
  }).pipe(
    Schema.filter(
      (rod) =>
        rod.power.isCompatibleWith(rod.action) ||
        `Power ${rod.power.name} is not compatible with Action ${rod.action.name}`
    )
  )
) {}

Agora sim, nesse momento garantimos que pouco a pouco os comportamentos esperados pelos nossos Especialista estão realmente sendo aplicadas no nosso código. Além disso, é um ótimo momento para escrevermos alguns testes, com o que até o momento são combinações válidas e inválidas, ajudando na construção das nossas regras. Não vou entrar no detalhe da implementação aqui (pode checar o repositório para isso), mas só descrever o que estamos esperando, inclusive esses testes poderiam ter sido escritos antes como critérios de aceitação para as regras que iríamos implementar.

// test/domain/EquipmentCatalog/entities/FishingRod.test.ts
describe("valid combinations", () => {
  describe("with moderate action", () => {
    it("creates rod with compatible ultra-light power", () => { ... })
    it("creates rod with compatible heavy power", () => { ... })
  })
})
describe("invalid combinations", () => {
  it("rejects ultra-light power with fast action", () => { ... })
  it("rejects heavy power with slow action", () => { ... })
})
describe("validation rules", () => {
  it("rejects negative pieces count", () => { ... })
  it("rejects zero pieces count", () => { ... })
})

Juntos somos mais fortes

Agora vamos entrar em um desafio de modelagem diferente, um relacionado ao ciclo de vida dos Objetos de Domínio. Por mais que já temos uma estrutura inicial e algumas regras implementadas, ainda precisamos melhorar a posse das nossas propriedades e os limites com outros Domínios.

Mesmo que até agora temos apenas um Domínio implementado, já é possível entender que existe uma relação entre ele e seus Objetos de Valores, e quanto mais associações temos entre esses Objetos e até outros Domínios, mais complexo vai se tornar manter uma consistência enquanto eles estiverem “vivos” em memória ou mesmo salvos em um banco de dados.

Também é necessário ser capaz de impor as Invariantes, elas são aquelas regras que precisam ser mantidas sempre que os dados mudam. Isso é difícil de implementar se possuimos muitas referências a outros objetos (por mais que é impossível fugir disso).

É perigoso ir sozinho! Pegue isso!

Para facilitar isso, temos o conceito de agrupar as Entidades e os Objetos de Valor em um Agregado e então definir limites ao redor de cada um desses grupos. Uma Entidade vai se tornar a raiz/entrada para cada Agregado e o TODO o controle de acesso aos objetos dentro desse limite é feito através dele.

Acompanhe o código agora em outra PR aqui The Fisherman - Pull 2

// domain/EquipmentCatalog/entities/FishingRodEntity.ts
export class FishingRod extends Schema.Class<FishingRod>("FishingRod")(
  Schema.Struct({
    // Previous _BaseEquipment properties
    id: EquipmentId,
    manufacturer: Schema.instanceOf(ManufacturerInfo),
    modelName: Schema.NonEmptyString,
    partNumber: Schema.NonEmptyString,
    catalogAddedDate: Schema.DateFromSelf,

    power: Schema.instanceOf(RodPower),
    action: Schema.instanceOf(RodAction),
    length: RodLength,
    rodType: Schema.instanceOf(RodType),
    pieces: Schema.Int.pipe(Schema.positive()),
    materialComposition: Schema.String,
    lastModified: Schema.DateFromSelf,
    version: Schema.Int.pipe(Schema.positive())
  })

Perceberam que agora todos os atributos da _BaseEquipment também estão de volta? Temos que evitar abstrações tentando prever futuros “reusos” para o código, porque dessa forma, estamos nos distanciando da nossa linguagem ubíqua. Se olhando o código um dos nossos Especialistas lesse por ela classe, possívelmente ela não significaria nada, e claro que podemos ter niveis de abstrações para conseguirmos compartilhar código que realmente faça sentido, nesse caso eu estava tentando prever um pouco os próximos passos, então vamos voltar atrás.

// domain/EquipmentCatalog/entities/FishingRodEntity.ts
static create(data: {
  ...
}): Effect.Effect<FishingRod, EquipmentValidationError> {
  const now = new Date()

  const errors: Array<string> = []

  if (!data.power.isCompatibleWith(data.action)) {
    errors.push(`Power ${data.power.name} is not compatible with Action ${data.action.name}`)
  }

  if (data.pieces < 1 || data.pieces > 4) {
    errors.push("Rod pieces must be between 1 and 4")
  }

  if (errors.length > 0) {
    return Effect.fail(
      new EquipmentValidationError({
        equipmentId: data.id,
        errors
      })
    )
  }

  return Effect.succeed(
    new FishingRod({
      id: data.id,
      manufacturer: data.manufacturer,
      modelName: data.modelName,
      partNumber: data.partNumber,
      catalogAddedDate: now,
      power: data.power,
      action: data.action,
      length: data.length,
      rodType: data.rodType,
      pieces: data.pieces,
      materialComposition: data.materialComposition,
      lastModified: now,
      version: 1
    })
  )
}

Agora nós centralizamos de verdade a criação dessa nossa Entidade e evitamos que isso fuja do nosso controle, por mais que antes tínhamos alguma validação mínima na instância, melhoramos ainda mais com métodos específicos com a devida exposição.

Agora se precisamos fazer qualquer atualização, também precisamos manter a mesma consistência. Nosso Especialista diz algo como “Quando mudo a potência de uma Vara, preciso ter certeza de que ela ainda funcione com a ação existente e não quebra nenhuma combinação de equipamentos! Ah, mudanças significativa de potência podem afetar também a integridade estrutural!”

// domain/EquipmentCatalog/entities/FishingRodEntity.ts
changePower(newPower: RodPower): Effect.Effect<FishingRod, IncompatibleSpecificationError> {
  // New power must be compatible with existing action
  if (!newPower.isCompatibleWith(this.action)) {
    return Effect.fail(
      new IncompatibleSpecificationError({
        equipmentId: this.id,
        reason: `New power ${newPower.name} is not compatible with action ${this.action.name}`
      })
    )
  }

  // Significant power changes might affect structural integrity
  const powerDifference = 
  Math.abs(newPower.powerRating - this.power.powerRating)
  if (powerDifference > 3) {
    return Effect.fail(
      new IncompatibleSpecificationError({
        equipmentId: this.id,
        reason: `Power change too dramatic: from ${this.power.name} to ${newPower.name}`
      })
    )
  }

  return Effect.succeed(
    new FishingRod({
      ...this,
      power: newPower,
      lastModified: new Date(),
      version: this.version + 1
    })
  )
}

Nesse ponto da nossa implementação, garantimos que não seja possível atualizar a Potência sem passarmos pelas regras necessárias e também garantindo que nenhum estado inválido para a nossa Vara de Pesca exista. Também é bom lembrar que os testes precisam ser atualizados com a nova estrutura e métodos.

O que tem na caixa?

Esse é o mesmo peixe que eu deixei aqui?

Até o momento trabalhamos apenas com parte do ciclo de vida das nossas Entidades e Objetos, mas ainda não possuímos uma maneira de mantermos armazenado em um estado permanente em um banco de dados ou outra forma de persistência.

Precisamos de um meio de adquirirmos referências a os nosso Objetos de Domínio sem que seja adicionado mais responsabilidades ao que já temos. Usaremos um Repositório, cujo objetivo é encapsular toda a lógica necessária que precisamos em uma nova parte que lida com a infraestrutura.

Para termos um exemplo mais prático e simples, criei apenas um repositório que vai manter nosso Objeto de Domínio em memória. O Effect.gen é como uma “fábrica” que cria nosso repositório, é similar ao async/await, mas mais poderoso para lidar com erros.

// infrastructure/EquipmentCatalog/repositories/InMemoryFishingRodRepository.ts
export const makeFishingRodRepository = Effect.gen(function* () {
  const rods = yield* Ref.make(new Map<EquipmentId, FishingRod>())

  const findById = (id: EquipmentId): Effect.Effect<FishingRod, EquipmentNotFoundError> =>
    pipe(
      Ref.get(rods), // "Abrir a caixa" e ver o que tem dentro
      Effect.flatMap((rodMap) => { // "Se conseguiu abrir..."
        const rod = rodMap.get(id) // Procurar pela Vara de Pesca
        return rod ? Effect.succeed(rod) : Effect.fail(new EquipmentNotFoundError({ equipmentId: id })) // "Não achei, erro!"
      })
    )

  const save = (rod: FishingRod): Effect.Effect<void, EquipmentValidationError> =>
    pipe(
      Ref.update(rods, (rodMap) => new Map(rodMap).set(rod.id, rod)), // Adicionar/atualizar
      Effect.asVoid
    )

  return {
    findById,
    save
  } as const
})

Todas as Entidades, Objetos e regras que implementamos até agora não tiveram seus conceitos tirados de nenhum outro lugar que não da nossa linguague ubíqua. Nada foi adivinhado ou desenhado por coincidência, nossa implementação e o modelo que estamos implementando devem ainda coincidir.

E o que temos até agora implementado pode ser melhor visualizado aqui:

Por hoje é isso, quero deixar claro que muitas decisões aqui foram tomadas pensando na forma mais clara para os exemplos e com certeza vários conceitos e entendimentos podem ser revistados conforme avançamos nos nossos estudos.

Todas as reflexões aqui foram enquanto eu consumia esses materiais:

Nos vemos no GitHub, até breve! 🚀