Ваш код — это крепость: почему 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