Heksagonalna arhitektura | Praktičen vodnik za sodobne aplikacije
Spoznajte, kaj je heksagonalna arhitektura, zakaj je pomembna in kako jo implementirati. Celovit vodnik z resničnimi primeri za gradnjo vzdržljive, testabilne programske opreme.
Heksagonalna arhitektura je vzorec načrtovanja programske opreme, ki izolira vašo temeljno poslovno logiko od zunanjih sistemov kot so baze podatkov, API-ji in uporabniški vmesniki. Ideja je preprosta: vaša domenska koda ne sme nikoli biti odvisna od infrastrukture. Infrastruktura je odvisna od domene.
Vzorec je leta 2005 predstavil Alistair Cockburn. Morda ga slišite imenovanega tudi “Ports and Adapters” (vrata in adapterji). Obe imeni opisujeta isti koncept.
Zakaj obstaja
Večina aplikacij se začne enako. Poslovna logika se zaplete z poizvedbami baze podatkov, HTTP upravljavci in klici storitev tretjih oseb. Sprva deluje. Nato se zbirka kode razraste in zgodijo se tri stvari:
- Testiranje postane boleče. Poslovnih pravil ne morete testirati brez zagona baze podatkov ali zamaskovanja polovice ogrodja.
- Spreminjanje infrastrukture je tvegano. Zamenjava ponudnika plačil ali migracija z REST na GraphQL pomeni prepisovanje poslovne logike.
- Kodo je težko razumeti. Nihče ne more povedati, kje se domena konča in infrastruktura začne.
Heksagonalna arhitektura rešuje vse tri probleme z uveljavljanjem enega samega pravila: odvisnosti vedno kažejo navznoter.
Temeljni koncepti
Predstavljajte si svojo aplikacijo kot tri koncentrične plasti.
Domena (sredina)
To je vaša poslovna logika. Čiste funkcije, entitete, vrednostni objekti in domenske storitve. Nima nobenih odvisnosti od ogrodij, baz podatkov ali zunanjih knjižnic. Govori svoj lasten jezik.
Za sistem e-trgovine ta plast vsebuje koncepte kot Order, Product, PricingRule in InventoryPolicy. Ti objekti ne vedo ničesar o SQL, HTTP ali JSON.
Vrata (srednja plast)
Vrata so vmesniki, ki opredeljujejo, kako zunanji svet komunicira z domeno. So pogodbe, ne implementacije.
Obstajata dve vrsti:
- Vhodna vrata (pogonska). Opredeljujejo, kaj aplikacija zmore. Predstavljajte si jih kot primere uporabe. “Oddaj naročilo.” “Prekliči naročnino.” “Generiraj račun.”
- Izhodna vrata (poganjana). Opredeljujejo, kaj aplikacija potrebuje od zunanjega sveta. “Shrani naročilo.” “Pošlji obvestilo.” “Pridobi menjalni tečaj.”
Vrata so del domenske plasti. Napisana so v domenskem jeziku, ne v jeziku infrastrukture. Napišete OrderRepository, ne PostgresOrderDAO.
Adapterji (zunanja plast)
Adapterji so konkretne implementacije, ki povezujejo resnični svet z vašimi vrati.
- Vhodni adapterji prevajajo zunanje zahteve v domenske klice. REST krmilnik, GraphQL razreševalec, CLI ukaz, porabnik čakalne vrste sporočil. Vse to so vhodni adapterji.
- Izhodni adapterji implementirajo vmesnike izhodnih vrat. PostgreSQL repozitorij, SMTP pošiljatelj e-pošte, Stripe plačilni prehod. Vse to so izhodni adapterji.
Ključni uvid: adapterji so odvisni od vrat. Vrata niso nikoli odvisna od adapterjev. To je tisto, kar naredi celoten vzorec delujoč.
Konkreten primer
Recimo, da gradite funkcionalnost oddaje naročila. Tukaj je razčlenitev plasti.
Domena (entitete in 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" });
}
}
Vhodna vrata (vmesnik primera uporabe):
// ports/inbound/PlaceOrder.ts
interface PlaceOrder {
execute(command: PlaceOrderCommand): Promise<OrderConfirmation>;
}
Izhodna vrata (kaj primer uporabe potrebuje):
// 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>;
}
Aplikacijska storitev (implementira vhodna vrata):
// 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);
}
}
Adapterji (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;
}
}
Opazite, da PlaceOrderService nikoli ne omenja PostgreSQL, HTTP ali kateregakoli ogrodja. Deluje izključno z domenskimi koncepti in vmesniki vrat.
Pravilo odvisnosti
To je najpomembnejše pravilo. Če si ne zapomnite ničesar drugega iz tega članka, si zapomnite tole:
Odvisnosti izvorne kode morajo vedno kazati navznoter, proti domeni.
- Adapterji so odvisni od vrat. Nikoli obratno.
- Vrata so odvisna od domenskih entitet. Nikoli od adapterjev.
- Domena ni odvisna od ničesar zunanjega.
To se uveljavlja z vbrizgavanjem odvisnosti. Vaš kompozicijski koren (vstopna točka aplikacije) poveže vse skupaj:
// 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);
Domenska plast nikoli ne uvozi iz plasti adapterjev. Če vidite uvoz iz adapters/ znotraj domain/ ali ports/, je nekaj narobe.
Struktura projekta
Čista struktura map naredi meje vidne:
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
Vsak razvijalec v ekipi lahko pogleda to drevo in razume, kam umestiti novo kodo.
Koristi testiranja
Tukaj se heksagonalna arhitektura zares izplača.
Testiranje enot 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));
});
Brez baze podatkov. Brez HTTP strežnika. Brez ogrodja za zamaskovanje. Čista logika, čisti testi.
Testiranje primerov uporabe z lažnimi adapterji:
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);
});
Celoten primer uporabe testirate brez dotikanja resnične infrastrukture. Lažni adapterji implementirajo iste vmesnike vrat, zato so zamenljivi z resničnimi.
Integracijski testi le za adapterje:
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());
});
Vsak adapter dobi svoj integracijski test. Če adapter prestane svoj test in primer uporabe prestane z lažnimi adapterji, celoten sistem deluje.
Kdaj uporabiti heksagonalno arhitekturo
Heksagonalna arhitektura dodaja strukturo in posrednost. To ima ceno. Tukaj je, kdaj se izplača:
- Dolgoživujoče aplikacije. Če bo projekt vzdrževan leta, se investicija hitro povrne.
- Zapletena poslovna pravila. Če ima vaša domena netrivialno logiko, ki jo je treba temeljito testirati.
- Več vstopnih točk. Če je ista logika klicana iz spletnega API-ja, CLI-ja, čakalne vrste sporočil in cron opravila.
- Infrastruktura se lahko spremeni. Če niste prepričani, ali boste ostali pri trenutni bazi podatkov, oblačnem ponudniku ali storitvah tretjih oseb.
- Več razvijalcev. Jasne meje zmanjšajo konflikte pri združevanju in naredijo preglede kode hitrejše.
Kdaj jo preskočiti
Vsak projekt ne potrebuje te ravni strukture:
- Preproste CRUD aplikacije. Če aplikacija večinoma bere in piše podatke z minimalno poslovno logiko, posrednost dodaja stroške brez koristi.
- Prototipi in MVP-ji. Hitrost je pomembnejša od arhitekture, ko validirate idejo. Refaktorirajte pozneje.
- Majhne skripte in orodja. 200-vrstično CLI orodje ne potrebuje vrat in adapterjev.
Prava količina arhitekture je odvisna od zapletenosti problema. Začnite preprosto. Uvedite heksagonalne meje, ko bolečina brez njih postane resnična.
Pogoste napake
Uhajanje infrastrukture v domeno. Če ima vaša entiteta Order dekorator @Column ali metodo toJSON(), je infrastruktura prodrla. Ohranite svoje domenske objekte čiste.
Ustvarjanje vrat, ki zrcalijo infrastrukturo. Vrata z imenom SqlQuery porazijo namen. Vrata morajo opisovati, kaj domena potrebuje v domenskem jeziku, ne kako infrastruktura deluje.
Pretirano abstrahiranje. Vse ne potrebuje vrat. Če imate pomožno funkcijo, ki formatira datume, jo preprosto uporabite neposredno. Vrata rezervirajte za dejanske infrastrukturne meje.
Izpustitev kompozicijskega korena. Brez jasnega mesta, kjer se odvisnosti povežejo, se vzorec podre. Vsako odvisnost je treba vbrizgati, ne neposredno uvoziti.
Heksagonalna v primerjavi z drugimi vzorci
Morda se sprašujete, kako se heksagonalna arhitektura nanaša na druge znane vzorce:
- Clean Architecture (Robert C. Martin): Zelo podobna. Clean Architecture dodaja več plasti (entitete, primeri uporabe, vmesniški adapterji, ogrodja), vendar je temeljna ideja enaka: odvisnosti kažejo navznoter.
- Onion Architecture (Jeffrey Palermo): Prav tako zelo podobna. Onion Architecture uporablja koncentrične obroče z domeno v sredini. Terminologija se razlikuje, pravilo odvisnosti pa je identično.
- Plastna arhitektura (N-tier): Tradicionalni pristop, kjer se plasti zlagajo navpično (predstavitev, poslovanje, podatki). Ključna razlika: pri plastni arhitekturi je poslovna plast običajno odvisna od podatkovne plasti. Pri heksagonalni arhitekturi je ta odvisnost obrnjena.
Vsi trije sodobni vzorci (heksagonalni, čisti, čebulni) delijo isto temeljno načelo. Domena ni odvisna od infrastrukture. Razlikujejo se večinoma v konvencijah poimenovanja in številu plasti, ki jih predpisujejo.
Kako začeti
Če želite uporabiti heksagonalno arhitekturo na obstoječem projektu, ne poskušajte refaktorirati vsega naenkrat. Začnite z enim omejenim kontekstom ali eno funkcionalnostjo:
- Identificirajte temeljno poslovno logiko v tej funkcionalnosti. Ekstrahirajte jo v čiste funkcije ali razrede brez odvisnosti od ogrodij.
- Opredelite vrata za zunanje odvisnosti, ki jih ta funkcionalnost uporablja. Ustvarite vmesnike v domenskem jeziku.
- Premaknite obstoječo infrastrukturno kodo v adapterske razrede, ki implementirajo te vmesnike.
- Povežite vse skupaj v kompozicijskem korenu z vbrizgavanjem odvisnosti.
- Napišite teste za domeno in primere uporabe z lažnimi adapterji.
Ponovite za naslednjo funkcionalnost. Sčasoma se bodo heksagonalne meje naravno razširile po zbirki kode.
Zaključne misli
Heksagonalna arhitektura ne pomeni sledenja togemu predlogku. Gre za eno preprosto idejo: zaščitite svojo poslovno logiko pred kaosom zunanjega sveta. Baze podatkov se spreminjajo. Ogrodja gredo iz mode. API-ji so opuščeni. Vaša poslovna pravila morajo vse to preživeti.
Vzorec deluje, ker se ujema s tem, kako se programska oprema dejansko razvija. Zahteve se nenehno spreminjajo. Infrastruktura se občasno spreminja. Toda temeljna pravila vašega poslovanja so ponavadi najstabilnejši del sistema. Gradite okoli te stabilnosti.
Če gradite izdelek, ki mora trajati, je heksagonalna arhitektura ena najboljših investicij, ki jih lahko naredite v svojo zbirko kode.
Potrebujete pomoč pri oblikovanju ali refaktoriranju arhitekture vaše aplikacije? Stopite v stik. Ekipam pomagamo graditi programsko opremo, ki ostane vzdržljiva, ko raste.