Heksagonaaliarkkitehtuuri | Käytännön opas nykyaikaisiin sovelluksiin
Opi, mitä heksagonaaliarkkitehtuuri on, miksi sillä on merkitystä ja miten se toteutetaan. Täydellinen opas todellisilla esimerkeillä ylläpidettävän, testattavan ohjelmiston rakentamiseen.
Heksagonaaliarkkitehtuuri on ohjelmistosuunnittelumalli, joka eristää liiketoimintalogiikkasi ulkoisista järjestelmistä kuten tietokannoista, API:sta ja käyttöliittymistä. Idea on yksinkertainen: domain-koodisi ei saisi koskaan riippua infrastruktuurista. Infrastruktuuri riippuu domainista.
Malli esiteltiin Alistair Cockburnin toimesta vuonna 2005. Saatat kuulla siitä myös nimellä “Ports and Adapters”. Molemmat nimet kuvaavat samaa konseptia.
Miksi se on olemassa
Useimmat sovellukset alkavat samalla tavalla. Liiketoimintalogiikka sotkeutuu tietokantakyselyihin, HTTP-käsittelijöihin ja kolmannen osapuolen palvelukutsuihin. Se toimii aluksi. Sitten koodikanta kasvaa, ja kolme asiaa tapahtuu:
- Testaaminen muuttuu tuskalliseksi. Et voi testata liiketoimintasääntöjä käynnistämättä tietokantaa tai mockaamatta puolta viitekehyksestä.
- Infrastruktuurin muuttaminen on riskialtista. Maksunvälittäjän vaihtaminen tai REST:stä GraphQL:ään siirtyminen tarkoittaa liiketoimintalogiikan uudelleenkirjoittamista.
- Koodista on vaikea saada selvää. Kukaan ei pysty kertomaan, missä domain loppuu ja infrastruktuuri alkaa.
Heksagonaaliarkkitehtuuri ratkaisee kaikki kolme ongelmaa pakottamalla yhden säännön: riippuvuudet osoittavat aina sisäänpäin.
Ydinkonseptit
Ajattele sovellustasi kolmena sisäkkäisenä kerroksena.
Domain (keskusta)
Tämä on liiketoimintalogiikkasi. Puhtaita funktioita, entiteettejä, arvo-objekteja ja domain-palveluita. Sillä on nolla riippuvuutta viitekehyksiin, tietokantoihin tai ulkoisiin kirjastoihin. Se puhuu omaa kieltään.
Verkkokauppajärjestelmässä tämä kerros sisältää konseptit kuten Order, Product, PricingRule ja InventoryPolicy. Nämä objektit eivät tiedä mitään SQL:stä, HTTP:stä tai JSON:sta.
Portit (keskikerros)
Portit ovat rajapintoja, jotka määrittelevät, miten ulkomaailma vuorovaikuttaa domainin kanssa. Ne ovat sopimuksia, eivät toteutuksia.
On kahta tyyppiä:
- Sisääntulevat portit (ohjaavat). Määrittelevät, mitä sovellus voi tehdä. Ajattele niitä käyttötapauksina. “Tee tilaus.” “Peru tilaus.” “Luo lasku.”
- Lähtevät portit (ohjatut). Määrittelevät, mitä sovellus tarvitsee ulkomaailmasta. “Tallenna tilaus.” “Lähetä ilmoitus.” “Hae valuuttakurssi.”
Portit ovat osa domain-kerrosta. Ne kirjoitetaan domain-kielellä, ei infrastruktuurikielellä. Kirjoitat OrderRepository, et PostgresOrderDAO.
Adapterit (ulkokerros)
Adapterit ovat konkreettisia toteutuksia, jotka yhdistävät todellisen maailman portteihisi.
- Sisääntulevat adapterit kääntävät ulkoiset pyynnöt domain-kutsuiksi. REST-kontrolleri, GraphQL-resolveri, CLI-komento, viestijono-kuluttaja. Kaikki nämä ovat sisääntulevia adaptereita.
- Lähtevät adapterit toteuttavat lähtevien porttien rajapinnat. PostgreSQL-repositorio, SMTP-sähköpostilähetin, Stripe-maksuportti. Kaikki nämä ovat lähteviä adaptereita.
Ydinajatus: adapterit riippuvat porteista. Portit eivät koskaan riipu adaptereista. Tämä tekee koko mallin toimivaksi.
Konkreettinen esimerkki
Oletetaan, että rakennat tilauksen tekemistoiminnon. Tässä miten kerrokset jakautuvat.
Domain (entiteetit ja säännöt):
// 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" });
}
}
Sisääntuleva portti (käyttötapausrajapinta):
// ports/inbound/PlaceOrder.ts
interface PlaceOrder {
execute(command: PlaceOrderCommand): Promise<OrderConfirmation>;
}
Lähtevät portit (mitä käyttötapaus tarvitsee):
// 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>;
}
Sovelluspalvelu (toteuttaa sisääntulevan portin):
// 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);
}
}
Adapterit (infrastruktuuri):
// 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;
}
}
Huomaa, miten PlaceOrderService ei koskaan mainitse PostgreSQL:ää, HTTP:tä tai mitään viitekehystä. Se toimii puhtaasti domain-konseptien ja porttirajapintojen kanssa.
Riippuvuussääntö
Tämä on tärkein sääntö. Jos et saa tästä artikkelista muuta, ota tämä:
Lähdekoodiriippuvuuksien on aina osoitettava sisäänpäin, kohti domainia.
- Adapterit riippuvat porteista. Ei koskaan toisinpäin.
- Portit riippuvat domain-entiteeteistä. Eivät koskaan adaptereista.
- Domain ei riipu mistään ulkoisesta.
Tämä pakotetaan riippuvuusinjektiolla. Sovelluksesi koostamispiste (composition root, sovelluksen aloituspiste) kytkee kaiken yhteen:
// 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);
Domain-kerros ei koskaan tuo mitään adapterit-kerroksesta. Jos näet tuonnin adapters/-kansiosta domain/- tai ports/-kansion sisällä, jokin on vialla.
Projektirakenne
Selkeä kansiorakenne tekee rajat näkyviksi:
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
Jokainen tiimin kehittäjä voi katsoa tätä puuta ja ymmärtää, minne uusi koodi kuuluu.
Testauksen hyödyt
Tässä heksagonaaliarkkitehtuuri todella kannattaa.
Domain-yksikkötestaus:
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));
});
Ei tietokantaa. Ei HTTP-palvelinta. Ei mockaus-viitekehystä. Puhdasta logiikkaa, puhtaita testejä.
Käyttötapausten testaus valeadaptereilla:
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);
});
Testaat koko käyttötapauksen koskematta todelliseen infrastruktuuriin. Valeadapterit toteuttavat samat porttirajapinnat, joten ne ovat vaihdettavissa todellisten kanssa.
Integraatiotestit vain adaptereille:
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());
});
Jokainen adapteri saa oman integraatiotestinsä. Jos adapteri läpäisee testinsä ja käyttötapaus läpäisee valeadaptereilla, koko järjestelmä toimii.
Milloin käyttää heksagonaaliarkkitehtuuria
Heksagonaaliarkkitehtuuri lisää rakennetta ja epäsuoruutta. Siitä on kustannus. Tässä milloin se kannattaa:
- Pitkäikäiset sovellukset. Jos projektia ylläpidetään vuosia, investointi maksaa itsensä takaisin nopeasti.
- Monimutkaiset liiketoimintasäännöt. Jos domainissasi on merkittävää logiikkaa, joka pitää testata perusteellisesti.
- Useita sisääntulopisteitä. Jos samaa logiikkaa kutsutaan web-API:sta, CLI:stä, viestijonosta ja ajastetusta tehtävästä.
- Infrastruktuuri saattaa muuttua. Jos et ole varma, pysytkö nykyisessä tietokannassasi, pilvipalveluntarjoajassasi tai kolmannen osapuolen palveluissasi.
- Useita kehittäjiä. Selkeät rajat vähentävät yhteentörmäyksiä ja nopeuttavat koodikatselmoinnit.
Milloin ohittaa se
Jokainen projekti ei tarvitse tätä rakennetta:
- Yksinkertaiset CRUD-sovellukset. Jos sovellus lukee ja kirjoittaa dataa pienellä liiketoimintalogiikalla, epäsuoruus lisää kustannuksia ilman hyötyä.
- Prototyypit ja MVP:t. Nopeus on tärkeämpää kuin arkkitehtuuri ideaa validoitaessa. Refaktoroi myöhemmin.
- Pienet skriptit ja työkalut. 200-rivinen CLI-työkalu ei tarvitse portteja ja adaptereita.
Oikea arkkitehtuurin määrä riippuu ongelman monimutkaisuudesta. Aloita yksinkertaisesti. Ota heksagonaaliset rajat käyttöön, kun niiden puuttumisen tuska tulee todelliseksi.
Yleisimmät virheet
Infrastruktuurin vuotaminen domainiin. Jos Order-entiteetilläsi on @Column-dekoraattori tai toJSON()-metodi, infrastruktuuri on vuotanut sisään. Pidä domain-objektisi puhtaina.
Porttien luominen infrastruktuurin peiliksi. Portti nimeltä SqlQuery tekee tarkoituksen tyhjäksi. Porttien pitäisi kuvata domain-kielellä, mitä domain tarvitsee, ei miten infrastruktuuri toimii.
Yliabstrahointi. Kaikki ei tarvitse porttia. Jos sinulla on apufunktio, joka muotoilee päivämääriä, käytä sitä suoraan. Varaa portit todellisiin infrastruktuurin rajoille.
Koostamispisteen ohittaminen. Ilman selkeää paikkaa, jossa riippuvuudet kytketään yhteen, malli hajoaa. Jokainen riippuvuus pitäisi injektoida, ei tuoda suoraan.
Heksagonaalinen vs. muut mallit
Saatat miettiä, miten heksagonaaliarkkitehtuuri liittyy muihin tunnettuihin malleihin:
- Clean Architecture (Robert C. Martin): Hyvin samankaltainen. Clean Architecture lisää useampia kerroksia (entiteetit, käyttötapaukset, rajapinta-adapterit, viitekehykset), mutta ydinajatus on sama: riippuvuudet osoittavat sisäänpäin.
- Onion Architecture (Jeffrey Palermo): Myös hyvin samankaltainen. Onion Architecture käyttää sisäkkäisiä renkaita domainin ollessa keskellä. Terminologia eroaa, mutta riippuvuussääntö on identtinen.
- Kerrosarkkitehtuuri (N-tier): Perinteinen lähestymistapa, jossa kerrokset pinotaan pystysuoraan (esitys, liiketoiminta, data). Ratkaiseva ero: kerrosarkkitehtuurissa liiketoimintakerros tyypillisesti riippuu datakerroksesta. Heksagonaaliarkkitehtuurissa tuo riippuvuus on käännetty.
Kaikki kolme modernia mallia (heksagonaalinen, clean, onion) jakavat saman perusperiaatteen. Domain ei riipu infrastruktuurista. Ne eroavat pääasiassa nimeämiskäytännöissä ja kerrsten määrässä.
Alkuun pääseminen
Jos haluat soveltaa heksagonaaliarkkitehtuuria olemassa olevaan projektiin, älä yritä refaktoroida kaikkea kerralla. Aloita yhdellä rajatulla kontekstilla tai yhdellä ominaisuudella:
- Tunnista ydinliiketoimintalogiikka tuossa ominaisuudessa. Purkaa se puhtaiksi funktioiksi tai luokiksi ilman viitekehysriippuvuuksia.
- Määrittele portit ulkoisille riippuvuuksille, joita ominaisuus käyttää. Luo rajapinnat domain-kielellä.
- Siirrä olemassa oleva infrastruktuurikoodi adapteriluokkiin, jotka toteuttavat nuo rajapinnat.
- Kytke kaikki yhteen koostamispisteessä riippuvuusinjektiolla.
- Kirjoita testit domainille ja käyttötapauksille valeadaptereita käyttäen.
Toista seuraavalle ominaisuudelle. Ajan myötä heksagonaaliset rajat leviävät koodikannan läpi luonnollisesti.
Lopputoteamus
Heksagonaaliarkkitehtuuri ei ole jäykän mallin noudattamista. Se on yksi yksinkertainen idea: suojaa liiketoimintalogiikkasi ulkomaailman kaaokselta. Tietokannat vaihtuvat. Viitekehykset jäävät pois muodista. API:t vanhennetaan. Liiketoimintasääntöjesi pitäisi selvitä kaikesta tuosta.
Malli toimii, koska se vastaa tapaa, jolla ohjelmisto todella kehittyy. Vaatimukset muuttuvat jatkuvasti. Infrastruktuuri muuttuu ajoittain. Mutta liiketoimintasi ydinsäännöt ovat yleensä järjestelmän vakain osa. Rakenna tuon vakauden ympärille.
Jos rakennat tuotetta, jonka pitää kestää, heksagonaaliarkkitehtuuri on yksi parhaista investoinneista, joita voit tehdä koodikantaasi.
Tarvitsetko apua sovelluksesi arkkitehtuurin suunnittelussa tai refaktoroinnissa? Ota yhteyttä. Autamme tiimejä rakentamaan ohjelmistoa, joka pysyy ylläpidettävänä kasvaessaan.