* В данной статье примеры будут на TypeScript


Краткое предисловие


Что такое DDD (Domain Driven Design) вопрос обширный, но если в кратце (как Я это понимаю) — это про перенос бизнес логики, как она есть, в код, без углубления в технические детали. То есть в идеале, человек, который знает за бизнес процессы, может открыть код и понять, что там происходит (так кстати часто бывает в 1С).


Всё это сопровождается кучей разных рекомендаций по технической реализации вопроса.


Для лучшего понимания статьи советую прочитать материалы, касающиеся DDD.


Гексагональная архитектура — это один из подходов реализации DDD.


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


Конкретнее описывать не буду, на хабре полно статей на эту тему, всё давно разжевано и практически переварено.


Вместо этого Я покажу картинку (рис.1):


рис.1


Скажите пожалуйста, что Вам понятно из этой картинки?


Например мне, когда Я первый раз увидел её, было непонятно абсолютно всё.


И, как бы ни было смешно, это первая проблема для меня.


Визуализация должна давать понимание, а не добавлять вопросов.


В ходе изучения всё частично становится на свои места, но вопросы и проблемы остаются.


И тут Я задумался о приложении в общем, развивая идеи вынесенные из DDD в целом и гексагональной архитектуры в частности.


Что мы имеем:


  • Реальная жизнь. Здесь есть бизнес процессы, которые мы должны автоматизировать.
  • Приложение, которое решает проблемы из реальной жизни, которое в свою очередь, не находится в вакууме. У приложения есть:
    1. Пользователи, будь то АПИ, кроны, пользовательские интерфейсы и т.д.
    2. Сам код приложения.
    3. Объекты данных — БД, другие АПИ.

Движение идёт сначала сверху вниз, потом обратно, то есть:


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

Всё логично.


Теперь углубимся в код приложения.


Как сделать так, чтобы код был понятным, тестируемым, но при этом максимально независимым от внешних объектов данных, таких как БД, АПИ и т.д.?


В ответ на этот вопрос родилась следующая схема (рис.2):


рис.2


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


Ещё одна разница состоит в том, что добавлен уровень бизнес процессов, о котором поговорим ниже.


Многоуровневая блочная архитектура


Пробежимся по схеме.


На рисунке (рис.2), слева, мы видим названия сущностей, справа — назначение уровней и их зависимости друг от друга.


Сверху вниз:


  1. Порты— уровень взаимодействия, который зависит от уровня бизнес процессов. Уровень отвечает за взаимодействие с приложением, то есть хранит контроллеры. Пользоваться приложением можно только через порты.
  2. Ядро приложения — уровень бизнес процессов, является центром всех зависимостей. Всё приложение строится исходя из бизнес процессов.
  3. Домены — уровень бизнес логики, который зависит от уровня бизнес процессов. Домены образуются и выстраиваются на основании тех бизнес процессов, которые мы хотим автоматизировать. Домены отвечают за конкретную бизнес логику.
  4. Адаптеры — уровень агрегации данных, который зависит от уровня бизнес логики. Сверху получает интерфейсы данных, которые должен реализовать. Отвечает за получение и нормализацию данных из объектов данных.
  5. Объекты данных — уровень хранения данных, который не входит в приложение, но т. к. приложение не существует в вакууме, мы должны учитывать их.

Несколько правил


По ходу практики родилось и несколько правил, которые позволяют сохранять чистоту, простоту и универсальность кода:


  1. Бизнес процессы должны возвращать однозначный ответ.
    Например создание клиента, при наличии партнерской программы. Можно сделать бизнес процесс, который создает клиента, а если у него есть партнерский код — добавляет его ещё и в партнеры, но это не правильно. Из за подобного подхода ваши бизнес процессы становятся непрозрачными и излишне сложными. Вы должны создать 2 бизнес процесса — создание клиента и создание партнера.
  2. Домены не должны общаться на прямую между собой. Всё общение между доменами происходит в бизнес процессах. Иначе домены становятся взаимозависимыми.
  3. Все доменные контроллеры не должны содержать бизнес логики, они лишь вызывают доменные методы.
  4. Доменные методы должны быть реализованы как чистые функции, у них не должно быть внешних зависимостей.
  5. У методов все входящие данные уже должны быть провалидированы, все необходимые параметры должны быть обязательными (тут помогут data-transfer-object-ы или просто DTO-шки).
  6. Для unit тестирования уровня нужен нижестоящий уровень. Инъекция (DI) производится только в нижестоящий уровень, например тестируете домены > подменяете адаптеры.

Как происходит разработка, согласно этой схеме


  1. Выделяются бизнес процессы, которые мы хотим автоматизировать, описываем уровень бизнес процессов.
  2. Бизнес процессы разбиваются на цепочки действий, которые связаны с конкретными областями (домены).
  3. Решаем как мы храним данные и с какими внешними сервисами взаимодействуем — подбираем адаптеры и источники данных, которые наши адаптеры поддерживают. Например в случае с БД мы решаем хранить наши данные в реляционной базе данных, ищем ORM, которая умеет с ними работать и при этом отвечает нашим требованиям, затем под неё выбираем БД, с которой наша ORM умеет работать. В случае с внешними API, часто придется писать свои адаптеры, но опять таки с оглядкой на домены, потому что у адаптера есть 2 главные задачи: получить данные и отдать их наверх в необходимом домену, адаптированном виде.
  4. Решаем как мы взаимодействуем с приложением, то есть продумываем порты.

Небольшой пример


Мы хотим сделать небольшую CRM, хранить данные хотим в реляционной БД, в качестве ORM используем TypeORM, в качестве БД — PostgresSQL.


Будет показан не весь код сервера, а лишь основные моменты, которые Вы сможете применить в своём приложении уже сейчас

Для начала реализуем бизнес процесс создания клиента.


Подготовим структуру папок:


рис.3


Для удобства добавим алиасы:


@clients = src/domains/clients
@clientsEnities = src/adapters/typeorm/entities/clients
@adapters = src/adapters

Из чего состоит бизнес процесс в самом простом виде:


  • на вход мы получаем данные о клиенте
  • нам нужно сохранить его в БД

После общения с доменным экспертом узнаем, что помимо общих данных клиента, у него могут быть различные контактные данные.


Формируем доменные модели, которые должны реализовать наши адаптеры. В нашем случае это 2 модели: клиент и контактные данные


domains/clients/models/Client.ts

import { Contact } from './Contact';

export interface Client {
  id: number;
  title: string;
  contacts?: Contact[];
}

domains/clients/models/Contact.ts

import { Client } from './Client';

export enum ContactType {
  PHONE = 'phone',
  EMAIL = 'email',
}

export interface Contact {
  client?: Client;
  type: ContactType;
  value: string;
}

Под них формируем TypeORM enitity


adapters/typeorm/entities/clients/Client.ts

import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { Client as ClientModel } from '@clients/models/Client';
import { Contact } from './Contact';

@Entity({ name: 'clients' })
export class Client implements ClientModel {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  title: string;

  @OneToMany((_type) => Contact, (contact) => contact.client)
  contacts?: Contact[];
}

adapters/typeorm/entities/clients/Contact.ts

import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { Contact as ContactModel, ContactType } from '@clients/models/Contact';
import { Client } from './Client';

@Entity({ name: 'contacts' })
export class Contact implements ContactModel {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'string' })
  type: ContactType;

  @Column()
  value: string;

  @ManyToOne((_type) => Client, (client) => client.contacts, { nullable: false })
  client?: Client;
}

Сразу объясню почему поля со связями у меня помечены как опциональные: т.к. данные лежат в разных таблицах, их всегда надо дозапрашивать. Можно конечно их сделать обязательными, но если вы где-то забудете дозапросить данные — получите ошибку.


Реализуем доменный метод создания клиента и доменный контроллер.


domains/clients/methods/createClient.ts

import { Repository } from 'typeorm';
import { Client as ClientModel } from '@clients/models/Client';
import { Client } from '@clientsEnities/Client';

export async function  createClient(repo: Repository<Client>, clientData: ClientModel) {
  const client = await repo.save(clientData);

  return client;
}

domains/clients/index.ts

import { Connection } from 'typeorm';
import { Client } from '@clientsEnities/Client';
import { Client as ClientModel } from '@clients/models/Client';
import { createClient } from './methods/createClient';

export class Clients {
  protected _connection: Connection;

  constructor(connection: Connection) {
    if (!connection) {
      throw new Error('No connection!');
    }
    this._connection = connection;
  }

  protected getRepository<T>(Entity: any) {
    return this._connection.getRepository<T>(Entity);
  }

  protected getTreeRepository<T>(Entity: any) {
    return this._connection.getTreeRepository<T>(Entity);
  }

  public async createClient(clientData: ClientModel) {
    const repo = this.getRepository<Client>(Client);

    const client = await createClient(repo, clientData);

    return client;
  }
}

Т.к. TypeORM немного специфичная библиотека, внутрь мы прокидываем (для DI) не конкретные репозитории, а connection, который будем подменять при тестах.


Осталось создать бизнес процесс.


businessProcesses/createClient.ts

import { Client as ClientModel } from '@clients/models/Client';
import { Clients } from '@clients';
import { db } from '@adapters/typeorm'; // Я складываю TypeORM соединения в объект db

export function createClient(clientData: ClientModel) {
  const clients = new ClientService(db.connection)

  const client = await clients.createClient(clientData)

  return  client
}

В примере не буду показывать как реализовать порты, которые по сути являются простыми контроллерами, которые вызывают те или иные бизнес процессы. Тут уж Вы сами как нибудь.


Что нам даёт данная архитектура?


  1. Понятную и удобную структуру папок и файлов.
  2. Удобное тестирование. Т. к. всё приложение разбито на слои — выберете нужный слой, подменяете нижестоящий слой и тестируете.
  3. Удобное логирование. В примере видно, что логирование можно встроить на каждый этап работы приложения — от банального замера скорости выполнения конкретного доменного метода (просто обернуть функцию метода функцией оберткой, которая всё замерит), до полного логирования всего бизнес процесса, включая промежуточные результаты.
  4. Удобную валидацию данных. Каждый уровень может проверять критичные для себя данные. Например тот же бизнес процесс создания клиента по хорошему в начале должен создать DTO для модели клиента, который провалидирует входящие данные, затем он должен вызвать доменный метод, который проверит, существует ли уже такой клиент и только потом создаст клиента. Сразу скажу про права доступа — Я считаю что права доступа это адаптер, который Вы должны также прокидывать при создании доменного контроллера и внутри в контроллерах проверять права.
  5. Легкое изменение кода. Допустим Я хочу после создания клиента создавать оповещение, то есть хочу обновить бизнес процесс. Захожу в бизнес процесс, в начале добавляю инциализацию домена notifications и после получения результата создания клиента делаю notifications.notifyClient({ client: client.id, type:’SUCCESS_REGISTRATION’ })

На этом всё, надеюсь было интересно, спасибо за внимание!