← Блог
tutorials

Хексагонална архитектура | Практическо ръководство за съвременни приложения

Научете какво е хексагонална архитектура, защо има значение и как да я имплементирате. Пълно ръководство с реални примери за изграждане на поддържаем, тестваем софтуер.

Ryveris Team ·
Хексагонална архитектура | Практическо ръководство за съвременни приложения

Хексагоналната архитектура е модел за софтуерен дизайн, който изолира основната ви бизнес логика от външните системи като бази данни, API-та и потребителски интерфейси. Идеята е проста: кодът на домейна ви никога не трябва да зависи от инфраструктурата. Инфраструктурата зависи от домейна.

Моделът е въведен от Alistair Cockburn през 2005 г. Може също да го чуете като “Ports and Adapters” (Портове и адаптери). И двете имена описват същата концепция.

Защо съществува

Повечето приложения започват по един и същи начин. Бизнес логиката се заплита с заявки към бази данни, HTTP обработчици и извиквания на услуги от трети страни. Работи в началото. След това кодовата база расте и се случват три неща:

  1. Тестването става болезнено. Не можете да тествате бизнес правилата, без да стартирате база данни или да създадете мок на половината фреймуърк.
  2. Промяната на инфраструктурата е рискована. Смяната на платежен доставчик или миграцията от REST към GraphQL означава пренаписване на бизнес логика.
  3. Кодът е труден за разбиране. Никой не може да каже къде свършва домейнът и къде започва инфраструктурата.

Хексагоналната архитектура решава и трите проблема, като налага едно правило: зависимостите винаги сочат навътре.

Основните концепции

Мислете за приложението си като три концентрични слоя.

Домейнът (Център)

Това е вашата бизнес логика. Чисти функции, обекти, стойностни обекти и домейн услуги. Има нулеви зависимости от фреймуърци, бази данни или външни библиотеки. Говори собствения си език.

За система за електронна търговия този слой съдържа концепции като Order, Product, PricingRule и InventoryPolicy. Тези обекти не знаят нищо за SQL, HTTP или JSON.

Портове (Среден слой)

Портовете са интерфейси, които дефинират как външният свят взаимодейства с домейна. Те са договори, а не имплементации.

Има два типа:

  • Входни портове (задвижващи). Дефинират какво може да прави приложението. Мислете за тях като случаи на употреба. “Направи поръчка.” “Откажи абонамент.” “Генерирай фактура.”
  • Изходни портове (задвижвани). Дефинират от какво приложението се нуждае от външния свят. “Запиши поръчка.” “Изпрати известие.” “Извлечи валутния курс.”

Портовете са част от домейн слоя. Написани са на домейн език, а не на инфраструктурен език. Пишете OrderRepository, а не PostgresOrderDAO.

Адаптери (Външен слой)

Адаптерите са конкретните имплементации, които свързват реалния свят с портовете ви.

  • Входни адаптери превеждат външни заявки в домейн извиквания. REST контролер, GraphQL резолвер, CLI команда, консуматор на опашка за съобщения. Всичко това са входни адаптери.
  • Изходни адаптери имплементират интерфейсите на изходните портове. PostgreSQL хранилище, SMTP изпращач на имейли, Stripe платежен шлюз. Всичко това са изходни адаптери.

Ключовото прозрение: адаптерите зависят от портовете. Портовете никога не зависят от адаптерите. Това е, което кара целия модел да работи.

Конкретен пример

Да кажем, че изграждате функция за правене на поръчка. Ето как слоевете се разделят.

Домейн (обекти и правила):

// domain/Order.ts
class Order {
  readonly id: string;
  readonly items: OrderItem[];
  readonly status: OrderStatus;

  calculateTotal(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.price.multiply(item.quantity)),
      Money.zero()
    );
  }

  confirm(): Order {
    if (this.items.length === 0) {
      throw new EmptyOrderError();
    }
    return new Order({ ...this, status: "confirmed" });
  }
}

Входен порт (интерфейс на случай на употреба):

// ports/inbound/PlaceOrder.ts
interface PlaceOrder {
  execute(command: PlaceOrderCommand): Promise<OrderConfirmation>;
}

Изходни портове (от какво се нуждае случаят на употреба):

// ports/outbound/OrderRepository.ts
interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | null>;
}

// ports/outbound/PaymentGateway.ts
interface PaymentGateway {
  charge(amount: Money, method: PaymentMethod): Promise<PaymentResult>;
}

// ports/outbound/NotificationSender.ts
interface NotificationSender {
  sendOrderConfirmation(order: Order): Promise<void>;
}

Приложна услуга (имплементира входния порт):

// application/PlaceOrderService.ts
class PlaceOrderService implements PlaceOrder {
  constructor(
    private orders: OrderRepository,
    private payments: PaymentGateway,
    private notifications: NotificationSender
  ) {}

  async execute(command: PlaceOrderCommand): Promise<OrderConfirmation> {
    const order = Order.create(command.items);
    const confirmed = order.confirm();

    const payment = await this.payments.charge(
      confirmed.calculateTotal(),
      command.paymentMethod
    );

    if (!payment.successful) {
      throw new PaymentFailedError(payment.reason);
    }

    await this.orders.save(confirmed);
    await this.notifications.sendOrderConfirmation(confirmed);

    return OrderConfirmation.from(confirmed, payment);
  }
}

Адаптери (инфраструктура):

// adapters/inbound/OrderController.ts
class OrderController {
  constructor(private placeOrder: PlaceOrder) {}

  async handlePost(req: Request): Promise<Response> {
    const command = PlaceOrderCommand.fromRequest(req.body);
    const confirmation = await this.placeOrder.execute(command);
    return Response.json(confirmation.toResponse(), { status: 201 });
  }
}

// adapters/outbound/PostgresOrderRepository.ts
class PostgresOrderRepository implements OrderRepository {
  async save(order: Order): Promise<void> {
    await this.db.query(
      "INSERT INTO orders (id, items, status) VALUES ($1, $2, $3)",
      [order.id, JSON.stringify(order.items), order.status]
    );
  }

  async findById(id: string): Promise<Order | null> {
    const row = await this.db.query("SELECT * FROM orders WHERE id = $1", [id]);
    return row ? Order.fromPersistence(row) : null;
  }
}

Забележете как PlaceOrderService никога не споменава PostgreSQL, HTTP или някакъв фреймуърк. Работи чисто с домейн концепции и интерфейси на портове.

Правилото за зависимости

Това е най-важното правило. Ако не вземете нищо друго от тази статия, запомнете това:

Зависимостите на изходния код трябва винаги да сочат навътре, към домейна.

  • Адаптерите зависят от портовете. Никога обратното.
  • Портовете зависят от домейн обектите. Никога от адаптерите.
  • Домейнът не зависи от нищо външно.

Това се налага чрез инжектиране на зависимости. Вашият composition root (входната точка на приложението) свързва всичко заедно:

// main.ts (composition root)
const orderRepo = new PostgresOrderRepository(db);
const payments = new StripePaymentGateway(stripeClient);
const notifications = new EmailNotificationSender(mailer);

const placeOrder = new PlaceOrderService(orderRepo, payments, notifications);
const controller = new OrderController(placeOrder);

Домейн слоят никога не импортира от слоя на адаптерите. Ако видите импорт от adapters/ вътре в domain/ или ports/, нещо не е наред.

Структура на проекта

Чистата файлова структура прави границите видими:

src/
  domain/
    Order.ts
    OrderItem.ts
    Money.ts
    errors/
      EmptyOrderError.ts
      PaymentFailedError.ts
  ports/
    inbound/
      PlaceOrder.ts
      CancelOrder.ts
    outbound/
      OrderRepository.ts
      PaymentGateway.ts
      NotificationSender.ts
  application/
    PlaceOrderService.ts
    CancelOrderService.ts
  adapters/
    inbound/
      http/
        OrderController.ts
      cli/
        PlaceOrderCommand.ts
    outbound/
      persistence/
        PostgresOrderRepository.ts
      payment/
        StripePaymentGateway.ts
      notification/
        EmailNotificationSender.ts
  main.ts

Всеки разработчик в екипа може да погледне това дърво и да разбере къде да постави нов код.

Предимства при тестването

Тук хексагоналната архитектура наистина се изплаща.

Unit тестване на домейна:

test("order calculates total correctly", () => {
  const order = Order.create([
    { product: "Widget", price: Money.eur(10), quantity: 3 },
    { product: "Gadget", price: Money.eur(25), quantity: 1 },
  ]);

  expect(order.calculateTotal()).toEqual(Money.eur(55));
});

Без база данни. Без HTTP сървър. Без мок фреймуърк. Чиста логика, чисти тестове.

Тестване на случаи на употреба с фалшиви адаптери:

test("place order charges payment and saves", async () => {
  const orders = new InMemoryOrderRepository();
  const payments = new FakePaymentGateway({ alwaysSucceeds: true });
  const notifications = new FakeNotificationSender();

  const service = new PlaceOrderService(orders, payments, notifications);

  const result = await service.execute({
    items: [{ product: "Widget", price: 10, quantity: 2 }],
    paymentMethod: { type: "card", token: "tok_test" },
  });

  expect(result.status).toBe("confirmed");
  expect(orders.savedOrders).toHaveLength(1);
  expect(payments.charges).toHaveLength(1);
  expect(notifications.sent).toHaveLength(1);
});

Тествате целия случай на употреба без да докосвате реална инфраструктура. Фалшивите адаптери имплементират същите интерфейси на портове, така че са взаимозаменяеми с истинските.

Интеграционни тестове само за адаптери:

test("PostgresOrderRepository saves and retrieves", async () => {
  const repo = new PostgresOrderRepository(testDb);
  const order = Order.create([
    { product: "Widget", price: Money.eur(10), quantity: 1 },
  ]);

  await repo.save(order.confirm());
  const found = await repo.findById(order.id);

  expect(found).toEqual(order.confirm());
});

Всеки адаптер получава собствен интеграционен тест. Ако адаптерът мине теста си и случаят на употреба мине с фалшиви адаптери, цялата система работи.

Кога да използвате хексагонална архитектура

Хексагоналната архитектура добавя структура и индиректност. Това има цена. Ето кога си заслужава:

  • Дълготрайни приложения. Ако проектът ще се поддържа с години, инвестицията се изплаща бързо.
  • Сложни бизнес правила. Ако домейнът ви има нетривиална логика, която трябва да бъде тествана обстойно.
  • Множество входни точки. Ако същата логика се извиква от уеб API, CLI, опашка за съобщения и cron задача.
  • Инфраструктурата може да се промени. Ако не сте сигурни дали ще останете с текущата база данни, облачен доставчик или услуги от трети страни.
  • Множество разработчици. Ясните граници намаляват конфликтите при обединяване и ускоряват прегледа на кода.

Кога да я пропуснете

Не всеки проект се нуждае от това ниво на структура:

  • Прости CRUD приложения. Ако приложението предимно чете и записва данни с минимална бизнес логика, индиректността добавя цена без полза.
  • Прототипи и MVP-та. Скоростта е по-важна от архитектурата, когато валидирате идея. Рефакторирайте по-късно.
  • Малки скриптове и инструменти. CLI инструмент от 200 реда не се нуждае от портове и адаптери.

Правилното количество архитектура зависи от сложността на проблема. Започнете просто. Въведете хексагонални граници, когато болката от липсата им стане реална.

Чести грешки

Изтичане на инфраструктура в домейна. Ако обектът Order има @Column декоратор или toJSON() метод, инфраструктурата е изтекла. Пазете домейн обектите си чисти.

Създаване на портове, които отразяват инфраструктурата. Порт, наречен SqlQuery, обезсмисля целта. Портовете трябва да описват от какво се нуждае домейнът на домейн език, а не как работи инфраструктурата.

Прекалена абстракция. Не всичко се нуждае от порт. Ако имате помощна функция, която форматира дати, просто я използвайте директно. Запазете портовете за реални инфраструктурни граници.

Пропускане на composition root. Без ясно място, където зависимостите се свързват заедно, моделът се разпада. Всяка зависимост трябва да бъде инжектирана, а не импортирана директно.

Хексагонална срещу други модели

Може да се чудите как хексагоналната архитектура се отнася към други добре познати модели:

  • Clean Architecture (Robert C. Martin): Много подобна. Clean Architecture добавя повече слоеве (обекти, случаи на употреба, интерфейсни адаптери, фреймуърци), но основната идея е същата: зависимостите сочат навътре.
  • Onion Architecture (Jeffrey Palermo): Също много подобна. Onion Architecture използва концентрични пръстени с домейна в центъра. Терминологията се различава, но правилото за зависимости е идентично.
  • Слоеста архитектура (N-tier): Традиционният подход, при който слоевете се подреждат вертикално (представяне, бизнес, данни). Ключовата разлика: при слоестата архитектура бизнес слоят обикновено зависи от слоя за данни. При хексагоналната архитектура тази зависимост е обърната.

И трите съвременни модела (хексагонален, чист, лук) споделят един и същи фундаментален принцип. Домейнът не зависи от инфраструктурата. Те се различават основно в конвенциите за именуване и в броя слоеве, които предписват.

Как да започнете

Ако искате да приложите хексагонална архитектура към съществуващ проект, не се опитвайте да рефакторирате всичко наведнъж. Започнете с един bounded context или една функция:

  1. Идентифицирайте основната бизнес логика в тази функция. Извлечете я в чисти функции или класове без зависимости от фреймуърка.
  2. Дефинирайте портове за външните зависимости, които функцията използва. Създайте интерфейси на домейн език.
  3. Преместете съществуващия инфраструктурен код в класове адаптери, които имплементират тези интерфейси.
  4. Свържете всичко заедно в composition root, използвайки инжектиране на зависимости.
  5. Напишете тестове за домейна и случаите на употреба, използвайки фалшиви адаптери.

Повторете за следващата функция. С времето хексагоналните граници ще се разпространят естествено в кодовата база.

Заключителни мисли

Хексагоналната архитектура не е за следване на твърд шаблон. Тя е за една проста идея: защитете бизнес логиката си от хаоса на външния свят. Базите данни се променят. Фреймуърците излизат от мода. API-тата се обезценяват. Вашите бизнес правила трябва да преживеят всичко това.

Моделът работи, защото е в съответствие с начина, по който софтуерът реално се развива. Изискванията се променят постоянно. Инфраструктурата се променя периодично. Но основните правила на бизнеса ви обикновено са най-стабилната част от системата. Изградете около тази стабилност.

Ако изграждате продукт, който трябва да издържи, хексагоналната архитектура е една от най-добрите инвестиции, които можете да направите в кодовата си база.


Нуждаете се от помощ при проектиране или рефакториране на архитектурата на приложението ви? Свържете се с нас. Помагаме на екипи да изграждат софтуер, който остава поддържаем, докато расте.

architecturehexagonalports and adaptersclean codesoftware design

Нека изградим следващия ви проект.

Запазете безплатно 30-минутно обаждане. Ще обсъдим целите, сроковете и най-добрия подход. Без обвързване.

Запазете консултация hello@ryveris.com