Hexagonal Architecture в TypeScript: чистая архитектура для гибких приложений

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

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

Начать курс

Почему 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 особенно полезна в следующих сценариях:

  • Микросервисы — каждый сервис может иметь свою реализацию хранения данных, не влияя на другие.
  • Сложный

Блоги

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