Чистая архитектура в JavaScript: осваиваем Hexagonal на практике

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Ваш код — это крепость: почему Hexagonal архитектура спасёт ваш JavaScript-проект

Вы когда-нибудь сталкивались с ситуацией, когда смена базы данных или замена внешнего API превращалась в ад переписывания половины проекта? Или, может быть, вы писали юнит-тесты, которые больше напоминали интеграционные, потому что бизнес-логика была плотно сцеплена с Express или MongoDB? Если да — добро пожаловать в мир Hexagonal архитектуры (Ports and Adapters).

Это не просто модный паттерн из книг Алистера Коберна. Это практический способ сделать ваш JavaScript-код устойчивым, тестируемым и независимым от фреймворков. В этой статье мы не будем читать скучные лекции. Мы возьмём реальную задачу (систему управления заказами) и шаг за шагом построим её на принципах гексагональной архитектуры в JavaScript. Вы увидите, как бизнес-логика остаётся девственно чистой, а весь «шум» (базы, HTTP-запросы, очереди) остаётся за бортом.

Готовы? Поехали!

Что такое Hexagonal архитектура (и при чём тут порты и адаптеры)?

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

Основная идея: ядро приложения (core) не должно знать, кто и как к нему стучится. Оно объявляет только «порты» (интерфейсы). А конкретные реализации (адаптеры) подключаются снаружи. Это даёт три супер-способности:

  • Изоляция бизнес-логики — вы меняете базу данных, не трогая код расчёта скидок.
  • Тестируемость — вы тестируете логику без поднятия докера и реальной БД.
  • Независимость от фреймворка — вы можете переехать с Express на Fastify, просто написав новый адаптер.

Давайте перейдём к коду.

Настройка проекта и структура папок

Для начала создадим простую структуру. Мы будем писать на чистом TypeScript (или JavaScript с JSDoc — кому как удобнее). Наша цель — система управления заказами.

src/
├── core/
│   ├── domain/          # Сущности и Value Objects
│   ├── ports/           # Интерфейсы (порты)
│   └── services/        # Бизнес-логика (use cases)
├── adapters/
│   ├── primary/         # Адаптеры, которые вызывают наше ядро (HTTP, CLI)
│   └── secondary/       # Адаптеры, которые ядро вызывает (БД, внешние API)
└── app.js               # Точка входа (сборка всего вместе)

Шаг 1. Ядро: Доменные сущности и порты

Начнём с самого важного — с того, что не зависит ни от чего. Создадим сущность Order (заказ).

core/domain/Order.js

export class Order {
  constructor({ id, userId, items, status = 'pending' }) {
    this.id = id;
    this.userId = userId;
    this.items = items; // Массив { productId, quantity, price }
    this.status = status;
    this.total = this.calculateTotal();
  }

  calculateTotal() {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }

  approve() {
    if (this.status !== 'pending') {
      throw new Error('Only pending orders can be approved');
    }
    this.status = 'approved';
  }
}

Теперь определим порты — интерфейсы, которые наше ядро ожидает от внешнего мира.

core/ports/OrderRepository.js

// Это порт (интерфейс). Мы не реализуем его здесь.
export class OrderRepository {
  async save(order) { throw new Error('Not implemented'); }
  async findById(id) { throw new Error('Not implemented'); }
}

core/ports/EmailService.js

export class EmailService {
  async sendOrderConfirmation(order) { throw new Error('Not implemented'); }
}

Шаг 2. Ядро: Use Case (Сервис)

Теперь напишем бизнес-логику — сервис, который использует порты. Обратите внимание: мы не импортируем никаких баз данных или HTTP-клиентов. Только интерфейсы!

core/services/OrderService.js

import { Order } from '../domain/Order.js';

export class OrderService {
  /**
   * @param {import('../ports/OrderRepository.js').OrderRepository} orderRepository
   * @param {import('../ports/EmailService.js').EmailService} emailService
   */
  constructor(orderRepository, emailService) {
    this.orderRepo = orderRepository;
    this.emailService = emailService;
  }

  async createOrder(userId, items) {
    // 1. Бизнес-правило: проверяем, что пользователь не забанен (гипотетически)
    if (!userId) throw new Error('User is required');

    // 2. Создаём сущность
    const order = new Order({
      id: crypto.randomUUID(),
      userId,
      items,
    });

    // 3. Сохраняем через порт
    await this.orderRepo.save(order);

    // 4. Отправляем email через порт
    await this.emailService.sendOrderConfirmation(order);

    return order;
  }

  async approveOrder(orderId) {
    const order = await this.orderRepo.findById(orderId);
    if (!order) throw new Error('Order not found');

    order.approve(); // Доменная логика
    await this.orderRepo.save(order);

    return order;
  }
}

Всё! Ядро готово. Оно не знает про Express, PostgreSQL или SendGrid. Оно знает только про OrderRepository и EmailService.

Шаг 3. Адаптеры: Подключаем внешний мир

Теперь самое интересное — напишем реальные адаптеры. Начнём с вторичного адаптера (driven) — репозитория для MongoDB.

adapters/secondary/MongoOrderRepository.js

import { OrderRepository } from '../../core/ports/OrderRepository.js';
import { Order } from '../../core/domain/Order.js';

export class MongoOrderRepository extends OrderRepository {
  constructor(db) {
    super();
    this.collection = db.collection('orders');
  }

  async save(order) {
    await this.collection.updateOne(
      { id: order.id },
      { $set: { ...order } },
      { upsert: true }
    );
  }

  async findById(id) {
    const doc = await this.collection.findOne({ id });
    if (!doc) return null;
    return new Order(doc);
  }
}

Адаптер для отправки email через nodemailer:

adapters/secondary/NodemailerEmailService.js

import { EmailService } from '../../core                

Блоги

Рекомендаций по книгам