Hopp til hovedinnhold

Klassiske lagdelt arkitektur er mye brukt i backends. Her sorterer vi klasser pent og pyntelig etter ansvar, og sier oss fornøyd. Samtidig kan dette føre til problemer lenger ned i løypa. Her skal vi se på Featured Ports and Adapter, og litt rundt hvilke problemer dette kan løse for oss!

Når en skal strukturere koden i en applikasjon, så kan en fort gå for den klassiske lagdelte arkitekturen. Her sorterer vi klasser pent og pyntelig etter ansvar, og sier oss fornøyd. Samtidig kan dette føre til noen fallgruver hvis vi ikke har en tydelig strategi. Endepunkter kan sende databaseklasser rett ut til konsumenter. Domenelogikken forholder seg til databaseklasser som er tett koblet til databasetabellen. Vi bruker klasser for alle avhengigheter, så må vi bruke et mocking bibliotek.

Og hva har vi oftest - en endring relatert til en feature, eller relatert til hvordan vi snakker med databasen på tvers av alle klasser?

Ports and adapters - enkel og kraftig

En rendyrking av lagdelingsarkitekturen er Ports and Adapters, men med noen viktige forskjeller som hever kvaliteten på kodebasen.
Kort oppsummert har vi disse to reglene;

  1. Kommunikasjon mellom lagene skal gå vi ports(interfaces)
  2. Applikasjonskjernen/domene skal isoleres fra eksterne grensesnitt

Da får vi at eksterne lag forholder seg til en port, enten endepunkter som kaller en port eller databasen som implementerer en port. Disse vil også være ansvarlige for å mappe til/fra applikasjonskoden(domenet) vårt.

Ports and Adapters er det "nye" navnet for Hexagonal Architecture, utviklet av Alistair Cockburn. Her skriver jeg "nye" fordi navnet Ports and Adapters ble foreslått av Alistair Cockburn i 2005. Onion Architecture av Jeffrey Palermo og Clean Architecture av Robert Martin er senere og mer komplekse varianter.

For å gi et mer konkret eksempel:

app
├── database
| ├── ProduktEntity.kt
│ └── PostgresProduktAdapter.kt
├── core
│ ├── FilPort.kt
│ ├── types
│ │ └── Produkt.kt
│ ├── ProduktCorePort.kt
│ ├── ProduktPort.kt
│ └── ProduktService.kt
├── endpoints
│ │── ProduktDto.kt
│ └── ProduktEndpoint.kt
└── s3
  ├── S3FilAdapter.kt
  └── S3Ref.kt

Her er ProduktPort et interface som domenet bruker for å lagre og hente data. Interfacet tar inn et Produkt, og transformerer det til et objekt som passer databasen - eksempelvis en ProduktEntity annotert med JPA-annotasjoner. I motsatt ende kan endepunktkoden ta imot et Produkt, og mappe det om til en ProduktDto. Det vil si at ting som dato-typer blir formatert på en gitt måte, eller at UUIDs skal gjøres om til en ikke-meningsbærende string. ProduktService eksponerer funksjonaliteten til endepunktene via ProduktCorePort.

Fordelen med denne arkitekturen er at du får tydelige ansvarsforhold - de ytre delene av applikasjonen er i stor grad ansvarlig for mapping fra/til domeneobjekter, og eventuelle naturlige områder som hører til. Dette kan være ting som autentisering og autorisering, logging, og enkel feilhåndtering.

Det blir også klart hvor en bør fokusere testingen - for de ytre lagene vil man sjekke at mapping er korrekt, og eventuell logikk rundt feilhåndtering eller autentisering. For applikasjonskjernen bør man fylle på med enhetstester som validerer logikken, og det er typisk her du vil ha flest tester.

En bakdel med Ports and Adapters er som med lagdelt arkitektur - du kan få mapper med mange klasser, som har veldig like navn. Hvor raskt ser du forskjell på "ProduktService", "ProsjektService" og "ProsessService"?

Featured arkitektur for naturlig oppdeling

Feature-basert arkitektur løser litt rundt sistnevnte problem ved å gruppere kode etter vertikal feature den løser. Så der hvor Ports and Adapters deler opp langs de horisontale lagene i en applikasjon, så deler featured opp etter vertikale skillelinjer. Dermed får vi en mappe som heter "Produkt", og relaterte klasser under den, som "ProduktEndpoint", "ProduktService" og "ProduktDatabase". Så vi finner naturlige grupperinger i domenet, og samler de i en mappe, for eksempel slik;

produkt
├── dto
│ ├── ProduktDto.kt
│ └── S3Ref.kt
├── ProduktDatabase.kt
├── ProduktEndpoint.kt
├── ProduktService.kt
├── S3ProduktAdapter.kt
└── types
  └── Produkt.kt

Tanken bak dette er at du får en naturlig oppdeling av koden som følger de vanligste endringsmønstrene. For hvor ofte endrer du endepunkt og litt logikk knyttet til en feature, sammenlignet med, si, hvordan du kommuniserer med databasen på tvers av alle klasser?

Men også her har vi noen uløste problemer. Arkitekturen sier ingenting om separering av lagene - altså kan du få lekkasje mellom lagene, slik at applikasjonen i ytterste fall lekker database-typer helt ut til konsumenten av endepunktene. Og der hvor vi tidligere hadde samme lag-navn på klassene, har vi nå mapper med flere klasser med samme feature-navn.

Featured Ports and Adapters

Det er her hybriden kommer inn; Featured Ports and Adapters.

Kort sagt organiserer du koden etter features, men intern i features organiserer du etter ports and adapters. For å gi et konkret eksempel;

produkt
├── domain
│ ├── FilPort.kt
│ ├── types
│ │ └── Produkt.kt
│ ├── ProduktCorePort.kt
│ ├── ProduktPort.kt
│ └── ProduktService.kt
├── inbound
│ └── http
│   ├── dto
│   │ └── ProduktDto.kt
│   └── ProduktEndpoint.kt
└── outbound
  ├── db
  │ ├── entities
  │ │ └── ProduktEntity.kt
  │ └── PostgresProduktAdapter.kt
  └── s3
    ├── S3FilAdapter.kt
    └── types
      └── S3Ref.kt

Her har vi en vertikal gruppering for featuren Produkt, hvor all Produkt-spesifikk kode er implementert. Vi har tydelige skillelinjer mellom lagene fra Ports and Adapters, samtidig som vi får den logiske grupperingen av kode som berører en gitt feature. Det blir også lett å se hvor vi skal legge ny funksjonalitet. Nytt kall mot en ekstern tjeneste? Mulig ny port i domain, ny klasse i Outbound, og så må vi ta en vurdering på om dette innebærer en ny type som skal inn i domenet. Skal vi støtte grahpql for konsumentene? Inbound! Ny regel som styrer verdien til et gitt felt? Domain. Med minst en test, naturligvis.

Vi får altså det beste fra begge verdener, og tydelig organisert kode. Vi kan raskt finne ut hvor vi skal gjøre en endring når det kommer et nytt krav for en gitt feature. Må vi feilsøke hvorfor et nyopprettet produkt har et felt med verdien 0 i stedet for null, så finner vi raskt all koden som håndterer denne featuren.

Så kan vi også tillate oss selv å være litt pragmatiske. Eksempelvis kan et endepunkt kalle ProduktPort direkte, hvis det bare er oppdatering av et felt - og det er helt greit! Det er fordi vi går via domenelaget vårt, slik at det er et tydelig skille mellom databasen og endepunktet. Vi får heller ingen avhengighet mellom lagene - endepunktet er avhengig av en port definert i domenelaget, og databasen implementerer en port definert i domenelaget. Og hvis teamet føler at det er greit å skille klassene i samme mappe, dropp domain/inbound/outbound grupperingen av klassene.

Denne organiseringa har funka veldig godt på nåværende oppdrag, og på tidligere oppdrag. For de fleste endringer får vi raskt en god oversikt over hva som bør endres, og de fleste endringer følger feature-mønsteret. Håper dette hjelper dere også!

Liker du innlegget?

Del gjerne med kollegaer og venner