Reine Architektur mit Typescript: DDD und Layered Architecture

Hallo Habr! Vor kurzem habe ich der Architektur viel Aufmerksamkeit geschenkt und beschlossen, eine Übersetzung des Artikels Saubere Architektur mit Typoskript: DDD, Zwiebel von André Bazaglia mit der Community zu teilen .

Einführung


Seit über 6 Jahren meiner Berufserfahrung hatte ich die Möglichkeit, in coolen Technologieunternehmen zu arbeiten, die viel Wert auf hohe Verfügbarkeit und Codequalität legen. Ich musste mich mit kritischen Situationen auseinandersetzen, in denen Fehler oder sogar eine zweite Ausfallzeit des Systems nicht akzeptabel waren.

Der Zweck dieses Artikels ist nicht eine detaillierte Beschreibung komplexer Themen zu DDD und Layered Architecture, sondern ein Beispiel für die Implementierung dieser beiden Ansätze in Typescript. Das verwendete Projekt ist grundlegend und kann beispielsweise mithilfe des CQRS- Ansatzes verfeinert und erweitert werden .

Warum DDD?


Erstellte Softwareprodukte müssen die festgelegten Geschäftsanforderungen implementieren.

Dieser Ansatz vereinfacht das Testen der Domänenschicht, wodurch sichergestellt werden kann, dass alle Geschäftsanforderungen berücksichtigt werden, und langlebiger, fehlersicherer Code geschrieben wird.
DDD in Kombination mit einer mehrschichtigen Architektur vermeidet den Dominoeffekt, wenn das Ändern des Codes an einer Stelle zu unzähligen Fehlern an verschiedenen Stellen führt.

Warum geschichtete Architektur?


Dieser Ansatz passt gut zu DDD, da das gesamte System um ein Domänenmodell herum aufgebaut ist, das der mittlere Kreis im Bild unten ist. Die inneren Schichten sind nicht direkt von den äußeren abhängig und werden durch Injektion verwendet. Darüber hinaus erhalten wir eine hervorragende Flexibilität, sodass jede Schicht ihren eigenen Verantwortungsbereich hat und die eingehenden Daten von jeder Schicht entsprechend ihren Anforderungen validiert werden. Daher erhalten innere Schichten immer gültige Daten von äußeren Schichten. Ganz zu schweigen vom Testen: Das Testen von Einheiten wird mithilfe von Abhängigkeitsmobs, die auf Schnittstellen basieren, einfacher, sodass Sie von externen Teilen des Systems, wie z. B. Datenbanken, abstrahieren können.



Im Fall von Typescript und Javascript Inversion der Kontrolle (über Dependency Inversion)bedeutet das Einbetten (Übergeben) von Abhängigkeiten durch Parameter anstelle des expliziten Imports. Im folgenden Codebeispiel verwenden wir die Inversify- Bibliothek , mit der wir Abhängigkeiten mithilfe von Dekoratoren beschreiben können, sodass später erstellte Klassen dynamisch Container zum Auflösen von Abhängigkeiten erstellen können.

Die Architektur


Für diese einfache Anwendung ...


Wir erstellen eine einfache Warenkorbanwendung, mit der Produkte zum Warenkorb hinzugefügt werden können. Ein Warenkorb kann mehrere Geschäftsregeln haben, z. B. eine minimale oder maximale Menge für jeden Artikel.

Lassen Sie uns in die Tiefen der Anwendungsebenen eintauchen, mit der innersten Ebene beginnen und nacheinander nach außen gehen.

Domain


Die Domänenschicht ist per Definition der Lebensraum für Geschäftsregeln. Es weiß nichts über externe Schichten, hat keine Abhängigkeiten und kann leicht getestet werden. Selbst wenn Sie die gesamte Anwendung ändern, bleibt die Domain unberührt, da sie nur Geschäftsregeln enthält, die verfügbar sind, um ihren Zweck für alle zu verstehen, die diesen Code lesen. Diese Schicht sollte so weit wie möglich mit Tests bedeckt werden.

Wir haben also eine Entity-Basisklasse, die bestimmte Logik enthalten kann, die allen Domänenklassen gemeinsam ist. In diesem Beispiel besteht die allgemeine Logik darin, eine kollisionssichere Kennung zu generieren, die für die horizontale Skalierung geeignet ist, und alle Objekte verwenden diesen Ansatz.

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

Nach dem Schreiben der Entity-Klasse können Sie mit der Erstellung unserer zentralen Domänenklasse beginnen, die die abstrakte Entity-Klasse erweitert.

In dieser Klasse gibt es nichts sehr Kompliziertes, aber es gibt einige interessante Punkte, auf die Sie achten sollten.

Erstens ist der Konstruktor privat, was bedeutet, dass das Ausführen von new Cart () einen Fehler verursacht, den wir benötigen. In DDD wird empfohlen, ein Domänenobjekt immer in einem gültigen Zustand zu halten. Anstatt direkt ein leeres Cart-Objekt zu erstellen, verwenden wir das Factory-MusterDies gibt die fertige Instanz der Cart-Klasse zurück. Um sicherzustellen, dass der Erstellungsvorgang alle erforderlichen Attribute erhalten hat, können sie validiert werden. In ähnlicher Weise werden Getter und Setter verwendet, um mit der Domäne zu interagieren. Aus diesem Grund wird der interne Status der Klasse in einem privaten Requisitenobjekt gespeichert . Getter bieten Lesezugriff auf Attribute, die öffentlich sein sollten. In ähnlicher Weise können Sie mit öffentlichen Setzern und anderen Methoden die Domäne mit einer konstanten Garantie für die Gültigkeit des Status des Objekts ändern.

Zusammenfassend können wir einen wichtigen Punkt hervorheben: Die Domänenlogik sollte sich auf das Verhalten und nicht auf Eigenschaften konzentrieren.

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

Während unser Cart-Objekt von der Anwendung mithilfe von Domänenmethoden verwendet werden kann, müssen wir es in einigen Fällen möglicherweise in einem sauberen Objekt "bereitstellen". Zum Beispiel, um in der Datenbank zu speichern oder als JSON-Objekt an den Client zu senden. Dies kann mit der Methode unmarshal () implementiert werden .

Um die Flexibilität der Architektur zu erhöhen, kann die Domänenschicht auch eine Quelle für Domänenereignisse werden. Hier kann der Event-Sourcing- Ansatz angewendet werden , bei dem Ereignisse erstellt werden, wenn sich Domänenentitäten ändern.

Benutzerskripte


Hier werden Domänenmethoden und Objekte verwendet, die auf Infrastrukturebene implementiert wurden, um Daten zu speichern.

Wir verwenden die inversify- Bibliothek , um den Inversion of Management-Ansatz zu implementieren, der das Repository von der Infrastrukturschicht in dieses Szenario einfügt und uns die Möglichkeit bietet, den Warenkorb mithilfe von Domänenmethoden zu bearbeiten und anschließend Änderungen an der Datenbank zu speichern.

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

Diese Schicht ist für den Betrieb der Anwendung verantwortlich. Codeänderungen an dieser Ebene wirken sich nicht auf Domänenentitäten oder externe Abhängigkeiten wie eine Datenbank aus.

Infrastruktur


Trotz aller Offensichtlichkeit interagiert die Infrastrukturschicht mit externen Systemen wie einer Datenbank.

Um Daten in der Datenbank zu speichern, verwende ich die Ansätze Data Mapper und Repository.

Mapper kann die Quelldaten aus der Datenbank abrufen und in das entsprechende Domänenobjekt konvertieren:

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

Das Repository selbst kann von der Clientbibliothek einer bestimmten Datenbank abhängen. Zum Beispiel aus dem Speicher im RAM, und verwenden Sie diese Methoden, um Daten zu verwalten:

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

Fazit


Es gibt viel Raum für Verbesserungen und Verbesserungen. Der Code wurde für diese visuelle Demonstration erstellt. Die erste Verbesserung der Schichtarchitektur könnte die Ankündigung der Deklaration der Repository-Schnittstelle in der Domänenschicht und deren Implementierung in der Infrastrukturschicht sein, die wir beim nächsten Mal diskutieren können.

Der Quellcode ist auf github verfügbar .

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


All Articles