Привет, Хабр! В последнее время уделяю много внимание архитектуре и решил поделиться с сообществом переводом статьи Clean Architecture with Typescript: DDD, Onion автора Andre Bazaglia.

Введение


На протяжении более 6 лет моего профессионального опыта, я имел возможность работать в крутых технологических компаниях, которые уделяют много внимания высокой доступности и качеству кода. Мне доводилось иметь дело с критическими ситуациями, когда баги или даже секундные простои системы были недопустимы.

Целью данной статьи является не детальное покрытие сложных тем по DDD и Слоистой архитектуре, а пример реализации этих двух подходов в Typescript. Используемый проект является базовым и может быть доработан и расширен, например с использованием подхода CQRS.

Почему DDD?


Создаваемые программные продукты должны реализовывать поставленные бизнес-требования.

Данный подход упрощает тестирование доменного слоя, что позволяет быть уверенным в том, что все бизнес-требования учтены, и писать долгоживущий, защищенный от ошибок код.
DDD в сочетании со слоистой архитектурой позволяет избегать эффекта домино, когда изменение кода в одном месте приводит к неисчислимому количеству багов в разных местах.

Почему слоистая архитектура?


Этот подход отлично сочетается с DDD, так как вся система строится вокруг доменной модели, которая является центральным кругом на изображении ниже. Внутренние слои не зависят напрямую от внешних и используют их посредством инъекции. Плюсом мы получаем отличную гибкость, так каждый слой имеет свою зону ответственности и входящие данные валидируются каждым слоем в соответствии с его потребностями. А следовательно, внутренние слои всегда получают валидные данные от внешних слоёв. Не говоря уже о тестировании: юнит-тестирование становится более простым с использованием моков зависимостей на основе интерфейсов, что позволяет абстрагироваться от внешних частей системы, таких как базы данных.



В случае Typescript и Javascript, Инверсия управления (через Инверсию зависимостей) означает внедрение (передачу) зависимостей через параметры вместо явного импорта. В следующем примере кода мы будем использовать библиотеку Inversify, которая позволяет нам описывать зависимости используя декораторы для того, чтобы создаваемые впоследствии классы могли иметь динамически созданные контейнеры для разрешения зависимостей.

Архитектура


Для этого простого приложения...


Мы создадим простое приложение корзины покупок, способное обрабатывать добавление товаров в корзину. Корзина может иметь несколько бизнес-правил, таких как минимальное или максимальное количество для каждого товара.

Давайте погрузимся в глубину слоёв приложения, начнем с самого внутреннего слоя и будем продвигаться наружу последовательно.

Домен


Доменный слой, по определению является местом обитания бизнес-правил. Он ничего не знает про любые внешние слои, не имеет зависимостей и может быть легко протестирован. Даже если вы измените всё приложение вокруг, домен останется нетронутым, так как он содержит только бизнес-правила, доступные для понимания их назначения каждому читающему этот код. Данный слой должен быть максимально покрыт тестами.

Итак, мы имеем базовый класс Entity, который может содержать определенную логику, общую для всех доменных классов. В данном примере вся общая логика заключается в генерации устойчивого к коллизиям идентификатора, подходящего для горизонтального масштабирования, и все объекты будут использовать данный подход.

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

После написания класса Entity можно приступать к созданию нашего центрального доменного класса, расширяющего абстрактный класс Entity.

В этом классе нет ничего очень сложного, но есть некоторые интересные моменты, на которые следует обратить внимание.

Во первых, конструктор является приватным, это означает, что выполнение new Cart() вызовет ошибку, такое поведение нам и требуется. В DDD считается хорошей практикой сохранение доменного объекта всегда в валидном состоянии. Вместо непосредственного создания пустого объекта Cart, мы используем паттерн Фабрика, который возвращает готовый инстанс класса Cart. Для уверенности в том, что процедура создания получила все требуемые атрибуты, может выполняться их валидация. Подобным образом, геттеры и сеттеры используются для взаимодействия с доменом, именно по этой причине внутреннее состояние класса хранится в приватном объекте props. Геттеры предоставляют доступ на чтение к атрибутам, которые должны быть публичными. Аналогично, публичные сеттеры и другие методы позволяют изменять домен с постоянной гарантией валидности состояния объекта.

Подводя итог, можно подчеркнуть важный момент: доменная логика должна быть сфокусирована на поведениях, а не на свойствах.

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

В то время, как наш объект класса Cart может быть использован приложением посредством доменных методов, в некоторых случаях, нам может понадобиться «развернуть» его в чистый объект. Например, для сохранения в базу данных или отправки клиенту в виде JSON-объекта. Это может быть реализовано использованием метода unmarshal().

Для повышения гибкости архитектуры, доменный слой также может стать источником доменных событий. Здесь может быть применен подход Event Sourcing, с созданием событий при изменениях доменных сущностей.

Пользовательские сценарии


Здесь мы будем использовать доменные методы и внедренные с инфраструктурного уровня объекты для сохранения данных.

Мы используем библиотеку inversify для реализации подхода Инверсия управления, который внедряет репозиторий из слоя инфраструктуры в данный сценарий, предоставляя нам возможность манипулировать корзиной посредством доменных методов и сохранять изменения в базе данных после этого.

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

Этот слой отвечает за работу приложения. Изменения кода этого слоя не влияют на доменные сущности или внешние зависимости вроде базы данных.

Инфраструктура


Несмотря на всю очевидность, инфраструктурный слой реализует взаимодействие со внешними системами, такими как база данных.

Для сохранения данных в БД, я использую подходы Data mapper и Repository.

Маппер может получить исходные данные из БД и преобразовать их в соответствующий доменный объект:

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

Репозиторий сам по себе может зависеть от клиентской библиотеки конкретной БД. Например, от хранилища в оперативной памяти, и использовать эти методы для управления данными:

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

Заключение


Осталось много пространства для доработок и улучшений. Код был создан для данной наглядной демонстрации и первым улучшением слоистой архитектуры может стать разнесение объявления интерфейса репозитория в доменный слой и его реализации в слой инфраструктуры, что мы сможем обсудить в следующий раз.

Исходный код доступен на гитхабе.