Arquitectura pura con mecanografiado: DDD y arquitectura en capas

Hola Habr! Recientemente he estado prestando mucha atención a la arquitectura y decidí compartir con la comunidad una traducción del artículo Arquitectura limpia con mecanografiado: DDD, Cebolla de André Bazaglia .

Introducción


Durante más de 6 años de mi experiencia profesional, he tenido la oportunidad de trabajar en empresas de tecnología que prestan mucha atención a la alta disponibilidad y la calidad del código. Tuve que lidiar con situaciones críticas cuando los errores o incluso un segundo tiempo de inactividad del sistema eran inaceptables.

El propósito de este artículo no es una cobertura detallada de temas complejos sobre DDD y arquitectura en capas, sino un ejemplo de la implementación de estos dos enfoques en Typecript. El proyecto utilizado es básico y se puede refinar y expandir, por ejemplo, utilizando el enfoque CQRS .

¿Por qué DDD?


Los productos de software creados deben implementar los requisitos comerciales establecidos.

Este enfoque simplifica la prueba de la capa de dominio, lo que le permite estar seguro de que todos los requisitos comerciales se tienen en cuenta y escribir código a prueba de errores de larga duración.
DDD en combinación con una arquitectura en capas evita el efecto dominó cuando cambiar el código en un lugar conduce a innumerables errores en diferentes lugares.

¿Por qué arquitectura en capas?


Este enfoque va bien con DDD, ya que todo el sistema se basa en un modelo de dominio, que es el círculo central en la imagen a continuación. Las capas internas no dependen directamente de las externas y las usan por inyección. Además, obtenemos una excelente flexibilidad, por lo que cada capa tiene su propia área de responsabilidad y los datos entrantes son validados por cada capa de acuerdo con sus necesidades. Y, por lo tanto, las capas internas siempre reciben datos válidos de las capas externas. Sin mencionar las pruebas: las pruebas unitarias se vuelven más fáciles con el uso de simulacros de dependencia basados ​​en interfaces, lo que le permite abstraer de partes externas del sistema, como bases de datos.



En el caso de Typecript y Javascript, Inversión de control (a través de Inversión de dependencia)significa incrustar (pasar) dependencias a través de parámetros en lugar de importación explícita. En el siguiente ejemplo de código, usaremos la biblioteca Inversify , que nos permite describir dependencias usando decoradores para que las clases creadas más tarde puedan tener contenedores creados dinámicamente para resolver dependencias.

Arquitectura


Para esta sencilla aplicación ...


Crearemos una aplicación de carrito de compras simple que pueda manejar la adición de productos al carrito. Un carrito puede tener varias reglas comerciales, como una cantidad mínima o máxima para cada artículo.

Profundicemos en las profundidades de las capas de aplicación, comencemos con la capa más interna y avancemos secuencialmente.

Dominio


La capa de dominio, por definición, es el hábitat de las reglas comerciales. No sabe nada sobre ninguna capa externa, no tiene dependencias y puede probarse fácilmente. Incluso si cambia la aplicación completa, el dominio permanecerá intacto, ya que contiene solo reglas comerciales que están disponibles para comprender su propósito para todos los que lean este código. Esta capa debe cubrirse tanto como sea posible con pruebas.

Entonces, tenemos una clase base de entidad, que puede contener cierta lógica común a todas las clases de dominio. En este ejemplo, toda la lógica común es generar un identificador resistente a colisiones adecuado para la escala horizontal, y todos los objetos utilizarán este enfoque.

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

Después de escribir la clase Entity, puede comenzar a crear nuestra clase de dominio central que extiende la clase abstracta Entity.

No hay nada muy complicado en esta clase, pero hay algunos puntos interesantes a los que debes prestar atención.

En primer lugar, el constructor es privado, lo que significa que ejecutar new Cart () causará un error, que es lo que necesitamos. En DDD, se considera una buena práctica mantener un objeto de dominio siempre en un estado válido. En lugar de crear directamente un objeto Cart vacío, usamos el patrón Factoryque devuelve la instancia finalizada de la clase Cart. Para garantizar que el procedimiento de creación ha recibido todos los atributos requeridos, se pueden validar. Del mismo modo, los captadores y definidores se utilizan para interactuar con el dominio, por esta misma razón el estado interno de la clase se almacena en una privada puntales objeto . Los captadores proporcionan acceso de lectura a los atributos que deberían ser públicos. Del mismo modo, los setters públicos y otros métodos le permiten cambiar el dominio con una garantía constante de la validez del estado del objeto.

Para resumir, podemos enfatizar un punto importante: la lógica de dominio debe centrarse en el comportamiento y no en las propiedades.

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([])
  }
}

Si bien nuestro objeto Cart puede ser utilizado por la aplicación utilizando métodos de dominio, en algunos casos, es posible que necesitemos "implementarlo" en un objeto limpio. Por ejemplo, para guardar en la base de datos o enviar al cliente como un objeto JSON. Esto se puede implementar utilizando el método unmarshal () .

Para aumentar la flexibilidad de la arquitectura, la capa de dominio también puede convertirse en una fuente de eventos de dominio. Aquí, se puede aplicar el enfoque de abastecimiento de eventos , con la creación de eventos cuando cambian las entidades de dominio.

Guiones de usuario


Aquí usaremos métodos de dominio y objetos implementados desde el nivel de infraestructura para almacenar datos.

Utilizamos la biblioteca inversify para implementar el enfoque de Inversión de gestión, que inyecta el repositorio desde la capa de infraestructura en este escenario, dándonos la oportunidad de manipular la canasta utilizando métodos de dominio y guardar los cambios en la base de datos después de eso.

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

Esta capa es responsable del funcionamiento de la aplicación. Los cambios de código en esta capa no afectan a las entidades de dominio o dependencias externas como una base de datos.

Infraestructura


A pesar de todo lo obvio, la capa de infraestructura interactúa con sistemas externos, como una base de datos.

Para guardar datos en la base de datos, utilizo el mapeador de datos y los enfoques de repositorio.

Mapper puede obtener los datos de origen de la base de datos y convertirlos al objeto de dominio correspondiente:

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 || [])
    })
  }
}

El repositorio en sí mismo puede depender de la biblioteca del cliente de una base de datos particular. Por ejemplo, desde el almacenamiento en RAM, y use estos métodos para administrar datos:

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

Conclusión


Hay mucho espacio para mejoras y mejoras. El código fue creado para esta demostración visual, y la primera mejora en la arquitectura en capas podría ser el anuncio de la declaración de la interfaz del repositorio en la capa de dominio y su implementación en la capa de infraestructura, que podemos discutir la próxima vez.

El código fuente está disponible en github .

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


All Articles