Architecture pure avec Typescript: DDD et architecture en couches

Bonjour, Habr! Récemment, j'ai accordé beaucoup d'attention à l'architecture et j'ai décidé de partager avec la communauté une traduction de l'article Architecture propre avec Typescript: DDD, Oignon d' André Bazaglia .

introduction


Depuis plus de 6 ans de mon expérience professionnelle, j'ai eu l'opportunité de travailler dans des entreprises de technologie cool qui accordent beaucoup d'attention à la haute disponibilité et à la qualité du code. J'ai dû faire face à des situations critiques lorsque des bogues ou même un deuxième temps d'arrêt du système étaient inacceptables.

Le but de cet article n'est pas une couverture détaillée de sujets complexes sur DDD et l'architecture en couches, mais un exemple de la mise en œuvre de ces deux approches dans Typescript. Le projet utilisé est basique et peut être affiné et étendu, par exemple, en utilisant l'approche CQRS .

Pourquoi DDD?


Les produits logiciels créés doivent implémenter les exigences commerciales définies.

Cette approche simplifie le test de la couche de domaine, ce qui vous permet de vous assurer que toutes les exigences commerciales sont prises en compte et d'écrire du code résistant aux erreurs de longue durée.
DDD en combinaison avec une architecture en couches évite l'effet domino lorsque la modification du code à un endroit entraîne d'innombrables bugs à différents endroits.

Pourquoi une architecture en couches?


Cette approche va bien avec DDD, car tout le système est construit autour d'un modèle de domaine, qui est le cercle central dans l'image ci-dessous. Les couches internes ne dépendent pas directement des couches externes et les utilisent par injection. De plus, nous bénéficions d'une excellente flexibilité, chaque couche a donc son propre domaine de responsabilité et les données entrantes sont validées par chaque couche en fonction de ses besoins. Et par conséquent, les couches internes reçoivent toujours des données valides des couches externes. Sans parler des tests: les tests unitaires deviennent plus faciles avec l'utilisation de simulations de dépendance basées sur des interfaces, ce qui vous permet de faire abstraction des parties externes du système, telles que les bases de données.



Dans le cas de Typescript et Javascript, Inversion de contrôle (via Dependency Inversion)signifie l'incorporation (passage) de dépendances via des paramètres au lieu d'une importation explicite. Dans l'exemple de code suivant, nous utiliserons la bibliothèque Inversify , qui nous permet de décrire les dépendances à l'aide de décorateurs afin que les classes créées ultérieurement puissent avoir des conteneurs créés dynamiquement pour résoudre les dépendances.

Architecture


Pour cette application simple ...


Nous allons créer une application de panier d'achat simple qui peut gérer l'ajout de produits au panier. Un panier peut avoir plusieurs règles commerciales, telles qu'une quantité minimale ou maximale pour chaque article.

Plongeons dans les profondeurs des couches d'application, commençons par la couche la plus interne et allons séquentiellement vers l'extérieur.

Domaine


La couche domaine, par définition, est l'habitat des règles métier. Il ne sait rien des couches externes, n'a aucune dépendance et peut être facilement testé. Même si vous modifiez l'intégralité de l'application, le domaine restera intact, car il ne contient que des règles métier disponibles pour comprendre leur objectif pour tous ceux qui lisent ce code. Cette couche doit être recouverte autant que possible de tests.

Nous avons donc une classe de base Entity, qui peut contenir une certaine logique commune à toutes les classes de domaine. Dans cet exemple, toute la logique courante consiste à générer un identifiant résistant aux collisions adapté à une mise à l'échelle horizontale, et tous les objets utiliseront cette approche.

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

Après avoir écrit la classe Entity, vous pouvez commencer à créer notre classe de domaine central qui étend la classe abstraite Entity.

Il n'y a rien de très compliqué dans cette classe, mais il y a des points intéressants auxquels vous devez faire attention.

Premièrement, le constructeur est privé, ce qui signifie que l'exécution de new Cart () provoquera une erreur, ce dont nous avons besoin. Dans DDD, il est considéré comme une bonne pratique de conserver un objet de domaine toujours dans un état valide. Au lieu de créer directement un objet Cart vide, nous utilisons le modèle Factoryqui renvoie l'instance finie de la classe Cart. Pour garantir que la procédure de création a reçu tous les attributs requis, ils peuvent être validés. De même, les getters et setters sont utilisés pour interagir avec le domaine, pour cette raison même, l'état interne de la classe est stocké dans un objet props privé . Les accesseurs fournissent un accès en lecture aux attributs qui devraient être publics. De même, les setters publics et autres méthodes vous permettent de changer de domaine avec une garantie constante de la validité de l'état de l'objet.

Pour résumer, nous pouvons souligner un point important: la logique du domaine doit se concentrer sur le comportement, et non sur les propriétés.

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

Bien que notre objet Cart puisse être utilisé par l'application à l'aide de méthodes de domaine, dans certains cas, nous pouvons avoir besoin de le «déployer» dans un objet propre. Par exemple, pour enregistrer dans la base de données ou envoyer au client en tant qu'objet JSON. Cela peut être implémenté à l'aide de la méthode unmarshal () .

Pour augmenter la flexibilité de l'architecture, la couche de domaine peut également devenir une source d'événements de domaine. Ici, l'approche Event Sourcing peut être appliquée , avec la création d'événements lorsque les entités de domaine changent.

Scripts utilisateur


Ici, nous utiliserons des méthodes de domaine et des objets implémentés à partir du niveau d'infrastructure pour stocker des données.

Nous utilisons la bibliothèque inversify pour implémenter l'approche Inversion of Management, qui injecte le référentiel de la couche infrastructure dans ce scénario, nous offrant la possibilité de manipuler le panier à l'aide de méthodes de domaine et d'enregistrer les modifications dans la base de données par la suite.

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

Cette couche est responsable du fonctionnement de l'application. Les modifications de code apportées à cette couche n'affectent pas les entités de domaine ou les dépendances externes comme une base de données.

Infrastructure


Malgré toute l'évidence, la couche infrastructure interagit avec des systèmes externes, tels qu'une base de données.

Pour enregistrer des données dans la base de données, j'utilise les approches Data mapper et Repository.

Le mappeur peut obtenir les données source de la base de données et les convertir en l'objet de domaine correspondant:

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

Le référentiel lui-même peut dépendre de la bibliothèque cliente d'une base de données particulière. Par exemple, à partir du stockage en RAM, et utilisez ces méthodes pour gérer les données:

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

Conclusion


Il y a beaucoup de place pour des améliorations et des améliorations. Le code a été créé pour cette démonstration visuelle, et la première amélioration de l'architecture en couches pourrait être l'annonce de l'interface du référentiel dans la couche domaine et son implémentation dans la couche infrastructure, dont nous pourrons discuter la prochaine fois.

Le code source est disponible sur github .

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


All Articles