Не стоит воспринимать статью за единственно верный подход. Вариаций много, это все лишь видение автора на тематику вопроса.
Погружение
Domain Driven Design - это набор принципов и схем, направленных на создание оптимальных систем объектов. Он сводится к созданию программных абстракций, которые называются моделями предметных областей. В эти модели входит бизнес-логика, устанавливающая связь между реальными условиями области применения продукта и кодом.
Таким образом, это декларации, абстракции, схемы, интерфейсы нашего будущего приложения.
Например, используя typescript в домене, можно создать интерфейсы и сущности, описать используемые параметры. Далее все реализуется на уровне application в приложении. В домен ничего не должно проникать извне - он является паттерном чистой архитектуры и соответствует принципу разделения ответственности.
Гексагональная архитектура является результатом работы Алистера Кокберна. Это архитектурный шаблон, используемый для разработки программных приложений.
Основа данной архитектуры - порты и адаптеры.
Порты - это интерфейсы нашего приложения,
Адаптеры - реализация наших портов.
Гексагон - фигура, имеющая 6 сторон, шестиугольник. В нашем случае слоистая или многогранная архитектура.
Преимущества данного метода:
Независимость: возможность не зацикливаться на бизнес логике.
Можно задекларировать, описать схему работы нашего приложения до создания внешних сервисов, использовать замоканные данные в реализации адаптеров.Гибкость: использование любых фреймворков, перенос доменов адаптеров в другие проекты, добавление новых адаптеров без изменения исходного кода.
Легкая изменчивость: изменения в одной области нашего приложения не влияют на другие области.
Минусы
Погружение: многим разработчикам может быть сложно освоиться, особенно, при невысоком уровне знаний. Долгое время я сам отторгал данный подход, ссылаясь на его избыточность, пока не освоил систему на практике в течение нескольких месяцев.
Также могут возникнуть сложности реализации с 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 { 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).Это делает класс чистым. В таком случае в контроллерах приложения придется прокидывать первичный адаптер во вторичный. Каждый волен выбирать удобную для него схему.
Заключение
Данные технологии помогут сделать код более чистым и независимым от фреймворков и внешнего мира. Не стоит забывать, что необходима серьезная практическая подготовка для использования данной технологии в полной мере.