← Blog
tutorials

Heksagonalna arhitektura | Praktican vodic za moderne aplikacije

Saznajte sto je heksagonalna arhitektura, zasto je vazna i kako je implementirati. Potpuni vodic s primjerima iz prakse za izgradnju odrzivog, testabilnog softvera.

Ryveris Team ·
Heksagonalna arhitektura | Praktican vodic za moderne aplikacije

Heksagonalna arhitektura je obrazac dizajna softvera koji izolira vasu temeljnu poslovnu logiku od vanjskih sustava poput baza podataka, API-ja i korisnickih sucelja. Ideja je jednostavna: vas domenski kod nikada ne smije ovisiti o infrastrukturi. Infrastruktura ovisi o domeni.

Obrazac je uveo Alistair Cockburn 2005. godine. Mozda cete ga cuti i pod nazivom “Ports and Adapters.” Oba naziva opisuju isti koncept.

Zasto postoji

Vecina aplikacija pocinje na isti nacin. Poslovna logika se zamrsi s upitima baze podataka, HTTP rukovateljima i pozivima usluga trecih strana. Na pocetku funkcionira. Zatim baza koda raste i dogadaju se tri stvari:

  1. Testiranje postaje bolno. Ne mozete testirati poslovna pravila bez pokretanja baze podataka ili mockanja pola frameworka.
  2. Promjena infrastrukture je rizicna. Zamjena pruzatelja placanja ili migracija s REST na GraphQL znaci prepisivanje poslovne logike.
  3. Kod je tesko razumjeti. Nitko ne moze reci gdje zavrsava domena, a pocinje infrastruktura.

Heksagonalna arhitektura rjesava sva tri problema namecuci jedno pravilo: ovisnosti uvijek pokazuju prema unutra.

Temeljni koncepti

Zamislite svoju aplikaciju kao tri koncentricna sloja.

Domena (srediste)

Ovo je vasa poslovna logika. Ciste funkcije, entiteti, vrijednosni objekti i domenski servisi. Nema nikakvih ovisnosti o frameworkima, bazama podataka ili vanjskim bibliotekama. Govori vlastitim jezikom.

Za e-commerce sustav, ovaj sloj sadrzi koncepte poput Order, Product, PricingRule i InventoryPolicy. Ti objekti ne znaju nista o SQL-u, HTTP-u ili JSON-u.

Portovi (srednji sloj)

Portovi su sucelja koja definiraju kako vanjski svijet komunicira s domenom. Oni su ugovori, ne implementacije.

Postoje dva tipa:

  • Ulazni portovi (pokretacki). Definiraju sto aplikacija moze raditi. Mislite na njih kao na slucajeve koristenja. “Naruci.” “Otkazi pretplatu.” “Generiraj fakturu.”
  • Izlazni portovi (pokretani). Definiraju sto aplikacija treba od vanjskog svijeta. “Spremi narudzbu.” “Posalji obavijest.” “Dohvati tecaj.”

Portovi su dio domenskog sloja. Napisani su na domenskom jeziku, ne na infrastrukturnom. Pisete OrderRepository, ne PostgresOrderDAO.

Adapteri (vanjski sloj)

Adapteri su konkretne implementacije koje povezuju stvarni svijet s vasim portovima.

  • Ulazni adapteri prevode vanjske zahtjeve u pozive domene. REST kontroler, GraphQL resolver, CLI naredba, potrosac reda poruka. Sve su to ulazni adapteri.
  • Izlazni adapteri implementiraju sucelja izlaznih portova. PostgreSQL repozitorij, SMTP posiljatelj emaila, Stripe payment gateway. Sve su to izlazni adapteri.

Kljucni uvid: adapteri ovise o portovima. Portovi nikada ne ovise o adapterima. To je ono sto cijeli obrazac cini funkcionalnim.

Konkretan primjer

Recimo da gradite funkcionalnost narudzbe. Evo kako se slojevi rasclanjuju.

Domena (entiteti i pravila):

// 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" });
  }
}

Ulazni port (sucelje slucaja koristenja):

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

Izlazni portovi (sto slucaj koristenja treba):

// 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>;
}

Aplikacijski servis (implementira ulazni port):

// 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);
  }
}

Adapteri (infrastruktura):

// 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;
  }
}

Primijetite kako PlaceOrderService nikada ne spominje PostgreSQL, HTTP ili bilo koji framework. Radi iskljucivo s domenskim konceptima i suceljima portova.

Pravilo ovisnosti

Ovo je najvaznije pravilo. Ako iz ovog clanka ne zapamtite nista drugo, zapamtite ovo:

Ovisnosti izvornog koda moraju uvijek pokazivati prema unutra, prema domeni.

  • Adapteri ovise o portovima. Nikada obrnuto.
  • Portovi ovise o domenskim entitetima. Nikada o adapterima.
  • Domena ne ovisi ni o cemu vanjskom.

Ovo se namece putem injekcije ovisnosti. Vas composition root (ulazna tocka aplikacije) povezuje sve zajedno:

// 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);

Domenski sloj nikada ne uvozi iz sloja adaptera. Ako vidite import iz adapters/ unutar domain/ ili ports/, nesto nije u redu.

Struktura projekta

Cista struktura mapa cini granice vidljivima:

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

Svaki programer u timu moze pogledati ovo stablo i razumjeti kamo staviti novi kod.

Prednosti testiranja

Tu heksagonalna arhitektura zaista daje rezultate.

Jedinicno testiranje domene:

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));
});

Bez baze podataka. Bez HTTP servera. Bez mocking frameworka. Cista logika, cisti testovi.

Testiranje slucajeva koristenja s laznim adapterima:

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);
});

Testirate cijeli slucaj koristenja bez dodirivanja stvarne infrastrukture. Lazni adapteri implementiraju ista sucelja portova pa su zamjenjivi sa stvarnima.

Integracijski testovi samo za adaptere:

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());
});

Svaki adapter dobiva vlastiti integracijski test. Ako adapter prolazi svoj test, a slucaj koristenja prolazi s laznim adapterima, cijeli sustav funkcionira.

Kada koristiti heksagonalnu arhitekturu

Heksagonalna arhitektura dodaje strukturu i indirekciju. To ima cijenu. Evo kada se isplati:

  • Dugozive aplikacije. Ako ce se projekt odrzavati godinama, investicija se brzo isplati.
  • Slozena poslovna pravila. Ako vasa domena ima netrivijalnu logiku koju treba temeljito testirati.
  • Visestruke ulazne tocke. Ako se ista logika poziva iz web API-ja, CLI-ja, reda poruka i cron zadatka.
  • Infrastruktura se moze promijeniti. Ako niste sigurni hocete li ostati s trenutnom bazom podataka, cloud providerom ili uslugama trecih strana.
  • Vise programera. Jasne granice smanjuju konflikte pri spajanju i ubrzavaju preglede koda.

Kada ju preskociti

Ne treba svaki projekt ovu razinu strukture:

  • Jednostavne CRUD aplikacije. Ako aplikacija uglavnom cita i pise podatke s minimalnom poslovnom logikom, indirekcija dodaje trosak bez koristi.
  • Prototipovi i MVP-ovi. Brzina je vaznija od arhitekture kada validirate ideju. Refaktorirajte kasnije.
  • Male skripte i alati. CLI alat od 200 linija ne treba portove i adaptere.

Prava kolicina arhitekture ovisi o slozenosti problema. Pocnite jednostavno. Uvedite heksagonalne granice kada bol od njihovog nedostatka postane stvarna.

Uobicajene pogreske

Propustanje infrastrukture u domenu. Ako vas Order entitet ima @Column dekorator ili toJSON() metodu, infrastruktura je procurila. Drzite svoje domeniske objekte cistima.

Stvaranje portova koji preslikavaju infrastrukturu. Port nazvan SqlQuery poraze svrhu. Portovi trebaju opisivati sto domena treba na domenskom jeziku, ne kako infrastruktura funkcionira.

Pretjerana apstrakcija. Ne treba sve port. Ako imate pomocnu funkciju koja formatira datume, samo je koristite izravno. Rezervirajte portove za stvarne infrastrukturne granice.

Preskakanje composition roota. Bez jasnog mjesta gdje se ovisnosti povezuju, obrazac se raspada. Svaka ovisnost treba biti injektirana, ne uvezena izravno.

Heksagonalna naspram drugih obrazaca

Mozda se pitate kako se heksagonalna arhitektura odnosi prema drugim poznatim obrascima:

  • Clean Architecture (Robert C. Martin): Vrlo slicna. Clean Architecture dodaje vise slojeva (entiteti, slucajevi koristenja, sucelja adaptera, frameworkovi), ali temeljna ideja je ista: ovisnosti pokazuju prema unutra.
  • Onion Architecture (Jeffrey Palermo): Takoder vrlo slicna. Onion Architecture koristi koncentricne prstenove s domenom u sredisstu. Terminologija se razlikuje, ali pravilo ovisnosti je identicno.
  • Slojevita arhitektura (N-tier): Tradicionalni pristup gdje se slojevi slazu vertikalno (prezentacija, poslovanje, podaci). Kljucna razlika: u slojevitoj arhitekturi, poslovni sloj obicno ovisi o podatkovnom sloju. U heksagonalnoj arhitekturi, ta ovisnost je invertirana.

Sva tri moderna obrasca (heksagonalna, cista, onion) dijele isti temeljni princip. Domena ne ovisi o infrastrukturi. Razlikuju se uglavnom u konvencijama imenovanja i broju slojeva koje propisuju.

Pocnite

Ako zelite primijeniti heksagonalnu arhitekturu na postojeci projekt, ne pokusavajte refaktorirati sve odjednom. Pocnite s jednim ogranicenim kontekstom ili jednom funkcionalnoscu:

  1. Identificirajte temeljnu poslovnu logiku u toj funkcionalnosti. Ekstrahirajte je u ciste funkcije ili klase bez ovisnosti o frameworku.
  2. Definirajte portove za vanjske ovisnosti koje ta funkcionalnost koristi. Stvorite sucelja na domenskom jeziku.
  3. Premjestite postojeci infrastrukturni kod u klase adaptera koje implementiraju ta sucelja.
  4. Povezite sve zajedno u composition rootu koristeci injekciju ovisnosti.
  5. Napisite testove za domenu i slucajeve koristenja koristeci lazne adaptere.

Ponovite za sljedecu funkcionalnost. S vremenom ce se heksagonalne granice prirodno prosiriti kroz bazu koda.

Zavrsne misli

Heksagonalna arhitektura nije o slijedenju krutog predloska. Radi se o jednoj jednostavnoj ideji: zastitite svoju poslovnu logiku od kaosa vanjskog svijeta. Baze podataka se mijenjaju. Frameworkovi izlaze iz mode. API-ji se povlace. Vasa poslovna pravila trebaju prezivjeti sve to.

Obrazac funkcionira jer se poklapa s nacinom na koji se softver zaista razvija. Zahtjevi se stalno mijenjaju. Infrastruktura se periodicno mijenja. Ali temeljna pravila vaseg poslovanja obicno su najstabilniji dio sustava. Gradite oko te stabilnosti.

Ako gradite proizvod koji treba trajati, heksagonalna arhitektura je jedna od najboljih investicija koju mozete napraviti u svoju bazu koda.


Trebate pomoc u dizajniranju ili refaktoriranju arhitekture vase aplikacije? Javite nam se. Pomazemo timovima graditi softver koji ostaje odrziv kako raste.

architecturehexagonalports and adaptersclean codesoftware design

Izgradimo vaš sljedeći projekt.

Zakažite besplatan 30-minutni poziv. Razgovarat ćemo o vašim ciljevima, rokovima i najboljem pristupu. Bez obveze.

Zakažite uvodni poziv hello@ryveris.com