Не стоит воспринимать статью за единственно верный подход. Вариаций много, это все лишь видение автора на тематику вопроса.

Погружение

Domain Driven Design - это набор принципов и схем, направленных на создание оптимальных систем объектов. Он сводится к созданию программных абстракций, которые называются моделями предметных областей. В эти модели входит бизнес-логика, устанавливающая связь между реальными условиями области применения продукта и кодом.

Таким образом, это  декларации, абстракции, схемы, интерфейсы нашего будущего приложения.

Например, используя typescript в домене, можно создать интерфейсы и сущности, описать используемые параметры. Далее все реализуется на уровне application в приложении. В домен ничего не должно проникать извне - он является паттерном чистой архитектуры и соответствует принципу разделения ответственности.

Гексагональная архитектура является результатом работы Алистера Кокберна. Это архитектурный шаблон, используемый для разработки программных приложений.

Основа данной архитектуры - порты и адаптеры.

Порты - это интерфейсы нашего приложения,

Адаптеры -  реализация наших портов.

Гексагон - фигура, имеющая 6 сторон, шестиугольник. В нашем случае слоистая или многогранная архитектура.

Преимущества данного метода:

  1. Независимость: возможность не зацикливаться на бизнес логике.
    Можно задекларировать, описать схему работы нашего приложения до создания внешних сервисов, использовать замоканные данные в реализации адаптеров.

  2. Гибкость: использование любых фреймворков, перенос доменов адаптеров в другие проекты, добавление новых адаптеров без изменения исходного кода.

  3. Легкая изменчивость: изменения в одной области нашего приложения не влияют на другие области.

Минусы

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

Также могут возникнуть сложности реализации с graphql.


Как это работает на практике?

Порты

Порты могут быть первичными (входящими) primary и вторичными (исходящими) secondary -  это связи между внешним миром и ядром приложения. 

Первичные  порты — это запросы поступающие в приложение http, api, подключение к бд. 

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

Такой подход гарантирует разделение бизнес-логики и технических уровней. При изменении стека фреймворка код домена останется прежним. Ядро содержит основную бизнес-логику и бизнес-правила.

Адаптеры

Адаптеры служат реализацией наших портов. Есть два типа адаптеров: первичный и вторичный - по аналогии с портами.

К примеру, адаптеры, взаимодействующие с веб-браузером реализуют вторичный порт, а те адаптеры, которые устанавливают связь с внешним миром (api), реализуют первичный порт.

Порты позволяют нам подключать адаптеры к основному домену.


Организация в проекте

INFRASTRUCTURE - это бизнес-логика

Adapter - реализует первичный primary (pr) порт, связывает внешний мир с доменом

Services - реализует вторичный secondary (sec), адаптер связывает приложение с доменом (в сервисах можно работать с браузерным api)

Schema - используется для валидации данных, пришедших от INFRASTRUCTURE. В последующем используется в DTO для преобразования в Entities

Commands - входные данные для адаптеров

Controller - Зависят от фреймворка. Это то, что вызывает сервис, например, в случае vuex или redux будет actions


Переходим к коду

Пример для ознакомления https://github.com/jtapes/geksagon-architecture-domain-driven-design

Структура

Для начала создадим сущности в нашем домене.

Создадим продукт нашего магазина:

export type ProductId = string;
export type ProductName = string;
export type ProductPrice = number;

export class ProductEntity {
  constructor(
    private readonly _id: ProductId,
    private readonly _name: ProductName,
    private readonly _price: ProductPrice
  ) {}

  /* istanbul ignore next */
  public get id() {
    return this._id;
  }

  /* istanbul ignore next */
  public get name() {
    return this._name;
  }

  /* istanbul ignore next */
  public get price() {
    return this._price;
  }
}

Создадим листинг продуктов:

import { ProductEntity } from "./ProductEntity";

export class ProductListEntity {
  constructor(protected readonly _products: ProductEntity[] = []) {}

  /* istanbul ignore next */
  get products() {
    return this._products;
  }

  get namesLog() {
    return this._products.map((product) => product.name).join(" ");
  }
}

Если используем методы или сложные геттеры и сеттеры, рекомендую писать тесты:

import { ProductListingMock } from "../../../application/mocks/ProductListingMock";

describe("Testing ProductListEntity", () => {
  test("get namesLog", () => {
    expect(ProductListingMock.namesLog === "snickers mars kinder").toBeTruthy();
  });
});

пример условный.

Для тестов используем моки. Также мы можем использовать их для демонстрации в приложении, или в первичных адаптерах, пока готовятся данные на бэкенде.

import { ProductListEntity } from "../../domain/product/ProductListEntity";
import { ProductEntity } from "../../domain/product/ProductEntity";

export const ProductListingMock = new ProductListEntity([
  new ProductEntity("1", "snickers", 60),
  new ProductEntity("2", "mars", 80),
  new ProductEntity("3", "kinder", 120),
]);

Создадим интерфейс первичного порта ProductLoadPort для получения данных извне:

import { Either } from "@sweet-monads/either";
import { ErrorEntity } from "../ErrorEntity";
import { ProductListEntity } from "./ProductListEntity";
import { ProductLoadCommand } from "./ProductLoadCommand";
export interface ProductLoadPort {
  load(command: ProductLoadCommand): Either<ErrorEntity, ProductListEntity>;
}

На вход принимаем команду ProductLoadCommand и отдаем ProductListEnitity в случае успеха или ErrorEntities при ошибке.

ProductLoadCommand:

import { ProductId } from "./ProductEntity";

export class ProductLoadCommand {
  constructor(
    private readonly _ids: ProductId[],
    private readonly _lang: string = "ru"
  ) {}

  public get ids(): ProductId[] {
    return this._ids;
  }

  public get lang(): ProductId {
    return this._lang;
  }
}

Реализуем этот порт в первичном адаптере:

import { ProductLoadPort } from "../../../domain/product/ProductLoadPort";
import { ProductLoadCommand } from "../../../domain/product/ProductLoadCommand";
import { productsMapper } from "../../mappers/ProductMapper";
import { ProductsResponseSchema } from "../../schema/ProductsSchema";
import { right, left } from "@sweet-monads/either";
import { ErrorEntity } from "../../../domain/ErrorEntity";
import { AxiosType } from "../../../types/AxiosType";

export class ProductLoadAdapter implements ProductLoadPort {
  api(command: ProductLoadCommand): AxiosType {
    const responseJson = process.api.products.filter((product) => {
      return command.ids.includes(product.id);
    });
    return {
      data: responseJson as unknown,
      code: 200,
    };
  }

  load(command: ProductLoadCommand) {
    const response = this.api(command);
    const valid = ProductsResponseSchema.safeParse(response.data);
    return valid.success
      ? right(productsMapper(valid.data))
      : left(new ErrorEntity("productLoad", valid.error));
  }
}

Метод api возвращает неизвестные для нас данные, поэтому  нужно провалидировать их по схеме:

ProductsResponseSchema

import { z } from "zod";

export const ProductsResponseSchema = z.array(
  z.object({
    id: z.string().max(2),
    title: z.string(),
    price: z.number().max(1000),
  })
);
export type ProductsResponseSchemaType = z.infer<typeof ProductsResponseSchema>;
 const valid = ProductsResponseSchema.safeParse(response.data);

если valid.success = true,  вызовем DTO (mapper) 

productMapper:

import { ProductEntity } from "../../domain/product/ProductEntity";
import { ProductListEntity } from "../../domain/product/ProductListEntity";
import { ProductsResponseSchemaType } from "../schema/ProductsSchema";

export function productsMapper(
  response: ProductsResponseSchemaType
): ProductListEntity {
  return new ProductListEntity(
    response.map(
      (product) => new ProductEntity(product.id, product.title, product.price)
    )
  );
}

Так как мы уже проверили, что данные из метода api соответствуют типу ProductsResponseSchemaType (valid.success = true),

в productMapper ошибок не будет.

В productMapper лишь одно изменение, поле title записываем в name.

Первичный адаптер готов!

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

import { ProductListEntity } from "./ProductListEntity";
import { Either } from "@sweet-monads/either";
import { ErrorEntity } from "../ErrorEntity";
import { ProductId } from "./ProductEntity";

export interface ProductLoadQuery {
  load(ids: ProductId[]): Either<ErrorEntity, ProductListEntity>;
}

Теперь на основе порта реализуем вторичный адаптер (сервис):

import { ProductLoadQuery } from "../../../domain/product/ProductLoadQuery";
import { ProductLoadCommand } from "../../../domain/product/ProductLoadCommand";
import { ProductLoadAdapter } from "../../adapters/product/ProductLoad";
import { ProductId } from "../../../domain/product/ProductEntity";

export class ProductLoadService implements ProductLoadQuery {
  productLoadPort = new ProductLoadAdapter();

  localization() {
    // mock browser api
    const navigator = {
      language: "en-EN",
    };
    const userLang = navigator.language;
    switch (userLang) {
      case "ru-RU":
        return "ru";
      case "en-EN":
        return "en";
      default:
        return "ru";
    }
  }

  load(ids: ProductId[]) {
    const command = new ProductLoadCommand(ids, this.localization());
    return this.productLoadPort.load(command);
  }
}

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

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

Напротив, можно использовать конструктор и передавать первичный адаптер аргументом при инициализации класса вторичного адаптера (service).Это делает класс чистым. В таком случае в контроллерах приложения  придется прокидывать первичный адаптер во вторичный. Каждый волен выбирать удобную для него схему.


Заключение

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

Комментарии (9)


  1. JordanCpp
    07.03.2022 23:15
    +3

    Честно очень интересно, не чёт замороченно.) Идеи здравые, борьба со сложностью, со связанностю кода. Но когнитивная нагрузка от Овер абстракций над абстракциями, абстрагирующие нижележащие абстракции, возрастает.


    1. jtape Автор
      08.03.2022 02:41

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


      1. derwin
        08.03.2022 07:37
        +4

        я тоже был адептом ддд, пока не попал на проект с ддд.

        Вместо 4х if-ок мне нужно было создать 4 dto, 4 enum, 3 handler-а и всё это упаковать в фабрику. Пока читаешь (чужой) код - хочется плакать кровью.


  1. dopusteam
    08.03.2022 19:45

    Первичные  порты — это запросы поступающие в приложение http, api, подключение к бд.

    А как подключение к БД является входящим для приложения? Выглядит как вторичный порт (согласно статье, они используются ядром приложения для доступа к внешним службам)

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

    адаптеры, которые устанавливают связь с внешним миром (api), реализуют первичный порт

    Как это соотносится?

    Вторичные порты используются ядром приложения для доступа к внешним службам

    К примеру, адаптеры, взаимодействующие с веб-браузером реализуют вторичный порт

    Получается, что наше приложение стучится в браузер?

    INFRASTRUCTURE - это бизнес-логика

    Вот эта штука меня смущает немного. Почему инфраструктура - это бизнес логика?


    1. jtape Автор
      09.03.2022 07:21

      Как это соотносится?

      "Вторичные порты используются ядром приложения для доступа к внешним службам." - тут имеется в виду (внешние службы) тоесть - браузером апи, пользовательские действия (изменение состояния приложения), возможно стоит перефразировать, изменить формулировку для большей понятности?

      может так:

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

      А как подключение к БД является входящим для приложения?

      в данном случае это получение данных из мне, поэтому первичный порт

      Получается, что наше приложение стучится в браузер?

      вызывает внешние службы, браузерное апи


      1. jtape Автор
        09.03.2022 08:21

        Вот эта штука меня смущает немного. Почему инфраструктура - это бизнес логика?

        Наверное стоит исправить, согласен думаю - "Внешние системы"


  1. Bone
    09.03.2022 09:48

    ProductList врядли является Entity. Судя по тому, что у него нет id, это ValueObject.


    1. jtape Автор
      09.03.2022 13:28

      разве по определению entity должен иметь id? ProductList может иметь собственные методы, гетеры и сеттеры, любые другие поля, помимо массива продуктов.


      1. Bone
        09.03.2022 14:18
        +1

        Вообще, да. Можно сказать, что entity - это уникальная сущность с id, а value object - это нечто общее. При этом они могут иметь какие угодно методы и поля. Классический пример банковский счет, допустим такого вида

        interface BankAccount {
          id: number
          money: Money
        }

        Это entity. Они сравниваются по id. Если разные id, то это разные entity даже, если у них совпадают все другие поля.

        interface Money {
          amount: number
          currency: "RUB"|"USD"
        }

        Money - это value object. У него нет своей собственной идентичности. 500 долларов равны любым другим 500 долларам. Но банковский счет с 500 долларами не равен любому другому банковскому счету с 500 долларами.