[Программирование, Анализ и проектирование систем, Проектирование и рефакторинг, Управление разработкой, TypeScript] Чем меня не устраивает гексагональная архитектура. Моя имплементация DDD – многоуровневая блочная архитектура
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
* В данной статье примеры будут на TypeScript
Краткое предисловие
Что такое DDD (Domain Driven Design) вопрос обширный, но если в кратце (как Я это понимаю) — это про перенос бизнес логики, как она есть, в код, без углубления в технические детали. То есть в идеале, человек, который знает за бизнес процессы, может открыть код и понять, что там происходит (так кстати часто бывает в 1С).
Всё это сопровождается кучей разных рекомендаций по технической реализации вопроса.
Для лучшего понимания статьи советую прочитать материалы, касающиеся DDD.
Гексагональная архитектура — это один из подходов реализации DDD.
Многие продвинутые разработчики знакомы с понятием гексагональной архитектуры.
Конкретнее описывать не буду, на хабре полно статей на эту тему, всё давно разжевано и практически переварено.
Вместо этого Я покажу картинку (рис.1):
рис.1
Скажите пожалуйста, что Вам понятно из этой картинки?
Например мне, когда Я первый раз увидел её, было непонятно абсолютно всё.
И, как бы ни было смешно, это первая проблема для меня.
Визуализация должна давать понимание, а не добавлять вопросов.
В ходе изучения всё частично становится на свои места, но вопросы и проблемы остаются.
И тут Я задумался о приложении в общем, развивая идеи вынесенные из DDD в целом и гексагональной архитектуры в частности.
Что мы имеем:
- Реальная жизнь. Здесь есть бизнес процессы, которые мы должны автоматизировать.
- Приложение, которое решает проблемы из реальной жизни, которое в свою очередь, не находится в вакууме. У приложения есть:
- Пользователи, будь то АПИ, кроны, пользовательские интерфейсы и т.д.
- Сам код приложения.
- Объекты данных — БД, другие АПИ.
Движение идёт сначала сверху вниз, потом обратно, то есть:
- Субъекты из реальной жизни взаимодействуют с приложением, код приложения взаимодействует с объектами данных, затем получив от них ответ, возвращает его пользователям.
Всё логично.
Теперь углубимся в код приложения.
Как сделать так, чтобы код был понятным, тестируемым, но при этом максимально независимым от внешних объектов данных, таких как БД, АПИ и т.д.?
В ответ на этот вопрос родилась следующая схема (рис.2):
рис.2
То что мы здесь видим, очень похоже на гексагональную архитектуру, но в отличии от неё, логика не замкнута в гексагон или круг, как в луковой архитектуре, а просто разнесена по уровням, сохраняя логичную цепочку взаимодействий описанных выше — запрос приходит сверху, спускается вниз, затем поднимается обратно, возвращая результат.
Ещё одна разница состоит в том, что добавлен уровень бизнес процессов, о котором поговорим ниже.
Многоуровневая блочная архитектура
Пробежимся по схеме.
На рисунке (рис.2), слева, мы видим названия сущностей, справа — назначение уровней и их зависимости друг от друга.
Сверху вниз:
- Порты— уровень взаимодействия, который зависит от уровня бизнес процессов. Уровень отвечает за взаимодействие с приложением, то есть хранит контроллеры. Пользоваться приложением можно только через порты.
- Ядро приложения — уровень бизнес процессов, является центром всех зависимостей. Всё приложение строится исходя из бизнес процессов.
- Домены — уровень бизнес логики, который зависит от уровня бизнес процессов. Домены образуются и выстраиваются на основании тех бизнес процессов, которые мы хотим автоматизировать. Домены отвечают за конкретную бизнес логику.
- Адаптеры — уровень агрегации данных, который зависит от уровня бизнес логики. Сверху получает интерфейсы данных, которые должен реализовать. Отвечает за получение и нормализацию данных из объектов данных.
- Объекты данных — уровень хранения данных, который не входит в приложение, но т. к. приложение не существует в вакууме, мы должны учитывать их.
Несколько правил
По ходу практики родилось и несколько правил, которые позволяют сохранять чистоту, простоту и универсальность кода:
- Бизнес процессы должны возвращать однозначный ответ.
Например создание клиента, при наличии партнерской программы. Можно сделать бизнес процесс, который создает клиента, а если у него есть партнерский код — добавляет его ещё и в партнеры, но это не правильно. Из за подобного подхода ваши бизнес процессы становятся непрозрачными и излишне сложными. Вы должны создать 2 бизнес процесса — создание клиента и создание партнера.
- Домены не должны общаться на прямую между собой. Всё общение между доменами происходит в бизнес процессах. Иначе домены становятся взаимозависимыми.
- Все доменные контроллеры не должны содержать бизнес логики, они лишь вызывают доменные методы.
- Доменные методы должны быть реализованы как чистые функции, у них не должно быть внешних зависимостей.
- У методов все входящие данные уже должны быть провалидированы, все необходимые параметры должны быть обязательными (тут помогут data-transfer-object-ы или просто DTO-шки).
- Для unit тестирования уровня нужен нижестоящий уровень. Инъекция (DI) производится только в нижестоящий уровень, например тестируете домены → подменяете адаптеры.
Как происходит разработка, согласно этой схеме
- Выделяются бизнес процессы, которые мы хотим автоматизировать, описываем уровень бизнес процессов.
- Бизнес процессы разбиваются на цепочки действий, которые связаны с конкретными областями (домены).
- Решаем как мы храним данные и с какими внешними сервисами взаимодействуем — подбираем адаптеры и источники данных, которые наши адаптеры поддерживают. Например в случае с БД мы решаем хранить наши данные в реляционной базе данных, ищем ORM, которая умеет с ними работать и при этом отвечает нашим требованиям, затем под неё выбираем БД, с которой наша ORM умеет работать. В случае с внешними API, часто придется писать свои адаптеры, но опять таки с оглядкой на домены, потому что у адаптера есть 2 главные задачи: получить данные и отдать их наверх в необходимом домену, адаптированном виде.
- Решаем как мы взаимодействуем с приложением, то есть продумываем порты.
Небольшой пример
Мы хотим сделать небольшую 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
}
В примере не буду показывать как реализовать порты, которые по сути являются простыми контроллерами, которые вызывают те или иные бизнес процессы. Тут уж Вы сами как нибудь.
Что нам даёт данная архитектура?
- Понятную и удобную структуру папок и файлов.
- Удобное тестирование. Т. к. всё приложение разбито на слои — выберете нужный слой, подменяете нижестоящий слой и тестируете.
- Удобное логирование. В примере видно, что логирование можно встроить на каждый этап работы приложения — от банального замера скорости выполнения конкретного доменного метода (просто обернуть функцию метода функцией оберткой, которая всё замерит), до полного логирования всего бизнес процесса, включая промежуточные результаты.
- Удобную валидацию данных. Каждый уровень может проверять критичные для себя данные. Например тот же бизнес процесс создания клиента по хорошему в начале должен создать DTO для модели клиента, который провалидирует входящие данные, затем он должен вызвать доменный метод, который проверит, существует ли уже такой клиент и только потом создаст клиента. Сразу скажу про права доступа — Я считаю что права доступа это адаптер, который Вы должны также прокидывать при создании доменного контроллера и внутри в контроллерах проверять права.
- Легкое изменение кода. Допустим Я хочу после создания клиента создавать оповещение, то есть хочу обновить бизнес процесс. Захожу в бизнес процесс, в начале добавляю инциализацию домена notifications и после получения результата создания клиента делаю notifications.notifyClient({ client: client.id, type:’SUCCESS_REGISTRATION’ })
На этом всё, надеюсь было интересно, спасибо за внимание!
===========
Источник:
habr.com
===========
Похожие новости:
- [Программирование, Бизнес-модели, Робототехника] 7 шагов к открытию своего детского технического центра / Сможет каждый
- [Python, Программирование, Визуализация данных, Машинное обучение] CatBoost и ML-конкурсы
- [Python, Программирование, C, Учебный процесс в IT] Не начинайте учиться кодингу с Python, начните с языка C (перевод)
- [Open source, Программирование, Системное программирование, Компиляторы, Rust] Планирование редакции Rust 2021 (перевод)
- [Python, Программирование, Отладка] Многоразовый шаблон логирования на Python для всех ваших приложений в Data Science (перевод)
- [Open source, Программирование, Геоинформационные сервисы, Визуализация данных, Научно-популярное] Цифровая геология, или пусть машины думают и находят золото для нас в Западной Сибири без геологических данных
- [Python, Программирование, Математика, Визуализация данных] С помощью Python создаём математические анимации, как на канале 3Blue1Brown (перевод)
- [Анализ и проектирование систем, Графические оболочки, Работа с 3D-графикой, CAD/CAM] Термический анализ в SOLIDWORKS Simulation на примере микрочипа
- [Python, Программирование] Преобразуем проект на Python в исполняемый файл .EXE (перевод)
- [Open source, Программирование, IT-инфраструктура, Открытые данные] Как свободное программное обеспечение может ускорить цифровизацию
Теги для поиска: #_programmirovanie (Программирование), #_analiz_i_proektirovanie_sistem (Анализ и проектирование систем), #_proektirovanie_i_refaktoring (Проектирование и рефакторинг), #_upravlenie_razrabotkoj (Управление разработкой), #_typescript, #_ddd, #_domain_driven_design, #_proektirovanie_sistem (проектирование систем), #_domennaja_model (доменная модель), #_arhitektura_prilozhenij (архитектура приложений), #_programmirovanie (
Программирование
), #_analiz_i_proektirovanie_sistem (
Анализ и проектирование систем
), #_proektirovanie_i_refaktoring (
Проектирование и рефакторинг
), #_upravlenie_razrabotkoj (
Управление разработкой
), #_typescript
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 14:10
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
* В данной статье примеры будут на TypeScript Краткое предисловие Что такое DDD (Domain Driven Design) вопрос обширный, но если в кратце (как Я это понимаю) — это про перенос бизнес логики, как она есть, в код, без углубления в технические детали. То есть в идеале, человек, который знает за бизнес процессы, может открыть код и понять, что там происходит (так кстати часто бывает в 1С). Всё это сопровождается кучей разных рекомендаций по технической реализации вопроса. Для лучшего понимания статьи советую прочитать материалы, касающиеся DDD. Гексагональная архитектура — это один из подходов реализации DDD. Многие продвинутые разработчики знакомы с понятием гексагональной архитектуры. Конкретнее описывать не буду, на хабре полно статей на эту тему, всё давно разжевано и практически переварено. Вместо этого Я покажу картинку (рис.1): рис.1 Скажите пожалуйста, что Вам понятно из этой картинки? Например мне, когда Я первый раз увидел её, было непонятно абсолютно всё. И, как бы ни было смешно, это первая проблема для меня. Визуализация должна давать понимание, а не добавлять вопросов. В ходе изучения всё частично становится на свои места, но вопросы и проблемы остаются. И тут Я задумался о приложении в общем, развивая идеи вынесенные из DDD в целом и гексагональной архитектуры в частности. Что мы имеем:
Движение идёт сначала сверху вниз, потом обратно, то есть:
Всё логично. Теперь углубимся в код приложения. Как сделать так, чтобы код был понятным, тестируемым, но при этом максимально независимым от внешних объектов данных, таких как БД, АПИ и т.д.? В ответ на этот вопрос родилась следующая схема (рис.2): рис.2 То что мы здесь видим, очень похоже на гексагональную архитектуру, но в отличии от неё, логика не замкнута в гексагон или круг, как в луковой архитектуре, а просто разнесена по уровням, сохраняя логичную цепочку взаимодействий описанных выше — запрос приходит сверху, спускается вниз, затем поднимается обратно, возвращая результат. Ещё одна разница состоит в том, что добавлен уровень бизнес процессов, о котором поговорим ниже. Многоуровневая блочная архитектура Пробежимся по схеме. На рисунке (рис.2), слева, мы видим названия сущностей, справа — назначение уровней и их зависимости друг от друга. Сверху вниз:
Несколько правил По ходу практики родилось и несколько правил, которые позволяют сохранять чистоту, простоту и универсальность кода:
Как происходит разработка, согласно этой схеме
Небольшой пример Мы хотим сделать небольшую 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 } В примере не буду показывать как реализовать порты, которые по сути являются простыми контроллерами, которые вызывают те или иные бизнес процессы. Тут уж Вы сами как нибудь. Что нам даёт данная архитектура?
На этом всё, надеюсь было интересно, спасибо за внимание! =========== Источник: habr.com =========== Похожие новости:
Программирование ), #_analiz_i_proektirovanie_sistem ( Анализ и проектирование систем ), #_proektirovanie_i_refaktoring ( Проектирование и рефакторинг ), #_upravlenie_razrabotkoj ( Управление разработкой ), #_typescript |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 14:10
Часовой пояс: UTC + 5