Як імплементувати Clean Architecture та Domain Driven Design на базі Nest.js
If you think good architecture is expensive, try bad architecture. — Brian Foote and Joseph Yoder
Всім привіт. Мене звати Сергій Галич, я Senior Software Engineer у компанії Yalantis. За моїми плечима шість років, присвячених розробці вебзастосунків та клієнт-серверних рішень.
Під час розробки програмного забезпечення розробник часто зіштовхується з викликом збереження стійкості до змін бізнес-вимог, адже саме вони є причиною існування інженерного рішення. Окрім самої бізнес-логіки також можуть змінюватись вимоги до технічних деталей, що теж, своєю чергою, вносить нові челенджі у швидку адаптацію ПЗ до нових вимог.
Звісно, що більше відокремлені різні аспекти програмного забезпечення один від одного, то легше робити ці зміни. Хоча, з іншого боку, має бути баланс в рівні відокремлення, інакше можна витратити занадто багато часу на архітектуру «заради архітектури» і почати over-engineering на кожному кроці.
Знову ж таки, баланс безпосередньо залежить від бізнес-вимог та цілей, проте є доволі універсальні архітектурні підходи, які можуть допомагати дотримуватись цього самого балансу.
У цій статті розповім, як Clean Architecture та DDD сприяють кращій організації коду шляхом розділення його на шари, кожен з яких відповідає за певні аспекти застосунку. Ці підходи не лише сприяють покращенню підтримки й розширення коду, але й допомагають розробникам зосередитися на вирішенні бізнес-задач, виокремлюючи бізнес-логіку від технічних деталей.
Для наочності, пропоную подивитись на один з багатьох можливих варіантів використання цих архітектурних підходів на прикладі проєкту онлайн-бронювання квитків до кінотеатру.
Ми створимо Node.js-проєкт із використанням фреймворку NestJS, який зарекомендував себе як ефективний інструмент для розробки сучасних вебзастосунків та мікросервісів. NestJS базується на TypeScript і пропонує рішення, які сприяють покращенню організації коду та швидкості розробки.
Основи Nest.js
Nest.js — прогресивний фреймворк Node.js для створення ефективних, надійних та масштабованих серверних застосунків. Ключові особливості фреймворку містять:
- Модульність: легка організація коду за допомогою модулів для зручного управління та підтримки.
- Зручні HTTP-маршрути: визначення контролерів для обробки HTTP-запитів для спрощеного створення API вебзастосунку.
- Dependency Injection: вбудований механізм інʼєкції залежностей із різними циклами життя.
- Підтримка TypeScript: фреймворк повністю базується на TypeScript та використовує декларативний підхід для конфігурування модулів за допомогою декораторів.
- Middleware, Guard, Interceptor, Pipe, та Filter: різні види мідлварів для обробки та керування HTTP-запитами для зручної та прозорої роботи.
Основи Clean Architecture
Шлях, яким ви зберігаєте програмне забезпечення гнучким, полягає в тому, щоб залишити якомога більше варіантів відкритими — настільки, наскільки це можливо. Які варіанти нам потрібно залишити відкритими? Це деталі, які не мають значення.
Політика та деталі визначають два основні елементи програмної системи. Політика містить бізнес-правила та процедури, які представляють справжню цінність системи.
Деталі, з іншого боку, містять усе, що необхідно для роботи застосунку, але не впливає на поведінку політики (пристрої вводу-виводу, бази даних, вебсистеми, сервери, фреймворки, протоколи зв’язку та інше).
Обирати систему баз даних на ранніх етапах розробки не обов’язково, оскільки високорівнева політика не цікавиться конкретним типом бази даних. Також неважливо, як буде здійснюватися комунікація із зовнішнім світом — через REST, GraphQL або івенти.
Програмне забезпечення на високому рівні містить специфічні для застосунку бізнес-правила та інкапсулює всі випадки використання системи. Зміни в цьому рівні не впливатимуть на сутності.
Ми також не очікуємо, що цей рівень буде змінений зовнішніми факторами, такими як база даних або користувацький інтерфейс. Цей рівень ізольований від деталей.
Основи DDD
Domain-Driven Design — це підхід до проєктування програмного забезпечення, який ставить акцент на бізнес-проблему та ефективну структуризацію логіки для її вирішення, відкидаючи незначні деталі, такі як технології інфраструктури.
Цей підхід спочатку був описаний Еріком Евансом у його книзі Domain-Driven Design: Tackling Complexity in the Heart of Software, але пізніше більш завершену та трохи перероблену концепцію описав Вон Вернон у «червоній» книзі Implementing Domain-Driven Design.
Базові концепції DDD містять:
Entities (об’єкти):
- Мають унікальну ідентичність та інкапсульовані критичні бізнес-правила.
- Атрибути можуть змінюватися, але ідентичність залишається.
- Ідентичність зберігається за допомогою унікального ключа або комбінації атрибутів.
Value Objects (об’єкти значення):
- Відображають конкретні характеристики або властивості даних і не мають унікальної ідентичності.
- Ідентифікація ґрунтується на значеннях його властивостей, а не на унікальному ідентифікаторі.
- Зазвичай описують атрибути сутностей та їх дані незмінні.
Repository (репозиторій):
- Забезпечує можливість отримання об’єктів з різних джерел (база даних, оперативна пам’ять тощо).
- Приховує деталі реалізації, надаючи інтерфейс, незалежний від конкретності інфраструктури.
- Працює з Entities, повертаючи та приймаючи їх як результати та аргументи.
Hands-on
Контекст
Розберемо використання чистої архітектури та DDD на прикладі системи керування сеансами фільмів для кінотеатру з використанням фреймворку NestJS. Бізнес-логіка охоплює такі сутності:
Кінозали: кожен кінозал має унікальний ідентифікатор та номер, інформацію про кількість місць.
Сеанси фільмів: сеанс фільму визначається унікальним ідентифікатором, а також початковим і кінцевим часом показу. Для кожного сеансу вказується фільм, який відтворюється, а також кінозал, в якому він проводиться.
Квитки: кожен квиток має унікальний ідентифікатор та номер місця, а також пов’язаний з конкретним сеансом фільму, покупцем і має ціну.
Система також має набір use cases які відповідають за реалізацію різноманітних дій, пов’язаних з бізнес-логікою кінотеатру. Ось декілька можливих дій, які можна виконувати в сервісі:
Скасування сеансу фільму: у разі непередбачених обставин або низького попиту на цей сеанс.
Резервація квитків: охоплює вибір місця, кількості квитків та інших параметрів.
Скасування резервації квитків: у разі зміни планів або інших обставин.
Отримання деталей сеансу фільму: можливість отримати детальну інформацію про певний сеанс фільму, зокрема дату, час, кількість вільних місць тощо.
Отримання списку сеансів фільмів: список усіх доступних сеансів фільмів у кінотеатрі за певний період часу або за певними критеріями.
Ці дії становлять лише частину можливостей, які може надавати реальний сервіс для управління бізнес-процесами кінотеатру, проте ми можемо розглянути кілька бізнес-кейсів.
High-level
Сервіс, який ми будемо розглядати, відповідальний за роботу із сесіями фільмів та квитками.
Сам сервіс викликається клієнтом через REST API та взаємодіє з іншими сервісами через брокера повідомлень, у цьому разі за допомогою RabbitMQ.
Також слід зазначити Movie Library Service, який є постачальником інформації про фільми. Дуже часто в реальному житті виникає ситуація, коли потрібно використовувати API іншого сервісу, який не відповідає підходам, що використовуються в розроблюваних нами сервісах. Іноді необхідно призначити API-інтерфейс для інтеграції, і це абсолютно нормально.
Payment Service відповідає за транзакції та інтегрується з платіжною системою. Analytics Service збирає всю необхідну аналітику, слухаючи івенти.
Структура
Проєкт має розподілення на директорії:
Директорії розділяються на три шари:
- Domain — вся критична бізнес-логіка (Domain Services), доменні сутності (Domain Entities, Value Objects) та доменні події (Domain Events)
- Application — use case (Command/Query) класи, обробники подій (Event Handlers), та абстракції над деталями (Boundaries).
- Infrastructure — всі деталі (конкретні імплементації), що належати до роботи із базою даних, REST, інтеграції з іншими сервісами та конфігурації самого сервісу.
Domain Layer
Почнемо з ядра нашого сервісу, а саме зі шару Domain, який відповідає за всю критичну бізнес-логіку нашого сервісу
Domain Entities
Ми маємо три сутності в поточному прикладі.
CinemaHall:
import { Seat } from '../../domain/valueObjects/Seat'; export class CinemaHall { private readonly _cinemaHallId: string; private readonly _cinemaHallNumber: number; private readonly _seats: Seat[]; constructor(cinemaHallId: string, cinemaHallNumber: number, seats: Seat[]) { this._cinemaHallId = cinemaHallId; this._cinemaHallNumber = cinemaHallNumber; this._seats = seats; } public get cinemaHallId(): string { return this._cinemaHallId; } public get cinemaHallNumber(): number { return this._cinemaHallNumber; } public get seats(): Seat[] { return this._seats; } }
Seat в цьому разі є Value Object, який має такий вигляд:
export class Seat { private readonly _seatNumber: number; constructor(seatNumber: number) { this._seatNumber = seatNumber; } public get seatNumber(): number { return this._seatNumber } }
MovieSession:
import { IEventDispatcher } from '../../../common/application/IEventDispatcher'; import { MovieSessionStatus } from '../enums/MovieSessionStatus'; import { MovieSessionCanceledEvent } from '../events/MovieSessionCanceledEvent'; export class MovieSession { private readonly _movieSessionId: string; private readonly _movieId: string; private readonly _cinemaHallId: string; private readonly _startTime: Date; private readonly _endTime: Date; private _status: MovieSessionStatus; constructor( movieSessionId: string, movieId: string, cinemaHallId: string, startTime: Date, endTime: Date, status: MovieSessionStatus, ) { this._movieSessionId = movieSessionId; this._movieId = movieId; this._cinemaHallId = cinemaHallId; this._startTime = startTime; this._endTime = endTime; this._status = status; } public get movieSessionId(): string { return this._movieSessionId; } public get movieId(): string { return this._movieId; } public get cinemaHallId(): string { return this._cinemaHallId; } public get startTime(): Date { return this._startTime; } public get endTime(): Date { return this._endTime; } public get status(): MovieSessionStatus { return this._status; } public cancel(eventDispatcher: IEventDispatcher): void { if (this._status === MovieSessionStatus.CANCELED) { throw new Error('Movie session already canceled'); } if (this._status === MovieSessionStatus.CLOSED) { throw new Error('Movie session already closed'); } this._status = MovieSessionStatus.CANCELED; eventDispatcher.registerEvent( new MovieSessionCanceledEvent({ movieSessionId: this._movieSessionId, }), ); } }
MovieSession має метод cancel
, який скасовує сеанс фільму та змінює внутрішній стан сутності. Окрім цього, цей метод також валідує поточний статус.
Ця валідація є виключно бізнесовою та однаковою для всіх випадків, коли ми захочемо «викликати» цей метод. Тому не важливо, в якому місці в коді викликається метод cancel
, — результат буде один і той самий. Саме це і називається критичною бізнес-логікою.
Також можна помітити, що цей метод реєструє подію MovieSessionCanceledEvent
. Ця подія є Domain Event, що відображає зміну стану сутності у системі.
Ticket:
import { IEventDispatcher } from '../../../common/domain/IEventDispatcher'; import { TicketReservationCanceledEvent } from '../events/TicketReservationCanceledEvent'; import { TicketReservedEvent } from '../events/TicketReservedEvent'; export class Ticket { private readonly _ticketId: string; private readonly _movieSessionId: string; private readonly _seatNumber: number; private readonly _price: number; private _visitorId: string | null; constructor( ticketId: string, movieSessionId: string, seatNumber: number, price: number, visitorId: string | null, ) { this._ticketId = ticketId; this._movieSessionId = movieSessionId; this._seatNumber = seatNumber; this._price = price; this._visitorId = visitorId; } public get ticketId(): string { return this._ticketId; } public get movieSessionId(): string { return this._movieSessionId; } public get visitorId(): string | null { return this._visitorId; } public get seatNumber(): number { return this._seatNumber; } public get price(): number { return this._price; } reserve(visitorId: string, eventDispatcher: IEventDispatcher): void { if (this._visitorId !== null) { throw new Error('Ticket already reserved!'); } this._visitorId = visitorId; eventDispatcher.registerEvent( new TicketReservedEvent({ ticketId: this._ticketId, visitorId: this._visitorId, }), ); } cancelReservation(eventDispatcher: IEventDispatcher): void { if (this._visitorId === null) { throw new Error('Ticket is not reserved!'); } eventDispatcher.registerEvent( new TicketReservationCanceledEvent({ ticketId: this._ticketId, visitorId: this._visitorId, }), ); this._visitorId = null; } }
Ticket, своєю чергою, має два методи — reserve
та cancelReservation
. Вони так само валідують внутрішній стейт сутності та змінюють його. Кожен з цих методів також реєструє Domain Event для відображення зміни внутрішнього стану сутності.
Domain Events
В системі є три види подій, які ми вже бачили трохи вище:
export enum EventType { MOVIE_SESSION_CANCELED = 'MOVIE_SESSION_CANCELED', TICKET_RESERVATION_CANCELED = 'TICKET_RESERVATION_CANCELED', TICKET_RESERVED = 'TICKET_RESERVED', }
Кожен із цих типів івентів декларується як окремий клас, інстанси якого реєструються в EventDispatcher
, щоб потім, після успішно завершеної транзакції, вони потрапили в систему. Важливо розділяти доменні та інтеграційні події.
Доменні івенти мають дві головні цілі:
- Інтеграція між модулями: доменні івенти дозволяють модулям системи взаємодіяти один з одним, навіть якщо вони перебувають у різних контекстах. Вони створюють зв’язок між доменними сферами та дозволяють їм спілкуватися без прямих залежностей.
- Реакція на події: доменні івенти дозволяють іншим частинам системи реагувати на певні події або зміни стану, що відбуваються в домені. Це дає можливість запускати різні операції або обробники під час виникнення певних умов або подій.
Інтеграційні івенти, своєю чергою, — це спосіб підтримувати консистентність між різними сервісами. Для них можна виділити декілька цілей:
- Реакція на зміни в інших сервісах: інтеграційні івенти дозволяють одному сервісу реагувати на зміни, що відбуваються в інших сервісах. Наприклад, сервіс може реагувати на створення, оновлення або видалення об’єктів в інших сервісах і відповідно змінювати свій власний стан або взаємодіяти з іншими сервісами.
- Асинхронна комунікація: інтеграційні івенти дозволяють сервісам комунікувати асинхронно, завдяки чому можна підвищити продуктивність та масштабованість системи. Вони дозволяють відправляти повідомлення та реагувати на них без блокування виконання інших операцій.
З мого досвіду скажу, що доменні івенти можуть бути так само й інтеграційними, але не навпаки. Це повʼязано з тим, що кожен контекст має свій унікальну бізнес-семантику, що може «не розуміти» семантику (або доменну логіку) іншого контексту.
Самі івенти мають такий вигляд:
interface MovieSessionCanceledEventPayload { movieSessionId: string; } export class MovieSessionCanceledEvent extends Event<MovieSessionCanceledEventPayload> { eventType = EventType.MOVIE_SESSION_CANCELED; }
interface TicketReservationCanceledEventPayload { ticketId: string; visitorId: string; } export class TicketReservationCanceledEvent extends Event<TicketReservationCanceledEventPayload> { eventType = EventType.TICKET_RESERVATION_CANCELED; }
interface TicketReservedEventPayload { ticketId: string; visitorId: string; } export class TicketReservedEvent extends Event<TicketReservedEventPayload> { eventType = EventType.TICKET_RESERVED; }
Івенти реєструються та обробляються за допомогою EventDispatcher
, проте, на мою думку, краще не мати залежності на конкретну імплементацію диспатчера, але мати контракт для нього на рівні Domain Layer:
export interface IEventDispatcher { registerEvent(event: Event<any>): void; dispatchEvents(): void; }
Application Layer
На цьому рівні ми маємо комбінацію з декількох архітектурних підходів одразу.
DDD та Clean Architecture відповідають за різні аспекти, тому не можна (і не правильно) казати, що щось краще. DDD ставить в пріоритет бізнес-правила та робить їх основою мислення під час дизайну системи та підсистем.
Clean Architecture, своєю чергою, допомагає відокремити деталі від політики, що допомагає краще сфокусуватись на важливих речах та мати своє особисте місця для кожної частини застосунку. Зрештою маємо непогану реалізацію Single Responsibility Principle, що допомагає розділити код з різною вірогідністю появ змін.
Основою Clean Architecture є use case — транзакційна одиниця функціональності, що відображає бізнесову дію, яку можна виконати в застосунку. Use case інкапсулює логіку використання репозиторіїв, сервісів, доменних сутностей та всього, що необхідно для виконання бізнес-дії.
Одна з речей, яка мені подобається, — це самодокументованість, що допомагає зменшити когнітивне навантаження під час створення, читання або редагування use case. У прикладі я розділяю use case на дві категорії — Command та Query.
Це розділення називається CQS (Command-Query Separation). Command — це use case, що змінює стан в системі (Write), Query — тільки читаючи операції (Read).
Приклади базових класів для Command та Query:
import { Inject } from '@nestjs/common'; import type { IDbContext } from './IDbContext'; import { BaseToken } from '../diTokens'; import { IEventDispatcher } from '../domain/IEventDispatcher'; export abstract class Command<TInput = void, TOutput = void> { protected _input: TInput; @Inject(BaseToken.EVENT_DISPATCHER) protected _eventDispatcher: IEventDispatcher; @Inject(BaseToken.DB_CONTEXT) protected _dbContext: IDbContext; async execute(input: TInput): Promise<TOutput> { this._input = input; await this._dbContext.startTransaction(); let result: TOutput; try { result = await this.implementation(); await this._dbContext.commitTransaction(); this._eventDispatcher.dispatchEvents(); } catch (error) { await this._dbContext.rollbackTransaction(); throw error; } return result; } protected abstract implementation(): Promise<TOutput> | TOutput; }
import { Inject } from '@nestjs/common'; import { IDbContext } from './IDbContext'; import { BaseToken } from '../diTokens'; export abstract class Query<TInput, TOutput> { protected _input: TInput; @Inject(BaseToken.DB_CONTEXT) protected _dbContext: IDbContext; async execute(input: TInput): Promise<TOutput> { this._input = input; const result: TOutput = await this.implementation(); return result; } protected abstract implementation(): Promise<TOutput> | TOutput; }
Можна одразу помітити, що команда має більше логіки, а також транзакцію і роботу з івентами, чого немає в Query.
Розглянемо такий use case в системі:
import { Injectable, Scope } from '@nestjs/common'; import { CancelTicketReservationCommandInput, ICancelTicketReservationCommand, } from './ICancelTicketReservationCommand'; import { Command } from '../../../../common/application/Command'; @Injectable({ scope: Scope.REQUEST }) export class CancelTicketReservationCommand extends Command<CancelTicketReservationCommandInput> implements ICancelTicketReservationCommand { protected async implementation(): Promise<void> { const { ticketId } = this._input; const ticket = await this._dbContext.ticketRepository.findById(ticketId); if (!ticket) { throw new Error('Ticket does not exist'); } ticket.cancelReservation(this._eventDispatcher); await this._dbContext.ticketRepository.save(ticket); } }
Ця команда скасовує резервацію для конкретного квитка. Ми не перевіряємо поточний стан квитка, чи є зараз резервація, чи ні, бо ми вже імплементували цю валідацію в методі cancelReservation()
.
Більш детально на цикл життя поточної команди можемо подивитись на цій діаграмі:
Можна помітити, що команда викликається з контролера, але дані, з якими вона викликається, описані на рівні самої команди. Ми не передаємо request-обʼєкт, бо тоді ми змішаємо деталі HTTP-комунікації в бізнес-логіку.
Також бачимо, що команда перш ніж щось робити, відкриває транзакцію в DbContext, у рамках якої потім відбувається робота з БД. Транзакція комітиться або скасовується залежно від успішності виконання команди.
Фінальний крок за успішного виконання команди — диспатчинг івентів. Цей процес викидує в систему всі івенти, що були зареєстровані під час виконання команди після успішного коміту транзакції бази даних. Після всього команда повертає результат її виконання, що може містити будь-яку інформацію необхідну для користувача.
Також подивимось на іншу команду:
import { Injectable, Scope } from '@nestjs/common'; import { CancelMovieSessionCommandInput, ICancelMovieSessionCommand, } from './ICancelMovieSessionCommand'; import { Command } from '../../../../common/application/Command'; @Injectable({ scope: Scope.REQUEST }) export class CancelMovieSessionCommand extends Command<CancelMovieSessionCommandInput> implements ICancelMovieSessionCommand { protected async implementation(): Promise<void> { const { id } = this._input; const movieSession = await this._dbContext.movieSessionRepository.findById(id); if (!movieSession) { throw new Error('Movie session does not exist'); } movieSession.cancel(this._eventDispatcher); await this._dbContext.movieSessionRepository.save(movieSession); } }
Теж доволі проста в розумінні бізнес-логіка, яка плюс-мінус повторює основну ідею роботи use case з попередньої команди.
Тепер можемо розглянути Query:
import { Inject, Injectable, Scope } from '@nestjs/common'; import * as _ from 'lodash'; import { GetMovieSessionQueryInput, GetMovieSessionQueryOutput, IGetMovieSessionQuery, } from './IGetMovieSessionQuery'; import { Query } from '../../../../common/application/Query'; import { ServiceToken } from '../../../../common/diTokens'; import { IMovieProvider } from '../../services/IMovieProvider'; @Injectable({ scope: Scope.REQUEST }) export class GetMovieSessionQuery extends Query<GetMovieSessionQueryInput, GetMovieSessionQueryOutput> implements IGetMovieSessionQuery { constructor( @Inject(ServiceToken.MOVIE_PROVIDER) private readonly _movieProvider: IMovieProvider, ) { super(); } protected async implementation(): Promise<GetMovieSessionQueryOutput> { const { id } = this._input; const movieSession = await this._dbContext.movieSessionRepository.findById(id); if (!movieSession) { return { movieSession: null, }; } const movieInfo = await this._movieProvider.getMovieInfo( movieSession.movieId, ); if (!movieInfo) { // non-trivial case. Should be logged return { movieSession: null, }; } const cinemaHall = await this._dbContext.cinemaHallRepository.findById( movieSession.cinemaHallId, ); if (!cinemaHall) { // non-trivial case. Should be logged return { movieSession: null, }; } const tickets = await this._dbContext.ticketRepository.listByMovieSessionId(id); const ticketsHashMap = _.keyBy(tickets, (ticket) => ticket.seatNumber); return { movieSession: { id, movieName: movieInfo.name, thumbnailUrl: movieInfo.thumbnailUrl, endTime: movieSession.endTime, startTime: movieSession.startTime, cinemaHallNumber: cinemaHall.cinemaHallNumber, status: movieSession.status, seats: cinemaHall.seats.map((seat) => { const ticket = ticketsHashMap[seat.seatNumber]; return { ticketId: ticket?.ticketId ?? null, seatNumber: seat.seatNumber, price: ticket?.price ?? null, isAvailable: ticket !== undefined ? ticket.visitorId === null : false, }; }), }, }; } }
import { Inject, Injectable, Scope } from '@nestjs/common'; import * as _ from 'lodash'; import { ListMovieSessionsQueryInput, ListMovieSessionsQueryOutput, IListMovieSessionsQuery, } from './IListMovieSessionsQuery'; import { Query } from '../../../../common/application/Query'; import { ServiceToken } from '../../../../common/diTokens'; import { IMovieProvider } from '../../services/IMovieProvider'; @Injectable({ scope: Scope.REQUEST }) export class ListMovieSessionsQuery extends Query<ListMovieSessionsQueryInput, ListMovieSessionsQueryOutput> implements IListMovieSessionsQuery { constructor( @Inject(ServiceToken.MOVIE_PROVIDER) private readonly _movieProvider: IMovieProvider, ) { super(); } protected async implementation(): Promise<ListMovieSessionsQueryOutput> { const { movieId } = this._input; const movieSessions = await this._dbContext.movieSessionRepository.list({ movieId, }); if (movieSessions.length === 0) { return { movieSessions: [], }; } const moviesInfo = await Promise.all( movieSessions.map(({ movieId }) => this._movieProvider.getMovieInfo(movieId), ), ); const moviesInfoHashMap = _(moviesInfo) .compact() .keyBy((movieInfo) => movieInfo.id) .value(); const tickets = await this._dbContext.ticketRepository.listByMovieSessionIds( movieSessions.map(({ movieSessionId }) => movieSessionId), ); return { movieSessions: movieSessions.map((movieSession) => { const movieInfo = moviesInfoHashMap[movieSession.movieId]; return { id: movieSession.movieSessionId, movieName: movieInfo!.name, thumbnailUrl: movieInfo!.thumbnailUrl, startTime: movieSession.startTime, endTime: movieSession.endTime, status: movieSession.status, availableSeats: tickets.filter( ({ visitorId }) => visitorId === null, ).length, totalSeats: tickets.length, }; }), }; } }
Структура Query схожа на Command, але під капотом ми не маємо EventDispatcher
та транзакції.
Також можна подивитись, що IMovieProvider — це абстракція над реальною імплементацією. Це і є розділення на бізнес політику та деталі: нам неважливо звідки та як провайдер буде брати інформацію про фільм. Нам важливо, щоб він вмів робити це:
export interface IMovieProvider { getMovieInfo(movieId: string): Promise<MovieInfo | null>; }
Те ж саме стосується і репозиторіїв, і DbContext. Ми не залежимо на рівні Application від конкретної реалізації:
import { CinemaHall } from '../../domain/entities/CinemaHall'; export interface ICinemaHallRepository { findById(id: string): Promise<CinemaHall | null>; }
import { MovieSession } from '../../domain/entities/MovieSession'; export interface IMovieSessionRepository { findById(id: string): Promise<MovieSession | null>; list(params: { movieId: string | null }): Promise<MovieSession[]>; save(data: MovieSession): Promise<MovieSession>; }
import { Ticket } from '../../domain/entities/Ticket'; export interface ITicketRepository { findById(id: string): Promise<Ticket | null>; listByMovieSessionId(movieSessionId: string): Promise<Ticket[]>; listByMovieSessionIds(movieSessionIds: string[]): Promise<Ticket[]>; save(data: Ticket): Promise<Ticket>; }
import { ICinemaHallRepository } from '../../movieSession/application/boundaries/ICinemaHallRepository'; import { IMovieSessionRepository } from '../../movieSession/application/boundaries/IMovieSessionRepository'; import { ITicketRepository } from '../../movieSession/application/boundaries/ITicketRepository'; export interface IDbContext { cinemaHallRepository: ICinemaHallRepository; movieSessionRepository: IMovieSessionRepository; ticketRepository: ITicketRepository; startTransaction(): Promise<void>; commitTransaction(): Promise<void>; rollbackTransaction(): Promise<void>; }
Infrastructure Layer
Цей рівень відповідає за деталі, тому він найбільш знайомий та когнітивно простий для розробників. Це саме те місце, де треба працювати із запитами в БД, HTTP Request/Response обробкою, мідлварами, інтеграцією з іншими сервісами/API — всім тим, з чим зіштовхується Node.js-розробник майже кожен день.
API
Сервіс має інтерфейс взаємодії через REST API для отримання інформації про сеанси та квитки:
import { Controller, Get, Inject, Param, Post, Query } from '@nestjs/common'; import { CommandToken, QueryToken } from '../../../common/diTokens'; import { ICancelMovieSessionCommand } from '../../application/commands/cancelMovieSession/ICancelMovieSessionCommand'; import { GetMovieSessionQueryOutput, IGetMovieSessionQuery, } from '../../application/queries/getMovieSession/IGetMovieSessionQuery'; import { IListMovieSessionsQuery, ListMovieSessionsQueryOutput, } from '../../application/queries/listMovieSessions/IListMovieSessionsQuery'; @Controller({ path: 'movie-sessions', }) export class MovieSessionController { constructor( @Inject(QueryToken.GET_MOVIE_SESSION) private readonly _getMovieSessionQuery: IGetMovieSessionQuery, @Inject(QueryToken.LIST_MOVIE_SESSIONS) private readonly _listMovieSessionsQuery: IListMovieSessionsQuery, @Inject(CommandToken.CANCEL_MOVIE_SESSION) private readonly _cancelMovieSessionCommand: ICancelMovieSessionCommand, ) {} @Get() listMovieSessions( @Query('movie_id') movieId: string, ): Promise<ListMovieSessionsQueryOutput> { return this._listMovieSessionsQuery.execute({ movieId, }); } @Get(':movieSessionId') getMovieSession( @Param('movieSessionId') movieSessionId: string, ): Promise<GetMovieSessionQueryOutput> { return this._getMovieSessionQuery.execute({ id: movieSessionId, }); } @Post(':movieSessionId/cancel') async cancelMovieSession( @Param('movieSessionId') movieSessionId: string, ): Promise<void> { await this._cancelMovieSessionCommand.execute({ id: movieSessionId, }); } }
До прикладу, метод cancelMovieSession
був теж доданий як спосіб затригерити команду ICancelMovieSessionCommand
, проте на реальному проєкті є сенс винести ентрі-поінт в цю логіку в окреме, більш захищене від користувача місце:
import { Controller, Inject, Param, Post } from '@nestjs/common'; import { CommandToken } from '../../../common/diTokens'; import { ICancelTicketReservationCommand } from '../../application/commands/cancelTicketReservation/ICancelTicketReservationCommand'; @Controller({ path: 'tickets', }) export class TicketController { constructor( @Inject(CommandToken.CANCEL_TICKET_BINDING) private readonly _cancelTicketReservationCommand: ICancelTicketReservationCommand, ) {} @Post(':ticketId/cancel') async cancelTicketReservation( @Param('ticketId') ticketId: string, ): Promise<void> { await this._cancelTicketReservationCommand.execute({ ticketId, }); } }
Тут використовується Controller з NestJS, який дозволяє декларативно описувати ендпоінти апішки, тим самим абстрагує нас від деталей роботи з роутингом.
Persistence
Для роботи з базою даних ми будемо використовувати TypeORM разом з PostgreSQL. Для прикладу візьмемо репозиторій TicketRepository
:
import { In } from 'typeorm'; import { TicketEntity } from './TicketEntity'; import { TicketMapper } from './TicketMapper'; import { TypeOrmRepository } from '../../../common/infrastructure/persistence/TypeOrmRepository'; import { ITicketRepository } from '../../application/boundaries/ITicketRepository'; import { Ticket } from '../../domain/entities/Ticket'; export class TicketRepository extends TypeOrmRepository<TicketEntity> implements ITicketRepository { public async listByMovieSessionIds( movieSessionIds: string[], ): Promise<Ticket[]> { const ticketEntities = await this.repository.find({ where: { movieSessionId: In(movieSessionIds), }, }); return ticketEntities.map(TicketMapper.toDto); } public async findById(id: string): Promise<Ticket | null> { const ticketEntity = await this.repository.findOne({ where: { id, }, }); if (!ticketEntity) { return null; } return TicketMapper.toDto(ticketEntity); } public async listByMovieSessionId(movieSessionId: string): Promise<Ticket[]> { const ticketEntities = await this.repository.find({ where: { movieSessionId, }, }); return ticketEntities.map(TicketMapper.toDto); } public async save(data: Ticket): Promise<Ticket> { const ticketEntity = TicketMapper.toEntity(data); const createdTicketEntity = await this.repository.save(ticketEntity); return TicketMapper.toDto(createdTicketEntity); } }
Як ми бачимо, ми не виносимо деталі — ні про TypeORM, ні про PostgreSQL, ані навіть про реляційний тип бази даних за репозиторій, інакше деталі в цьому випадку змішуються з бізнес-логікою. Для зручності мапінгу Domain Entity в TypeORM Entity (і навпаки) ми винесемо логіку мапінгу в окремий клас TicketMapper
:
import { TicketEntity, SaveableTicketEntity } from './TicketEntity'; import { Ticket } from '../../domain/entities/Ticket'; export class TicketMapper { static toDto(from: TicketEntity): Ticket { const { visitorId, id, movieSessionId, price, seatNumber } = from; return new Ticket(id, movieSessionId, seatNumber, price, visitorId); } static toEntity(from: Ticket): SaveableTicketEntity { const { visitorId, movieSessionId, price, seatNumber, ticketId } = from; return { id: ticketId, visitorId, movieSessionId, price, seatNumber, }; } }
Сам же TypeORM Entity буде мати такий вигляд:
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, } from 'typeorm'; import { MovieSessionEntity } from './MovieSessionEntity'; export type SaveableTicketEntity = Omit<TicketEntity, 'movieSession'>; @Entity({ name: 'ticket' }) export class TicketEntity { @PrimaryGeneratedColumn('uuid', { name: 'ticket_id' }) id: string; @Column({ type: 'uuid', name: 'movie_session_id' }) movieSessionId: string; @Column({ type: 'uuid', name: 'visitor_id', nullable: true }) visitorId: string | null; @Column({ type: 'int', name: 'seat_number' }) seatNumber: number; @Column({ type: 'float', name: 'price' }) price: number; @ManyToOne(() => MovieSessionEntity) @JoinColumn({ name: 'movie_session_id' }) movieSession: MovieSessionEntity; }
Інтеграція зі сторонніми сервісами
Інтеграція зі сторонніми сервісами є необхідною складовою мікросервісних систем. Цей процес може бути реалізований як у синхронному, так і в асинхронному форматі, залежно від потреб та особливостей конкретного кейсу.
У синхронній інтеграції взаємодія між сервісами відбувається в форматі, коли один сервіс чекає на відповідь від іншого перед продовженням виконання запиту. З іншого боку, асинхронна інтеграція дозволяє сервісам взаємодіяти незалежно один від одного, відправляючи події не чекаючи на миттєву відповідь.
Такий підхід забезпечує більшу гнучкість та швидкість системи, особливо у випадках, коли обробка деяких даних може займати тривалий час або відбуватись асинхронно.
Також асинхронний підхід зменшує кількість залежностей на інші сервіси шляхом брокера повідомлень, який слухають інші мікросервіси. Коли один сервіс реєструє відправляє подію в брокеру, йому не важливо хто саме в результаті отримає меседж.
Звісно в реальному світі команди або архітектори домовляються за контракти самих івентів, тож залежність переходить на інший рівень — оперативний.
Пропоную сфокусуватись на інтеграціях, що безпосередньо цікаві для нашого сервісу Movie Session Service:
Ми маємо комбінацію із двох типів інтеграції для наочності:
- Movie Library Service використовується як енциклопедія, яка містить розширену інформацію про прокати та фільми. Наш сервіс отримує інформацію використовуючи REST API Movie Library Service.
- Payment Service обмінюється повідомленнями з нашим сервісом через RabbitMQ для того, щоб забезпечити процес покупки та повернення квитків. Вся інформація про транзакції, оплату та повернення грошей не важлива для нашого сервісу.
Усе, що нам важливо розуміти — чи був квиток оплачений (TICKET_PURCHASED
). Як тільки квиток оплачується кінцевим користувачем, наша система змінює стан Ticket наRESERVED
та, своєю чергою, викидає івентTICKET_RESERVED
у брокер повідомлень, що може підхоплюватись іншими мікросервісами, наприклад, Analytics Service.
Для простого прикладу зробимо сервіс інтеграції із RabbitMQ:
import { Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import * as amqp from 'amqplib/callback_api'; import { IntegrationEventType } from './events/IntegrationEventType'; import { TicketPurchasedEventHandler } from '../../movieSession/infrastructure/eventHandlers/TicketPurchasedEventHandler'; import { IIntegrationService } from '../application/IIntegrationService'; import { Event } from '../domain/Event'; @Injectable() export class RabbitMQIntegrationService implements IIntegrationService { private readonly _QUEUE = 'cinema-integration-events'; private _channel: amqp.Channel | null = null; constructor(private readonly moduleRef: ModuleRef) {} listen(): Promise<void> { return new Promise(async (resolve, reject) => { // Connect to RabbitMQ server amqp.connect((error, connection) => { if (error !== null) { reject(error); } // Create a channel connection.createChannel((error, channel) => { if (error !== null) { reject(error); } this._channel = channel; channel.assertQueue(this._QUEUE, { durable: false }); channel.consume( this._QUEUE, async (message) => { if (message) { console.log(`Received event: ${message.content.toString()}`); const event: { type: IntegrationEventType.TICKET_PURCHASED; customerId: string; ticketId: string; } = JSON.parse(message.content.toString()); if (event.type === IntegrationEventType.TICKET_PURCHASED) { const hander = await this.moduleRef.resolve( TicketPurchasedEventHandler, ); await hander.handle({ customerId: event.customerId, ticketId: event.ticketId, }); } } }, { noAck: true }, ); resolve(); }); }); }); } publishEvent(event: Event<any>): void { this._channel?.sendToQueue(this._QUEUE, Buffer.from(JSON.stringify(event))); } }
Як бачимо, у нас є два методи — listen()
та publishEvent(event: Event<any>)
. Метод listen
відкриває конекшн до брокера та підписується на чергу cinema-integration-events
, а publishEvent
, своєю чергою, відправляє туди івенти з нашої системи.
Room for improvement: можна створити окремий клас-фабрику EventHandlerFactory
, який буде мапити отриманий інтеграційний івент на хендлери, що будуть його обробляти.
Тепер подивимось на сам Event Handler — TicketPurchasedEventHandler
:
import { Inject, Injectable, Scope }from '@nestjs/common'; import * as _ from 'lodash'; import { CommandToken } from '../../../common/diTokens'; import { IBindTicketToVisitorCommand } from '../../application/commands/bindTicketToVisitor/IBindTicketToVisitorCommand'; @Injectable({ scope: Scope.REQUEST }) export class TicketPurchasedEventHandler { constructor( @Inject(CommandToken.BIND_TICKET_TO_CUSTOMER) private readonly _bindTicketToVisitor: IBindTicketToVisitorCommand, ) {} public async handle(payload: { customerId: string; ticketId: string; }): Promise<void> { const { customerId, ticketId } = payload; await this._bindTicketToVisitor.execute({ visitorId: customerId, ticketId, }); } }
Хендлер не має розширеної логіки. Його мета — бути драйвером для логіки нашого Application Layer та мапити дані з інфраструктурного івенту у вигляд, який розуміє наш контекст. В цьому разі хендлер викликає команду BindTicketToVisitorCommand
:
import { Injectable, Scope } from '@nestjs/common'; import { BindTicketToVisitorCommandInput, IBindTicketToVisitorCommand, } from './IBindTicketToVisitorCommand'; import { Command } from '../../../../common/application/Command'; @Injectable({ scope: Scope.REQUEST }) export class BindTicketToVisitorCommand extends Command<BindTicketToVisitorCommandInput> implements IBindTicketToVisitorCommand { protected async implementation(): Promise<void> { const { visitorId, ticketId } = this._input; const ticket = await this._dbContext.ticketRepository.findById(ticketId); if (ticket === null) { throw new Error('Ticket does not exist'); } ticket.reserve(visitorId, this._eventDispatcher); await this._dbContext.ticketRepository.save(ticket); } }
Тестування сервісу
Отримання сеансів фільмів
GET https://localhost:3000/movie-sessions
Лист сеансів має інформацію про фільм: час сеансу, загальну кількість місць, кількість вільних місць та статус сеансу (OPEN
, CLOSED
, CANCELED
).
Отримання інформації про сеанс фільму
GET https://localhost:3000/movie-sessions/f751a9f3-fcdd-49e6-85ca-3f3f82082a40
Інформація про сеанс фільму має дані про кожне окреме місце: доступність, ціна та номер місця.
Скасування резервації квитка
POST https://localhost:3000/tickets/d99bbf06-39c6-4469-82b8-18230b64a660/cancel
За умови скасування резервації викидається івент, який може бути оброблений Payment Service для повернення коштів юзеру. Також можна побачити що квиток знову став доступний до резервації:
Скасування сеансу перегляду кіно
POST https://localhost:3000/movie-sessions/01a58514-6ed3-4b2a-8d40-55ff67088f9c/cancel
За умови скасування сеансу теж викидається івент. У цьому разі Payment Service може мати окрему логіку для обробки повернення коштів.
Стан сеансу змінюється на CANCELED.
Імітація інтеграційного івенту з Payment Service
Для імітації івенту відкриємо UI RabbitMQ і запаблішемо івент TICKET_PURCHASED
.
Побачимо, що сервіс отримав івент, і запустив BindTicketToVisitorCommand
, який виконав зміну стейту Ticket, викликавши reserve()
метод. Зрештою система зареєструвала ще один івент — TICKET_RESERVED
як підтвердження резервації.
Стейт самого квитка теж відображається у відповіді з API. Квиток більше недоступний для резервації:
Висновок
Використання Domain-Driven Design (DDD) та Clean Architecture у розробці змушує розробника тримати в голові дві осі розділення коду — вертикальну, що відображається у розподіленні бізнес-логіки на use case, та горизонтальну, що ділить застосунок на різні шари, відділяючи деталі від бізнесової частини.
Як завжди, є свої переваги та недоліки. Почнемо з переваг:
- Зрозуміла структура коду: обидва методи дозволяють легко розібратися у тому, як система організована, що спрощує роботу з кодом.
- Легкість у зміні й підтримці: DDD і Clean Architecture роблять систему більш гнучкою, дозволяючи швидко внести зміни та додавати нові функціональності.
- Фокус на бізнес-логіці: дані методи ставлять бізнес-потреби на перший план, допомагаючи легше відтворити ділові процеси в коді.
Недоліки:
- Складність реалізації: для впровадження цих підходів може знадобитися багато часу і зусиль на етапі розробки. Особливо це стосується івентів та комунікацій з іншими сервісами та контекстами.
- Потреба в навчанні: іноді терміни та концепції, використовувані в DDD та Clean Architecture, можуть бути складними для розуміння для новачків. Навчання команди та привчання їх до нових підходів може вимагати часу та зусиль.
Звісно, у цій статті описана одна з багатьох можливих інтерпретацій DDD в коді, адже цей підхід не про код, а про ідею мислення.
Чи використовувати всюди Value Objects, чи спроєктувати контекст так, чи інакше, чи застосовувати CQS, Hexagonal, Clean architecture, як саме — все це залежить від компетентності команди та лідів, бізнес-пріоритетів, доречності підходів у поточному кейсі тощо.
Для кожного окремого кейсу баланс гнучкості та простоти буде унікальний — це перше на що треба звертати увагу, щоб не почати over-engineerʼити.
Посилання на код проєкту ви можете знайти тут.
Використані матеріали та ресурси
- Clean Architecture — Robert C. Martin.
- Implementing Domain-Driven Design — Vaughn Vernon.
- Hexagonal Architecture with NestJS.
- NestJS Framework.
17 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів