Arsitektur murni dengan naskah: DDD dan arsitektur berlapis

Halo, Habr! Baru-baru ini saya telah menaruh banyak perhatian pada arsitektur dan memutuskan untuk berbagi dengan komunitas terjemahan dari artikel Arsitektur Bersih dengan naskah: DDD, Onion oleh AndrΓ© Bazaglia .

pengantar


Selama lebih dari 6 tahun pengalaman profesional saya, saya memiliki kesempatan untuk bekerja di perusahaan teknologi keren yang memperhatikan ketersediaan tinggi dan kualitas kode. Saya harus berurusan dengan situasi kritis ketika bug atau bahkan downtime kedua dari sistem tidak dapat diterima.

Tujuan artikel ini bukan cakupan terperinci dari topik kompleks pada DDD dan arsitektur Layered, tetapi contoh implementasi dari dua pendekatan ini dalam naskah. Proyek yang digunakan adalah dasar dan dapat disempurnakan dan diperluas, misalnya, menggunakan pendekatan CQRS .

Mengapa DDD?


Produk perangkat lunak yang dibuat harus menerapkan persyaratan bisnis yang ditetapkan.

Pendekatan ini menyederhanakan pengujian lapisan domain, yang memungkinkan Anda untuk memastikan bahwa semua persyaratan bisnis diperhitungkan dan menulis kode tahan-kesalahan yang berumur panjang.
DDD dalam kombinasi dengan arsitektur berlapis menghindari efek domino ketika mengubah kode di satu tempat menyebabkan bug yang tak terhitung jumlahnya di tempat yang berbeda.

Mengapa arsitektur berlapis?


Pendekatan ini berjalan baik dengan DDD, karena seluruh sistem dibangun di sekitar model domain, yang merupakan lingkaran pusat pada gambar di bawah ini. Lapisan dalam tidak secara langsung tergantung pada yang luar dan menggunakannya dengan injeksi. Plus, kami mendapatkan fleksibilitas yang sangat baik, sehingga setiap lapisan memiliki area tanggung jawab masing-masing dan data yang masuk divalidasi oleh setiap lapisan sesuai dengan kebutuhannya. Dan oleh karena itu, lapisan dalam selalu menerima data yang valid dari lapisan luar. Belum lagi pengujian: pengujian unit menjadi lebih mudah dengan penggunaan mock ketergantungan berdasarkan antarmuka, yang memungkinkan Anda untuk abstrak dari bagian eksternal sistem, seperti database.



Dalam kasus TypeScript dan Javascript, Inversi kontrol (via Dependency Inversion)berarti menyematkan (meneruskan) dependensi melalui parameter alih-alih impor eksplisit. Dalam contoh kode berikut, kami akan menggunakan perpustakaan Inversify , yang memungkinkan kami untuk menjelaskan dependensi menggunakan dekorator sehingga kelas yang dibuat nanti dapat memiliki wadah yang dibuat secara dinamis untuk menyelesaikan dependensi.

Arsitektur


Untuk aplikasi sederhana ini ...


Kami akan membuat aplikasi keranjang belanja sederhana yang dapat menangani penambahan produk ke keranjang. Keranjang dapat memiliki beberapa aturan bisnis, seperti jumlah minimum atau maksimum untuk setiap item.

Mari selami kedalaman lapisan aplikasi, mulailah dengan lapisan paling dalam dan bergerak ke luar secara berurutan.

Domain


Lapisan domain, menurut definisi, adalah habitat aturan bisnis. Ia tidak tahu apa-apa tentang lapisan eksternal, tidak memiliki dependensi dan dapat dengan mudah diuji. Bahkan jika Anda mengubah seluruh aplikasi, domain akan tetap tidak tersentuh, karena hanya berisi aturan bisnis yang tersedia untuk memahami tujuannya bagi semua orang yang membaca kode ini. Lapisan ini harus ditutup sebanyak mungkin dengan tes.

Jadi, kami memiliki kelas dasar Entity, yang dapat berisi logika tertentu yang umum untuk semua kelas domain. Dalam contoh ini, semua logika umum adalah untuk menghasilkan pengidentifikasi tahan-tabrakan yang cocok untuk penskalaan horizontal, dan semua objek akan menggunakan pendekatan ini.

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

Setelah menulis kelas Entity, Anda bisa mulai membuat kelas domain pusat kami yang memperluas kelas abstrak Entity.

Tidak ada yang sangat rumit di kelas ini, tetapi ada beberapa poin menarik yang harus Anda perhatikan.

Pertama, konstruktor bersifat pribadi, yang berarti mengeksekusi Cart baru () akan menyebabkan kesalahan, yang merupakan apa yang kita butuhkan. Dalam DDD, itu dianggap praktik yang baik untuk menjaga objek domain selalu dalam keadaan valid. Alih-alih secara langsung membuat objek Keranjang kosong, kami menggunakan pola Pabrikyang mengembalikan instance selesai dari kelas Cart. Untuk memastikan bahwa prosedur pembuatan telah menerima semua atribut yang diperlukan, mereka dapat divalidasi. Demikian pula, getter dan setter digunakan untuk berinteraksi dengan domain, untuk alasan inilah keadaan internal kelas disimpan dalam objek alat peraga pribadi . Getters menyediakan akses baca ke atribut yang seharusnya publik. Demikian pula, setter publik dan metode lain memungkinkan Anda untuk mengubah domain dengan jaminan konstan tentang keabsahan keadaan objek.

Untuk meringkas, kita dapat menekankan poin penting: logika domain harus difokuskan pada perilaku, dan bukan pada properti.

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

Meskipun objek Keranjang kami dapat digunakan oleh aplikasi menggunakan metode domain, dalam beberapa kasus, kami mungkin perlu "menyebarkan" ke objek bersih. Misalnya, untuk menyimpan ke database atau mengirim ke klien sebagai objek JSON. Ini dapat diimplementasikan menggunakan metode unmarshal () .

Untuk meningkatkan fleksibilitas arsitektur, lapisan domain juga dapat menjadi sumber peristiwa domain. Di sini, pendekatan Pengadaan Acara dapat diterapkan , dengan pembuatan acara saat entitas domain berubah.

Skrip pengguna


Di sini kita akan menggunakan metode dan objek domain yang diimplementasikan dari tingkat infrastruktur untuk menyimpan data.

Kami menggunakan pustaka terbalik untuk mengimplementasikan pendekatan Inversion of Management, yang menyuntikkan repositori dari lapisan infrastruktur ke dalam skenario ini, memberi kami kesempatan untuk memanipulasi keranjang menggunakan metode domain dan menyimpan perubahan ke database setelah itu.

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

Lapisan ini bertanggung jawab untuk pengoperasian aplikasi. Perubahan kode pada lapisan ini tidak memengaruhi entitas domain atau dependensi eksternal seperti database.

Infrastruktur


Terlepas dari semua kejelasannya, lapisan infrastruktur berinteraksi dengan sistem eksternal, seperti database.

Untuk menyimpan data ke database, saya menggunakan Data mapper dan pendekatan Repositori.

Mapper bisa mendapatkan sumber data dari database dan mengonversinya ke objek domain yang sesuai:

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

Repositori itu sendiri mungkin bergantung pada pustaka klien dari database tertentu. Misalnya, dari penyimpanan di RAM, dan gunakan metode ini untuk mengelola 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)
  }
}

Kesimpulan


Ada banyak ruang untuk perbaikan dan peningkatan. Kode dibuat untuk demonstrasi visual ini, dan peningkatan pertama dalam arsitektur layered bisa berupa pengumuman antarmuka repositori di lapisan domain dan implementasinya di lapisan infrastruktur, yang bisa kita bahas lain kali.

Kode sumber tersedia di github .

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


All Articles