Pure architecture with Typescript: DDD and layered architecture

Hello, Habr! Recently I have been paying much attention to architecture and decided to share with the community a translation of the article Clean Architecture with Typescript: DDD, Onion by André Bazaglia .

Introduction


For over 6 years of my professional experience, I have had the opportunity to work in cool technology companies that pay a lot of attention to high availability and code quality. I had to deal with critical situations when bugs or even a second downtime of the system were unacceptable.

The purpose of this article is not a detailed coverage of complex topics on DDD and Layered architecture, but an example of the implementation of these two approaches in Typescript. The project used is basic and can be refined and expanded, for example, using the CQRS approach .

Why DDD?


Created software products must implement the set business requirements.

This approach simplifies the testing of the domain layer, which allows you to be sure that all business requirements are taken into account and write long-lived error-proof code.
DDD in combination with a layered architecture avoids the domino effect when changing the code in one place leads to innumerable bugs in different places.

Why layered architecture?


This approach goes well with DDD, as the whole system is built around a domain model, which is the center circle in the image below. The inner layers are not directly dependent on the outer ones and use them by injection. Plus, we get excellent flexibility, so each layer has its own area of ​​responsibility and the incoming data is validated by each layer in accordance with its needs. And therefore, inner layers always receive valid data from outer layers. Not to mention testing: unit testing becomes easier with the use of dependency mocks based on interfaces, which allows you to abstract from external parts of the system, such as databases.



In the case of Typescript and Javascript, Inversion of control (via Dependency Inversion)means embedding (passing) dependencies through parameters instead of explicit import. In the following code example, we will use the Inversify library , which allows us to describe dependencies using decorators so that classes created later can have dynamically created containers for resolving dependencies.

Architecture


For this simple application ...


We will create a simple shopping cart application that can handle adding products to the cart. A cart may have several business rules, such as a minimum or maximum quantity for each item.

Let's dive into the depths of the application layers, start with the innermost layer and move outwards sequentially.

Domain


The domain layer, by definition, is the habitat of business rules. It knows nothing about any external layers, has no dependencies and can be easily tested. Even if you change the entire application around, the domain will remain untouched, since it contains only business rules that are available to understand their purpose for everyone reading this code. This layer should be covered as much as possible with tests.

So, we have an Entity base class, which can contain certain logic common to all domain classes. In this example, all the common logic is to generate a collision-resistant identifier suitable for horizontal scaling, and all objects will use this approach.

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

After writing the Entity class, you can begin to create our central domain class that extends the Entity abstract class.

There is nothing very complicated in this class, but there are some interesting points that you should pay attention to.

Firstly, the constructor is private, which means that executing new Cart () will cause an error, which is what we need. In DDD, it is considered good practice to keep a domain object always in a valid state. Instead of directly creating an empty Cart object, we use the Factory patternwhich returns the finished instance of the Cart class. To ensure that the creation procedure has received all the required attributes, they can be validated. Similarly, getters and setters are used to interact with the domain, for this very reason the internal state of the class is stored in a private props object . Getters provide read access to attributes that should be public. Similarly, public setters and other methods allow you to change the domain with a constant guarantee of the validity of the state of the object.

To summarize, we can emphasize an important point: domain logic should be focused on behavior, and not on properties.

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

While our Cart object can be used by the application using domain methods, in some cases, we may need to “deploy” it into a clean object. For example, to save to the database or send to the client as a JSON object. This can be implemented using the unmarshal () method .

To increase the flexibility of the architecture, the domain layer can also become a source of domain events. Here, the Event Sourcing approach can be applied , with the creation of events when domain entities change.

User scripts


Here we will use domain methods and objects implemented from the infrastructure level to store data.

We use the inversify library to implement the Inversion of Management approach, which injects the repository from the infrastructure layer into this scenario, providing us with the ability to manipulate the basket using domain methods and save changes to the database after that.

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

This layer is responsible for the operation of the application. Code changes to this layer do not affect domain entities or external dependencies like a database.

Infrastructure


Despite all the obviousness, the infrastructure layer interacts with external systems, such as a database.

To save data to the database, I use the Data mapper and Repository approaches.

Mapper can get the source data from the database and convert it to the corresponding domain object:

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

The repository itself may depend on the client library of a particular database. For example, from storage in RAM, and use these methods to manage data:

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


There is a lot of room for improvements and improvements. The code was created for this visual demonstration, and the first improvement in the layered architecture could be the announcement of the repository interface declaration in the domain layer and its implementation in the infrastructure layer, which we can discuss next time.

Source code is available on github .

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


All Articles