Arquitetura pura com Typescript: DDD e arquitetura em camadas

Olá Habr! Recentemente, tenho prestado muita atenção à arquitetura e decidi compartilhar com a comunidade uma tradução do artigo Arquitetura Limpa com Texto Datilografado : DDD, Onion, de André Bazaglia .

Introdução


Por mais de 6 anos de minha experiência profissional, tive a oportunidade de trabalhar em empresas legais de tecnologia que prestam muita atenção à alta disponibilidade e qualidade do código. Eu tive que lidar com situações críticas quando bugs ou mesmo um segundo tempo de inatividade do sistema eram inaceitáveis.

O objetivo deste artigo não é uma cobertura detalhada de tópicos complexos sobre DDD e arquitetura em camadas, mas um exemplo da implementação dessas duas abordagens no Typescript. O projeto usado é básico e pode ser refinado e expandido, por exemplo, usando a abordagem CQRS .

Por que DDD?


Os produtos de software criados devem implementar os requisitos de negócios definidos.

Essa abordagem simplifica o teste da camada de domínio, o que permite que você tenha certeza de que todos os requisitos de negócios sejam levados em consideração e grave códigos de longa duração à prova de erros.
O DDD em combinação com uma arquitetura em camadas evita o efeito dominó quando a alteração do código em um local leva a inúmeros erros em locais diferentes.

Por que arquitetura em camadas?


Essa abordagem vai bem com o DDD, pois todo o sistema é construído em torno de um modelo de domínio, que é o círculo central na imagem abaixo. As camadas internas não são diretamente dependentes das externas e as utilizam por injeção. Além disso, obtemos excelente flexibilidade, para que cada camada tenha sua própria área de responsabilidade e os dados recebidos sejam validados por cada camada de acordo com suas necessidades. E, portanto, as camadas internas sempre recebem dados válidos das camadas externas. Sem mencionar o teste: o teste de unidade se torna mais fácil com o uso de simulações de dependência baseadas em interfaces, o que permite abstrair de partes externas do sistema, como bancos de dados.



No caso de Typecript e Javascript, inversão de controle (via inversão de dependência)significa incorporar (passar) dependências por meio de parâmetros em vez de importação explícita. No exemplo de código a seguir, usaremos a biblioteca Inversify , que nos permite descrever dependências usando decoradores para que as classes criadas posteriormente possam criar dinamicamente contêineres para resolver dependências.

Arquitetura


Para esta aplicação simples ...


Criaremos um aplicativo de carrinho de compras simples, capaz de adicionar produtos ao carrinho. Um carrinho pode ter várias regras de negócios, como uma quantidade mínima ou máxima para cada item.

Vamos mergulhar nas profundezas das camadas de aplicativos, começar com a camada mais interna e avançar para fora sequencialmente.

Domínio


A camada de domínio, por definição, é o habitat das regras de negócios. Ele não sabe nada sobre nenhuma camada externa, não possui dependências e pode ser facilmente testado. Mesmo se você alterar o aplicativo inteiro, o domínio permanecerá intocado, pois contém apenas regras de negócios disponíveis para entender sua finalidade para todos que leem esse código. Essa camada deve ser coberta o máximo possível com testes.

Portanto, temos uma classe base Entity, que pode conter certa lógica comum a todas as classes de domínio. Neste exemplo, toda a lógica comum é gerar um identificador resistente a colisões adequado para dimensionamento horizontal, e todos os objetos usarão essa abordagem.

export abstract class Entity<T> {
  protected readonly _id: string
  protected props: T

  constructor(props: T, id?: string) {
    this._id = id ? id : UniqueEntityID()
    this.props = props
  }

  // other common methods here...
}

Após escrever a classe Entity, você pode começar a criar nossa classe de domínio central que estende a classe abstrata Entity.

Não há nada muito complicado nesta classe, mas há alguns pontos interessantes aos quais você deve prestar atenção.

Primeiro, o construtor é privado, o que significa que a execução de novo Cart () causará um erro, e é disso que precisamos. No DDD, é considerado uma boa prática manter um objeto de domínio sempre em um estado válido. Em vez de criar diretamente um objeto Cart vazio, usamos o padrão Factoryque retorna a instância final da classe Cart. Para garantir que o procedimento de criação tenha recebido todos os atributos necessários, eles podem ser validados. Da mesma forma, getters e setters são usados ​​para interagir com o domínio, por esse motivo, o estado interno da classe é armazenado em um objeto de props privado . Os getters fornecem acesso de leitura a atributos que devem ser públicos. Da mesma forma, setters públicos e outros métodos permitem alterar o domínio com uma garantia constante da validade do estado do objeto.

Para resumir, podemos enfatizar um ponto importante: a lógica do domínio deve se concentrar no comportamento, e não nas propriedades.

export class Cart extends Entity<ICartProps> {
  private constructor({ id, ...data }: ICartProps) {
    super(data, id)
  }

  public static create(props: ICartProps): Cart {
    const instance = new Cart(props)
    return instance
  }

  public unmarshal(): UnmarshalledCart {
    return {
      id: this.id,
      products: this.products.map(product => ({
        item: product.item.unmarshal(),
        quantity: product.quantity
      })),
      totalPrice: this.totalPrice
    }
  }

  private static validQuantity(quantity: number) {
    return quantity >= 1 && quantity <= 1000
  }

  private setProducts(products: CartItem[]) {
    this.props.products = products
  }

  get id(): string {
    return this._id
  }

  get products(): CartItem[] {
    return this.props.products
  }

  get totalPrice(): number {
    const cartSum = (acc: number, product: CartItem) => {
      return acc + product.item.price * product.quantity
    }

    return this.products.reduce(cartSum, 0)
  }

  public add(item: Item, quantity: number) {
    if (!Cart.validQuantity(quantity)) {
      throw new ValidationError(
        'SKU needs to have a quantity between 1 and 1000'
      )
    }

    const index = this.products.findIndex(
      product => product.item.sku === item.sku
    )

    if (index > -1) {
      const product = {
        ...this.products[index],
        quantity: this.products[index].quantity + quantity
      }

      if (!Cart.validQuantity(product.quantity)) {
        throw new ValidationError('SKU exceeded allowed quantity')
      }

      const products = [
        ...this.products.slice(0, index),
        product,
        ...this.products.slice(index + 1)
      ]

      return this.setProducts(products)
    }

    const products = [...this.products, { item, quantity }]
    this.setProducts(products)
  }

  public remove(itemId: string) {
    const products = this.products.filter(product => product.item.id !== itemId)
    this.setProducts(products)
    this.emitCartMutation()
  }

  public empty() {
    this.setProducts([])
  }
}

Embora nosso objeto Carrinho possa ser usado pelo aplicativo usando métodos de domínio, em alguns casos, talvez seja necessário "implantá-lo" em um objeto limpo. Por exemplo, para salvar no banco de dados ou enviar para o cliente como um objeto JSON. Isso pode ser implementado usando o método unmarshal () .

Para aumentar a flexibilidade da arquitetura, a camada do domínio também pode se tornar uma fonte de eventos do domínio. Aqui, a abordagem de Event Sourcing pode ser aplicada , com a criação de eventos quando as entidades do domínio mudam.

Scripts de usuário


Aqui, usaremos métodos e objetos de domínio implementados no nível da infraestrutura para armazenar dados.

Usamos a biblioteca inversify para implementar a abordagem Inversion of Management, que injeta o repositório da camada de infraestrutura nesse cenário, fornecendo a capacidade de manipular a cesta usando métodos de domínio e salvar as alterações no banco de dados depois disso.

import { inject, injectable } from 'inversify'

@injectable()
export class CartService {
  @inject(TYPES.CartRepository) private repository: CartRepository

  private async _getCart(id: string): Promise<Cart> {
    try {
      const cart = await this.repository.getById(id)
      return cart
    } catch (e) {
      const emptyCart = Cart.create({ id, products: [] })
      return this.repository.create(emptyCart)
    }
  }

  public getById(id: string): Promise<Cart> {
    return this.repository.getById(id)
  }

  public async add(cartId: string, item: Item, sku: number): Promise<Cart> {
    const cart = await this._getCart(cartId)
    cart.add(item, sku)

    return this.repository.update(cart)
  }

  public async remove(cartId: string, itemId: string): Promise<Cart> {
    const cart = await this._getCart(cartId)
    cart.remove(itemId)

    return this.repository.update(cart)
  }
}

Essa camada é responsável pela operação do aplicativo. Alterações de código nessa camada não afetam entidades de domínio ou dependências externas, como um banco de dados.

A infraestrutura


Apesar de toda a obviedade, a camada de infraestrutura interage com sistemas externos, como um banco de dados.

Para salvar dados no banco de dados, eu uso as abordagens Mapeador de Dados e Repositório.

O Mapper pode obter os dados de origem do banco de dados e convertê-los no objeto de domínio correspondente:

import { Cart, CartItem } from 'src/domain/cart'

const getProducts = (products: CartItem[]) => {
  return products.map(product => ({
    item: product.item,
    quantity: product.quantity
  }))
}

export class CartMapper {
  public static toDomain(raw: any): Cart {
    return Cart.create({
      id: raw.id,
      couponCode: raw.couponCode,
      products: getProducts(raw.products || [])
    })
  }
}

O próprio repositório pode depender da biblioteca do cliente de um banco de dados específico. Por exemplo, do armazenamento na RAM, e use estes métodos para gerenciar dados:

import { injectable, inject } from 'inversify'
import { Cart } from 'src/domain/cart'
import { CartMapper } from '../mappers/cart'

interface ICartRepository {
  getById(id: string): Promise<Cart>
  create(cart: Cart): Promise<Cart>
  update(cart: Cart): Promise<Cart>
}

@injectable()
export class CartRepository implements ICartRepository {
  @inject(TYPES.Database) private _database: MemoryData

  async getById(id: string): Promise<Cart> {
    const cart = await this._database.cart.getById(id)
    if (!cart) {
      throw new ResourceNotFound('Cart', { id })
    }
    return CartMapper.toDomain(cart)
  }

  async create(cart: Cart): Promise<Cart> {
    const dtoCart = cart.unmarshal()
    const inserted = await this._database.cart.insert(dtoCart)
    return CartMapper.toDomain(inserted)
  }

  async update(cart: Cart): Promise<Cart> {
    const dtoCart = cart.unmarshal()
    const updated = await this._database.cart.update(cart.id, dtoCart)
    return CartMapper.toDomain(updated)
  }
}

Conclusão


Há muito espaço para melhorias e aprimoramentos. O código foi criado para esta demonstração visual, e a primeira melhoria na arquitetura em camadas pode ser o anúncio da interface do repositório na camada de domínio e sua implementação na camada de infraestrutura, que discutiremos na próxima vez.

O código fonte está disponível no github .

Source: https://habr.com/ru/post/undefined/


All Articles