Гексагональная архитектура для Node.js-приложения, или Как сделать код более поддерживаемым

Привет, меня зовут Андрей, я Engineering Manager в компании Uptech. В этой статье хочу рассказать об одном из архитектурных подходов для создания приложений — гексагональной архитектуре. Рассмотрим пример ее использования для создания Node.js-приложения.

Если ищете способ улучшить поддерживаемость и тестируемость кода, тогда эта статья для вас.

TLDR. Разделяйте бизнес-логику и любые внешние зависимости — будьте счастливы.

Перед тем как начать, давайте разберемся, какие проблемы нужно решить. Основное — сделать код более поддерживаемым. А именно: быстро добавлять новые фичи, легко менять одни зависимости на другие и тестировать код. Фактически минимизировать технический долг в долгосрочной перспективе. Гексагональная архитектура при правильном использовании может удовлетворить наши желания.

Рассмотрим типичное приложение:

В более общем случае:

Вроде все хорошо, но что обычно происходит в реальной жизни?

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

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

Хотелось бы достичь независимости компонентов приложения и особенно доменной логики от инфраструктуры. Посмотрим, как этого можно добиться.

Порты и адаптеры

Если посмотрим на описанный выше флоу, увидим, что роуты — это просто точки «входа» к бизнес-логике, а база данных и другие сторонние сервисы — «выходы» из приложения. И таких точек «входа» и «выхода» может быть много. Заметив это, Алистер Коберн в 2005 году предложил подход к архитектуре приложений, который назвал «гексагональная архитектура», или «архитектура портов и адаптеров».

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

Наша основная цель — это изоляция бизнес-логики. Но нужен способ взаимодействия с ней. Порт служит именно для этой цели. Порт — описанная спецификация взаимодействия уровня логики с любыми внешними зависимостями. Это просто интерфейс, по которому бизнес-логика вызывается или сама вызывает внешние зависимости, без каких-либо деталей имплементации. Для большинства языков порт — это интерфейс и DTO (Data transfer object), связанные с этими интерфейсами. Порты принадлежат уровню бизнес-логики.

После того как описали процесс взаимодействия с бизнес-логикой, стоит раскрыть логику взаимодействия с конкретными сторонними сервисами и соединить их вместе. Для этой цели служат адаптеры. Адаптер — конкретная имплементация работы с другими сервисами. Эти сервисы бывают двух типов по отношению к «ядру» приложения. Те, которые логику вызывают — HTTP-роуты, Socket-соединения. Их называют первичными (Primary) адаптерами. И те, которых бизнес-логика сама вызывает: база данных, платежные программы, сервисы уведомлений и так далее. Их называют вторичными (Secondary) адаптерами.

Основная разница между ними в том, как они взаимодействуют с уровнем бизнес-логики. Primary-адаптеры втягивают в себя уровень логики и вызывают его напрямую. Они фактически говорят нашему приложению, что делать. А Secondary-адаптеры имплементируют порт и после этого инжектятся в уровень логики с помощью Dependency injection.

Выглядит это приблизительно так:

Выглядит красиво, но рассмотрим на примере. Спроектируем небольшое приложение календаря.

Первичный адаптер. HTTP endpoint

Наш календарь имеет обычный REST API и взаимодействует с внешним миром через HTTP. HTTP endpoint — самый простой пример первичного адаптера. Контроллер, который работает с вашим фреймворком и обрабатывает HTTP-запросы.

В данном случае метод CreateEvent обрабатывает POST-запросы по роуту /event, парсит все параметры запроса и вызывает eventCreationService, который есть частью слоя с бизнес-логикой. Он принимает на вход только значения и ничего не знает об особенностях транспорта, что его вызвал, таких как тело запроса или заголовки.

Таким образом мы убираем зависимость бизнес-логики от транспортного протокола и фреймворка:

@injectable()
@JsonController('/event')
export class EventController {
    // ...
    @Post()
    public async createEvent(
        @Body() input: EventCreationInput,
        @CurrentUser({required: true}) userId: number
    ): Promise<EventResponse> {
        return await this.eventCreationService.createEvent(input, userId);
    }
    // ...
}

Порт. Сервис нотификаций

Наш календарь отсылает уведомления и напоминания, и для этого слою бизнес-логики нужно уметь отправлять нотификации с помощью стороннего сервиса. Чтобы не зависеть от конкретного провайдера нотификаций, спрячем его за порт.

Порт в нашем случае — интерфейс, в котором описываем, как отправить нотификации. Для слоя бизнес-логики достаточно знать, что для их отправки нужно вызвать метод sendNotifications с конкретным сообщением и списком пользовательских токенов. Как именно это происходит на этом уровне, нам неважно.

Порт принадлежит сервису и используется на уровне бизнес-логики. В сервисе следует заинжектить конкретную имплементацию интерфейса. Это рассмотрим ниже.

export interface NotificationService {
    sendNotifications(message: NotificationMessage, userTokens: string[]): Promise<void>;
}

Вторичный адаптер. Сервис нотификаций

Теперь осталось описать, как именно оправлять нотификации с помощью конкретного провайдера. Для этого пишем вторичный адаптер. Он имплементирует описанный выше интерфейс NotificationService. В нем с использованием Firebase SDK имплементируем отправку нотификаций через конкретный сервис. Таким образом мы изолируем зависимость на Firebase SDK в одном месте нашего приложения. Адаптер нужно зарегистрировать в DI-контексте, чтобы потом заинжектить его в сервисе с бизнес-логикой.

@injectable()
export class FCMNotificationAdapter implements NotificationService {
    private readonly fcmApp: admin.app.App;

    constructor() {
        this.fcmApp = admin.initializeApp({
            credential: config.firebase.authJson
        });
    }

    public async sendNotifications(message: MessageData, tokens: string[]): Promise<void> {
        const payload = {
            tokens,
            notification: message.notification,
            data: {
                data: JSON.stringify(message.data)
            }
        };

        const response = await this.fcmApp.messaging().sendMulticast(payload);
    }
}

Inversion of Control

Обратите внимание: чтобы сделать вторичные адаптеры независимыми от бизнес-логики, используем Dependency injection (DI). Получается, что зависимости направлены к Application core. Работает принцип Inversion of Control сразу на уровне архитектуры.

Организация Application core

На этом этапе классическая гексагональная архитектура, описанная Алистером Коберном, заканчивается. А у нас большая часть приложения остается не организованной. Собственно, это «гексагон» с бизнес-логикой — Application core. Я поделюсь нашим подходом, как мы это делали. Для нас это неплохо сработало. Но фактически организация кора не является стандартизированной, поэтому эта часть полностью на ваше усмотрение.

Для организации бизнес-логики мы объединили подход слоевой архитектуры (Layered Architecture) и DDD (Domain Driven Design).

Вот что из этого вышло. Следуя слоевому подходу, мы разделили Application core на два слоя: Application и Domain. Domain отвечает за бизнес-логику, в нем хранятся доменные модели и сервисы, которые отражают бизнес-процессы. Это базовый слой, он не зависит от других частей приложения.

Слой Application связывает доменную логику с внешними сервисами. Application-уровень описывает полноценные пользовательские сценарии (use case). Здесь живут порты, через которые происходит взаимодействие с адаптерами. Запросы в базу и на другие сторонние сервисы вызываются из этого уровня.

Сервис Аpplication-уровня работает приблизительно так: он инжектит в себя вторичные адаптеры, не зная ничего о конкретной имплементации этих сервисов. В данном примере EventRepository и NotificationService — это порты, описанные на доменном уровне, для которых из DI-контекста инжектится конкретная имплементация взаимодействия с внешними сервисами.

@injectable()
export class EventCreationService {
    private eventRepo: EventRepository;
    private notificationService: NotificationService;

    constructor(
        @inject(EventRepositoryType) eventRepo: EventRepository,
        @inject(NotificationServiceType) notificationService: NotificationService,
    ) {
        this.eventRepo = eventRepo;
        this.notificationService = notificationService;
    }

    public async createEvent(input: EventCreationInput, userId: number): Promise<EventResponse> {
        const newEvent = Event.fromObject({
            ...input,
            creatorId: userId
        });

        const savedEvent = await this.eventRepo.save(newEvent);
        
        const message = {
            title: `You invited to ${event.title}`,
            body: 'Wanna participate?',
            data: {eventId: event.id}
        };

        await this.notificationService.sendNotifications(message, tokens);

        return savedEvent;
    }
}

Второй шаг организации Application core — это разделение на компоненты по доменной области. В каждом из них лежат сервисы и модели, которые тесно связаны между собой, но слабо зависимы от других частей приложения. Такой себе Bounding Сontext из DDD. Примеры таких компонентов: User, Reminder, Event и так далее.

В итоге получаем такую схему:

Для уменьшения связности между компонентами можно использовать event-driven подход и реализовать их взаимодействие на основании ивентов.

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

Тесты

У нас было 3 вида тестов, каждый отвечал за свою часть архитектуры.

Unit-тесты

Классические unit-тесты для каждого сервиса. С их помощью мы протестировали бизнес-логику в Аpplication core. Они достаточно легковесны и быстро исполняются.

Integration-тесты

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

Acceptance-тесты

Когда мы проверили бизнес-логику и интеграции с внешними сервисами, осталось убедиться, как система работает целиком. В этом помогут Acceptance-тесты. Они тестируют полный пользовательский сценарий, но с замокаными вторичными адаптерами. При этом выполняются быстро, так как не взаимодействуют с сетью. Из минусов — тесты могут быть очень большими для сложных сценариев, поэтому их написание и поддержка потребует много усилий. Чтобы сбалансировать усилия и пользу, мы выработали правило: один Acceptance-тест для одного пользовательского сценария и только для основного успешного кейса.

Звучит это все хорошо, а в чем подвох, спросите вы?

Проблемы

Гексагональная архитектура — не серебряная пуля и к тому же имеет ряд проблем, с которыми придется разбираться.

Транзакции

Транзакция — это абстракция уровня базы данных, которая используется на уровне бизнес-логики. Поэтому получаем прямую зависимость на базу данных на уровне Аpplication. А это то, чего хотелось бы избежать. Не все базы данных поддерживают транзакции, поэтому абстрагировать их не выйдет. Транзакции иногда могут требовать сложной логики роллбеков.

Оптимизация

Порой требуется вытащить данные из базы нетривиальным образом. Для этого пишем сложный запрос, описывая бизнес-правила, какие именно данныенужны. В итоге получаем «утечку» логики в адаптер, усложняем адаптер и тем самым лишаем себя части пользы от архитектуры. Приходится искать баланс между хорошей архитектурой и оптимизациями.

Валидация

Не очевидно, где именно должны быть валидации? С одной стороны, валидация — это часть бизнес-логики и должна жить в Аpplication core. С другой стороны, слои должны быть максимально независимы со своей валидацией на каждом уровне. Или достаточно проверить так где они приходят — на первичных адаптерах? Тут нет правильного ответа, выбирайте то, что больше подойдет вашему приложению.

Выводы

  • Отделяйте внешние зависимости от бизнес-логики.
  • Тестируйте код с разных сторон и не забывайте о здравом смысле.
  • В каждом подходе есть свои проблемы, будьте готовы их решать.
  • Помните, что не существует одного метода, который решит все проблемы. Оцените, насколько данный подход эффективен именно для вашей задачи. Например, если у вас мало бизнес-логики, наверное, гексагональная архитектура это не лучший выбор.

Полноценный демопроект можете найти на GitHub.

Похожие статьи:
Компания Irbis представила три новых планшета-трансформера: TW20, TW21 и TZ94. Первые два работают под управлением Windows 8.1, а третья – на базе...
У Луцьку в IT-сфері працює більше 250 спеціалістів та щонайменше 15 компаній. У 2015 році було створено Луцький IT-кластер. У місті...
У випуску: гід з вибору Python-фреймворків для початківців, нотатник в консолі та життєві історії про...
Александр Соловьев начинал свою работу в ІТ как дежурный сисадмин. Теперь он работает на позиции...
Вы безгранично любознательны, Вам нравится устраивать краш-тесты своему телефону и поверять...
Яндекс.Метрика