Як імплементувати 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ʼити.

Посилання на код проєкту ви можете знайти тут.

Використані матеріали та ресурси

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось20
До обраногоВ обраному12
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

доволі спірний неймінг класів і файлів, не контракт неста в цілому, але стаття хороша, дякую

А де код та алгоритм роботи візуального інтерфейса з якого користувач працює з квитками?

Просто не можу пройти повз, бо колись був гарний час i я працював з автором статтi разом на проектом (у ролi куа), i от тодi якраз переробляли бек зачiпаючи в тому числi архiтекутуру. Окрiм того, що зафiксали старезнi баги якi нiхто не вiрив що вдасться закрити, так ще на виходi мiнiмально наговнякали нових. Я не можу професiйно оцiнити техничний бiк статтi, але якщо тодi ми видали дуже крутий результат, то вважаю що поради точно мають бути корисними.

От мені завжди було цікаво, чи вдавалося комусь це запровадити на практиці у масштабі. Основна проблема — це те що шматочок бізнес логіки може бути або у самій ентіті (метод який щось робить), або у Aggregate — ентіті, яка містить декільна ентіті та транзакції. Або у domain service (там є Policy і Specification), або у application service (use case). + бізнес логіки може бути так багато, що ентіті дуже розростається і всі починають дивитися на це криво (у цьому прикладі, додай бізнеc логіку що сеанс може бути прем’єрою, і там інша логіка покупки квитка у перший ряд — MovieSession уже розрослося, і так для кожного маленького бізнес запиту). І коли в команді 10+ девів, і немає чітких то зрозумілих правил що де класти — воно дуже не консістент.

У мене лише був випадок коли починали проект з нуля, хотілося це запровадити + була підтримка тім ліда. Вдалося зробити спрощену версію (лише domain та infrastructure), де в domain лише чисті джава класи та інтерфеси, в infrastucture — імплементація і код для бд/спрінга/меседж брокера. І то це зайняло багато зусиль переконати команду (бо на початковому етапі коли лише кради бенефіту не видно) + задокументувати + активні сесії під час код ревью поки всі не набили руку. Але основна мета «червоної» книжечки досягнута — розділення бізнес логіки та технології.

Якщо ти сам або маланька тіма, то воно легко таку гарну архітектуру малювати. Але якщо більша тіма, то гайдлайни мають бути прості як лом, щоб воно запрацювало, і їх можна було в 10 реченнях описати та додати до ПР чеклісту.

І вийшов Spring framework портований на Node. А кажуть Node простіший за Java EE/Jakarta EE. А тау усе те саме — класична трирівнева архітектура, Dependency Injection, DDD, ORM. P.S. Перед останнє посилання — бите 404.
P.P.S. Дякую за статтю було цікаво.

А чому мають бути якісь мінуси. То Google стек технологій — а то Oracle та VM Ware які роблять ровно одне і те саме. І як бачимо вже і одним і тим самим чином, з прямим портуванням. В цілому же, V8 поступається JVM з необхідністю інтерпретації з похідного коду а не проміжного байткоду — а як відомо чисті інтерпретатори мають проблеми зі станом гонки самого інтерпретатора, що лімітує можливість багатопоточності і розраралелювання. Це головні проблеми як JavaScript так і Python. Та напевно це тимчасово у випадку V8, яка розроблялась в першу чергу для браузера. Якщо буде зроблена попередня компіляція з будь чого (TypeScript/D/Darth/Kotlin/Jancy) в WebASM то проблему можна буде вичерпати. В моїй практиці, Node також використовує суттєво більше пам’яті навіть за Java. Головний плюс React/Angular та бекендом зазвичай займається одна і та сама людина, простіше масштабувати команди та керувати програмними проектами, та бізнесом в цілому. Команди де окремо йдуть бекенд, фронтенд та мобайл масштабується вкрай погано, з великим перекосом в сторону Frontend та. мобайл роботи. Замовники і менеджери так і взагалі не хочуть платити за бекенд і не бачать в тому сенсу виділяти людей які їм не показують мультфільми з UI які вони здатні зрозуміти.

Більше 1 слів, читати не буду

В моїй практиці, Node також використовує суттєво більше пам’яті навіть за Java.

Нетипова у вас практика. Одна з переваг Ноди на беку роками і є — значно менше використання пам’яті ніж у JVM.

А ще є свіженьке від AWS
LLRT (Low Latency Runtime) is a lightweight JavaScript runtime, for AWS Lambda, It’s built in Rust, utilizing QuickJS as JavaScript engine,
github.com/awslabs/llrt

що лімітує можливість багатопоточності і розраралелювання

Що зазвичай треба для операцій I/O

Можете порахувати кількість await’ів у наведеному в топіку коді. В цих місцях і були б запуски у іншому треді або передача виконання у паралельный channel

То як можна покращити наведений код «багатопоточністю і розраралелюванням»?

Замовники так і взагалі не хочуть платити за бекенд і не бачать в тому сенсу виділяти людей які їм не показують мультфільми з UI які вони здатні зрозуміти.

Вакансій — фулстек Java/.NET + Angular/React хватає.
та й Python/PHP теж.

Якщо буде зроблена попередня компіляція з будь чого (TypeScript/D/Darth/Kotlin/Jancy) в WebASM то проблему можна буде вичерпати.

На чому не пиши — писати треба під середовище браузера. Як ти його знаєш — то пофік на чому писати. Як не знаєш — то WebASM тобі нічим не допоможе.

Нетипова у вас практика. Одна з переваг Ноди на беку роками і є — значно менше використання пам’яті ніж у JVM.

Типовий класичний сервіс на Node — маленьке щось умовно з однієї форми приймає JSON і може десь в Redis чи Dynamo DB сторить. Суттєвий бекенд коли був на писаний на продакшені почав падати з нестачею пам’яті. Профільнули — та сама причина, що і в Java попередніх версій до оптимізацій — строки, але додатково до строк які використовує сама програма, ще і строки похідного коду в інтерпретаторі додаються. Коротше їло воно в нас 8 G так щоб не падало, а планувалось усе на дешевих подах з 2 G тому іноземні архічмоктори Node і задумували (потім прийшов інший і скзав усе пишемо на Spring і в іншій системі). Крім того G1 який в Java з 7-ї версії значно кращий за Mark-Swіp який в Node усе ще.

Можете порахувати кількість await’ів у наведеному в топіку коді

До чого тут проміси то взаглі, так само як і стріми наприклад з Optional ? Це синтаксичний цукор не більше. Ніяких корутин/файберів воно не надає і не гарантує. А треба то зазвичай для коннекту до БД в бібліотеці доступу до БД.

На чому не пиши — писати треба під середовище браузера. Як ти його знаєш — то пофік на чому писати. Як не знаєш — то WebASM тобі нічим не допоможе.

Йдеться про бекенд сервер, по суті прослідку до БД. Для Rest API середовище ні до чого, за великим рахунком браузеру повністю все одно на чому воно там написано і зроблено, 200 — чудово, 500 — погано. А от коли щоб написати якийсь комбобокс-дропдаун треба три людини, це вже проблема тільки не технічна. A WebASM це байткод, тобто якщо зробити JIT так само — проблема паралельності зникає. В браузері досі лише JS теж через проблеми з багатопотоковістю, саме тому WebASM не має доступу до DOM. На сервері взагалі не існує такої проблеми. Так можливо Go/D чи Rust тут краща ідея — але деколи те саме по перформансу, але швидкість розробки на Spring та Hibernate і велосипедах несумісна. І ще такі результати бувають habr.com/ru/articles/678628

маленьке щось умовно з однієї форми приймає JSON і може десь в Redis чи Dynamo DB сторить. ... Коротше їло воно в нас 8 G

Кейси звісно різні бувають. З вашого опису — в пам’яті одночасно знаходиться гігабайти стрінгів. І, аплікація на Джаві таку ж кількість стрінгів якось більш оптимально зберігає.
Ну, є така відома проблема у Ноди — штатні JSON.parse та JSON.stringify можуть почати тупити вже на десятках мегабайт.

Крім того G1 який в Java з 7-ї версії значно кращий за Mark-Swіp який в Node усе ще.

Тут важливіше навіть не сам тип GC — а як відбувається виділення пам’яті у конкретній аплікації і інтерпретаторі, ВМ — наскільки довго живуть об’єкти, наскільки швидко виникає те «сміття».

потім прийшов інший і скзав усе пишемо на Spring і в іншій системі

І можливо був правий. У вашому конкретному випадку.

До чого тут проміси то взаглі

До того, що як система звертається до I/O — то для подальшої роботи їй треба отримати відповідь.
І як вона те буде чекати — чи в чесному треді, чи від евен лупа — вже не має значення.

Це синтаксичний цукор не більше

Та як хочете його називайте. Головне що це вирішення проблеми потреби у паралельності.
І у Джава коді замість аwait стояв би запуск треда. Але тред — більш оверхедна штука аніж евентлпуп.

А треба то зазвичай для коннекту до БД в бібліотеці доступу до БД.

Запит до БД робиться для отримання результату. БД не відповідає миттєво. Тому — треба або чекати у чесному однопотоку — як у PHP, або — паралетити по факту виконання коду.
Як ви те зробите — тредами, каналами, Vertxіксом чи евентлупом — вже не принципово. Тобто чесна параллельність чи «нечесна» асінхронщина — вирішує цю проблему.

A WebASM це байткод, тобто якщо зробити JIT так само — проблема паралельності зникає.

Так питання ж у тому — коли вона і чому виникає :) Запит до БД виконується — паралельно роботі системи, чи послідовно.

але деколи те саме по перформансу

от про це деколи і мова.
Деколи нам треба обробляти гігабайти стрінгів, які приходят у вигляді JSON.
Деколи нам треба перемножати матриці.
Деколи нам треба тримати в пам’яті величезні хешмапи.

А зазвичай — ми чекаємо на відповідь від I/O. І — не хочемо щоб вся система, весь наш код чекав ту відповідь. От для цього, зазвичай і треба паралельність — щоб не чекати.

А деколи так, нам треба завантажити всі наявні ресурси CPU. Тоді асінхронщина не допоможе.
Хоча в Ноді і для цього вже є штатні засоби, але оверхед при їх використанні може бути вищий, ніж у JVM. Треба дивитись на конкретний кейс.

І ще такі результати бувають

Як на мене більш цікаві результати на www.techempower.com/benchmarks

На зараз там
18 ditsmod [postgres] — typ njs

Ditsmod — новий TypeScript веб-фреймворк для Node.js
dou.ua/forums/topic/32553

Сама по собі Нода виглядає простішою, дйсно. Фреймворки та архітектурні підходи як приємні додати які дозволяють більш гнучко реалізовувати застосунки, а також вносять свої стандарти розробки, що допомагають при роботі на продукті із великою кількістю команд. Таким чином зберігається консистентність в архітектурі не зважаючи на кількість розробників.

P.S. Дякую! Скоріш за все посилання на статтю «Hexagonal Architecture with NestJS» було змінено автором під час створення цієї статті :)
Виправляю.

І вийшов Spring framework портований на Node.

Ну автори Nest нібито не дуже й приховували, що надихалися Spring та подібними фреймворками.

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;
}
....

Дякую за статтю, але не знаю як у інших читачів — мене дико тіпало від подібних boilerplate кусків коду, які зустрічають в різних місцях статті

BTW Теж портовано з Java, в однопоточному JavaScript не мутабельність кортежей особливого сенсу не має. Коли потоків у вас може бути більше за один, це найпростіший метод забезпечити захищеність від стану гонки.
Та як вже зазначили, тут не ясно як бути коли одночасно декілька подів будуть робити зміни через persistence. Скажімо один і той же користувач використовує одночасно Web та мобільний пристрій, по суті треба додавати розподілену транзакцію.

ваша імплементація виглядає доволі наївною — ticket який можна відкенцилити, а потім оплатити(де версіонування апдейтів і резолв конфліктів/хендлінг concurrency), покупка квитків що не потребует acknowledgement на этапі резервування, діспатчинг івентів через amqp який в жодній з версії не пітримує атомарності якщо паблішити в декілька черг одночасно і т.д. з послідуючим роллбеком, а як що процесс впаде і на встигне зароллбекатися просто навіть — де event workflow, event sourcing, outbox, state consistency — перевинайшли все 2024 року все на роллбеках в try catch?
виглядає к сугубо академічний приклад який використовувати для систем де потрібна state consistency, operation ordering, durability категорично не можна.

Дякую за відгук!
Ви абсолютно праві, дійсно, імплементація наведена у цій статті робить більше акцент на саму концепцію архітектурних підходів, ніж на production-like рішення. Я бачу що ви маєте розширену практичну складову досвіду роботи із Event-based архітектурами і залюбки роздивлюсь ваш коментар як room-for-improvement для наступних статей щоб покрити більш вузьконаправленні аспекти які також можуть бути цікаві для читачів із більш розширеним рівнем знань, як у вас. Сподіваюсь ви отримали хоч трошечки корисної інформації з цієї статті :)

Підписатись на коментарі