Почему Hexagonal Architecture — это не просто модный термин, а спасение для вашего кода?
Представьте, что вы строите дом. Вы же не вмуровываете розетки в бетонные стены? Вы сначала прокладываете проводку, делаете разметку, а потом аккуратно подключаете розетки. В разработке ПО всё точно так же — ваша бизнес-логика (ядро приложения) не должна зависеть от того, через что с ней общаются: REST API, база данных или очереди сообщений.
Hexagonal Architecture (она же «Порты и Адаптеры», или «Чистая архитектура» в интерпретации Алистера Коберна) решает именно эту задачу. В этой статье мы разберем, как реализовать эту архитектуру на TypeScript с реальными примерами кода. Вы узнаете, как отделить бизнес-логику от инфраструктуры, сделать код тестируемым и гибким.
Что такое Hexagonal Architecture простыми словами?
Представьте себе шестигранник (гексагон). В центре — ваше приложение с бизнес-логикой. На каждой грани — «порт» (интерфейс), через который внешний мир может общаться с ядром. Адаптеры — это конкретные реализации: контроллеры, репозитории, клиенты внешних сервисов. Они подключаются к портам, как вилка в розетку.
Главный принцип: зависимости направлены внутрь. Внешний код (базы, фреймворки) зависит от ядра, а не наоборот. Это позволяет менять базу данных с PostgreSQL на MongoDB, не трогая ни строчки в бизнес-логике.
Основные понятия
- Ядро (Domain) — бизнес-логика, сущности, value objects, use cases. Не имеет внешних зависимостей.
- Порт (Port) — интерфейс (в TypeScript — это абстрактный класс или interface), который определяет контракт для внешнего мира.
- Адаптер (Adapter) — конкретная реализация порта. Например, репозиторий, который работает с PostgreSQL через TypeORM.
- Application — слой, который координирует выполнение use cases.
Практический пример: система управления заказами
Давайте спроектируем простую систему заказов. У нас будут сущности Order и Product, use case CreateOrder, порт для репозитория и адаптер на PostgreSQL.
Шаг 1. Определяем ядро (Domain layer)
Создадим папку domain/. Здесь живут сущности и интерфейсы для портов. Никаких зависимостей от фреймворков!
// domain/order.entity.ts
export class Order {
constructor(
public readonly id: string,
public items: OrderItem[],
public status: 'pending' | 'confirmed' | 'shipped',
public total: number
) {}
public addItem(productId: string, quantity: number, price: number): void {
this.items.push({ productId, quantity, price });
this.total = this.calculateTotal();
}
private calculateTotal(): number {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
}
export interface OrderItem {
productId: string;
quantity: number;
price: number;
}
Теперь определим порт для репозитория:
// domain/ports/order.repository.interface.ts
import { Order } from '../order.entity';
export interface IOrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
findAll(): Promise<Order[]>;
}
Шаг 2. Use Case — сердце приложения
Use Case — это сценарий использования. Он содержит бизнес-логику и использует порты, но не знает, кто их реализует.
// application/use-cases/create-order.use-case.ts
import { Order } from '../../domain/order.entity';
import { IOrderRepository } from '../../domain/ports/order.repository.interface';
export class CreateOrderUseCase {
constructor(private readonly orderRepository: IOrderRepository) {}
async execute(userId: string, items: Array<{ productId: string; quantity: number; price: number }>): Promise<Order> {
// Бизнес-правило: заказ не может быть пустым
if (items.length === 0) {
throw new Error('Order must have at least one item');
}
const order = new Order(
crypto.randomUUID(),
[],
'pending',
0
);
items.forEach(item => order.addItem(item.productId, item.quantity, item.price));
await this.orderRepository.save(order);
return order;
}
}
Обратите внимание: CreateOrderUseCase не знает, какая база данных используется. Он просто вызывает this.orderRepository.save().
Шаг 3. Адаптеры — реализация портов
Адаптер для PostgreSQL с TypeORM:
// infrastructure/adapters/order.repository.typeorm.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { IOrderRepository } from '../../../domain/ports/order.repository.interface';
import { Order } from '../../../domain/order.entity';
import { OrderEntity } from './order.entity.orm'; // ORM-сущность
@Injectable()
export class OrderRepositoryTypeOrm implements IOrderRepository {
constructor(
@InjectRepository(OrderEntity)
private readonly ormRepository: Repository<OrderEntity>
) {}
async save(order: Order): Promise<void> {
const ormEntity = this.toPersistence(order);
await this.ormRepository.save(ormEntity);
}
async findById(id: string): Promise<Order | null> {
const ormEntity = await this.ormRepository.findOne({ where: { id } });
return ormEntity ? this.toDomain(ormEntity) : null;
}
async findAll(): Promise<Order[]> {
const ormEntities = await this.ormRepository.find();
return ormEntities.map(entity => this.toDomain(entity));
}
private toPersistence(order: Order): OrderEntity {
// маппинг из доменной сущности в ORM
return { id: order.id, status: order.status, total: order.total, items: JSON.stringify(order.items) } as OrderEntity;
}
private toDomain(entity: OrderEntity): Order {
const items = JSON.parse(entity.items);
const order = new Order(entity.id, items, entity.status, entity.total);
return order;
}
}
Адаптер для REST API (NestJS):
// infrastructure/adapters/order.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { CreateOrderUseCase } from '../../../application/use-cases/create-order.use-case';
@Controller('orders')
export class OrderController {
constructor(private readonly createOrderUseCase: CreateOrderUseCase) {}
@Post()
async createOrder(@Body() dto: { userId: string; items: Array<{ productId: string; quantity: number; price: number }> }) {
const order = await this.createOrderUseCase.execute(dto.userId, dto.items);
return { id: order.id, status: order.status, total: order.total };
}
}
Почему это работает и когда это применять?
Hexagonal Architecture особенно полезна в следующих сценариях:
- Микросервисы — каждый сервис может иметь свою реализацию хранения данных, не влияя на другие.
- Сложный