Informatietechnologie
nog eens goed uitgelegd
softwareontwikkeling | ontwerp | laatst bijgewerkt op 2022-11-04

lagenarchitectuur

Een opbouw voor een informatiesysteem bestaande uit meerdere op elkaar gestapelde lagen, elk met hun eigen verantwoordelijkheid.

Een lagenarchitectuur ook wel N-tier-architectuur genoemd, is het idee dat een informatiesysteem bestaat uit meerdere op elkaar gestapelde lagen, elk met hun eigen verantwoordelijkheid. Het resultaat van het stapelen van lagen lijkt een beetje op een taart, waarbij de bovenste laag vrijwel altijd de presentatielaag is waarmee de gebruiker (mens) communiceert, en de onderste laag de persistentie van gegevens afhandelt. Bij dit laatste kun je onder anderen denken aan het wegschrijven van bestanden op disk of het opslaan van gegevens in een database. Hoeveel lagen er dan tussen deze twee uitersten zitten is een beetje afhankelijk van het soort informatiesysteem dat ontwikkeld wordt. Wanneer dit een losse, opzichzelfstaande applicatie is, dan is het zeer gebruikelijk om hier nog 1 laag tussen te stoppen, welke de applicatielaag of (business)logicalaag genoemd wordt. Deze laag handelt alle functionaliteit van de applicatie af en delegeert het persisteren aan de persistentielaag eronder, en het presenteren aan de presentatielaag erboven. Wanneer het een gedistribueerd informatiesysteem betreft, kan het zijn dat er meer dan 1 laag tussen zit omdat er dan ook nog verschillende verantwoordelijkheden zijn die gaan over de communicatie tussen de verschillende applicaties in het systeem. Vandaar dat je ook vaak 3-Tier of drielagenarchitectuur hoort, wanneer het over een enkele applicatie gaat.

In dit artikel ga ik uit van een lagenarchitectuur met 3 lagen in een enkele opzichzelfstaande applicatie, dus niet een gedistribueerd systeem met meerdere gekoppelde applicaties.

Na het klassendiagram

Het idee van de lagenarchitectuur is eigenlijk relatief eenvoudig en de vraag die het beantwoordt ontstaat meestal direct na het maken van een klassendiagram voor de applicatie, namelijk: Hoe persisteert deze applicatie gegevens en hoe presenteert deze applicatie informatie aan de gebruiker? En het antwoord daarop is misschien nog wel eenvoudiger, want dat is "Dat doet deze applicatie niet zelf, maar dat delegeert het naar een ander onderdeel (laag).". Het wordt echter nu wel belangrijk dat je duidelijk kunt aangeven waar de grenzen van de verantwoordelijkheid liggen: Welk stuk doe de applicatie zelf, en welk stuk wordt gedelegeerd? Om dit eenduidig aan te geven en te implementeren wordt gebruik gemaakt van packages.

In dit artikel maak ik gebruik van de term package zoals de definitie van package in UML, te weten een gegroepeerde set klassen die elkaar kunnen benaderen, maar waarvan uitsluitend de publieke onderdelen benaderbaar zijn voor klassen uit een andere package. In sommige programmeertalen heeft dit een andere naam, volgens mij heet het in Java een Module en in C# een Namespace, kijk dit voor jouw programmeertaal even goed na.

Voorbeeld

Hieronder een voorbeeld van een lagenarchitectuur met daarbij de afhankelijkheden aangegeven. Let op dat er meerdere manieren zijn om een lagenstructuur correct te maken die wellicht anders zijn dan deze. Het gaat erom dat je kunt onderbouwen waarom je voor een bepaalde architectuur kiest. Ik kies voor onderstaande architectuur omdat ik weet dat mijn applicatie potentieel door verschillende presentatielagen benaderd gaat worden, welke elk dezelfde use-cases hebben. Ik heb daarom een afhankelijkheid gemaakt van presentatielaag naar applicatielaag zodat ik in de applicatielaag op een centrale plek kan bepalen wat de use-cases zijn, en deze dan op gelijke wijze voor alle presentatielagen beschikbaar is. Daarnaast heb ik een afhankelijkheid gemaakt van persistentielaag naar applicatielaag, door depency inversion toe te passen. Dit, omdat ik de persistantielaag uitwisselbaar wil hebben zodat ik deze door een mock kan vervangen in mijn unit-testen.

full fig1: Een lagenarchitectuur met drie lagen, te weten een presentatielaag, een applicatielaag en een persistentielaag en de bijbehorende afhankelijkheden

Het koppelen van de presentatielaag

Bij het koppelen van de presentatielaag dien je je af te vragen welke laag bepaalt welke use-cases er in de applicatie aanwezig zijn. Er is best een pleidooi te maken voor de mening die stelt dat de presentatielaag dat doet omdat deze in dienst staat van de gebruiker (mens) en dus kan bepalen wat de gebruiker wil. Ik ben het daar in principe mee eens, maar dit levert in veel gevallen onhandigheid op in het bepalen van de afhankelijkheden tussen de packages omdat je dan een afhankelijkheid van applicatielaag naar presentatielaag zou maken omdat de applicatielaag moet weten welke use-cases er gevraagd worden door de presentatielaag. Dit is niet handig, omdat nu de presentatielaag niet meer vervangen kan worden door een andere presentatielaag, de applicatielaag is namelijk hard verbonden aan specifiek die ene presentatielaag. Ik ben daarom een voorstander van het laten bepalen welke use-cases er zijn door de applicatielaag. Hiermee is de afhankelijkheid van presentatielaag naar applicatielaag omdat de presentatielaag nu graag wil weten welke use-cases de applicatielaag aanbiedt. Het is in een dergelijk geval handig als de applicatielaag uitsluitend die operaties publiek aanbiedt die een use-case vertegenwoordigen, en alle andere operaties niet publiekelijk houdt. Eventueel kun je overwegen om een façadeklasse te maken zodat het voor de presentatielaag gemakkelijker is om de use-cases die aangeboden worden aan te spreken.

full fig.2: Dezelfde lagenarchitectuur maar nu met details over de koppeling tussen de presentatielaag en de applicatielaag.

Het koppelen van de persistentielaag

Bij het koppelen van de persistentielaag is de gedachte net andersom dan bij het koppelen van de presentatielaag. De persistentielaag staat naar mijn mening namelijk geheel in dienst van de applicatielaag en heeft zelf niets te bepalen. Het enige dat die persistentielaag moet doen, is invulling geven aan de taken die de applicatielaag naar de persistentielaag delegeert. Het is dus aan de applicatielaag om te bepalen welke taken dat zijn. De gemakkelijkste manier is om in de applicatielaag een interface op te nemen waarin deze taken gedefinieerd worden, en het is dan aan de persistentielaag om deze interface te implementeren. Zodoende zijn de lagen ontkoppelt en kan ik de werkelijke persistentielaag eventueel vervangen door een mock, zolang beiden aan dezelfde interface voldoen. Het voordeel van het plaatsen van de interface in de applicatielaag is dat ik bij het definiëren van de operaties in de interface gebruik kan maken van de klassen die in de applicatielaag gedefinieerd worden. Bijvoorbeeld in een applicatie voor een magazijnbeheer zou ik me voor kunnen stellen dat deze interface een operatie heeft als addProduct(p : Product) waarmee een product toegevoegd kan worden aan de persistentie, maar tevens waarin de definitie van het product gespecificeerd wordt in de klasse Product welke zich in dezelfde applicatielaag bevindt. Wanneer de interface in een andere laag dan de applicatielaag staat wordt het mogelijk ingewikkelder om de operatie op deze manier te definiëren omdat de klasse Product dan misschien niet beschikbaar is voor de laag waar de interface in staat.

full fig.3: Wederom dezelfde lagenarchitectuur maar nu met details over de koppeling tussen de persistentielaag en de applicatielaag.

In dit voorbeeld is het voor de eenvoud van de afbeelding zo dat de façadeklasse App een relatie met de interface DataProvider vasthoudt, maar in werkelijkheid zijn het waarschijnlijk de klassen die achter de façade leven die deze relatie vasthouden en geeft de façadeklasse het uitsluitend aan hen door.

Een gelaagde applicatie starten

Doordat de applicatielaag geen "weet" heeft van de andere 2 lagen is het niet mogelijk om deze applicatie te starten inclusief de overige lagen. Je kunt dit een beetje vergelijken met een verbrandingsmotor in een auto die niet zichzelf kan starten en daarvoor de externe hulp van de startmotor nodig heeft. Dit probleem ontstaat doordat de lagen van elkaar ontkoppelt zijn. De applicatielaag heeft, bijvoorbeeld, niet langer de verantwoordelijkheid om de persistentielaag op te starten, maar heeft wel een noodzaak voor een werkende persistentielaag. En hetzelfde geldt voor de presentatielaag, die niet verantwoordelijkheid is voor het starten van de applicatielaag, maar uiteraard wel een werkende applicatielaag nodig heeft. Het probleem is nu dat de verantwoordelijkheid voor het opstarten van de verschillende lagen, buiten de applicatie zelf is komen te liggen. De constructie waarin component X een component Y nodig heeft, maar niet zelf component Y kan maken, levert op dat bij het starten van component X een instantie uit component Y meegegeven (geïnjecteerd) dient te worden. Dit noemen we ook wel depency injection. Er zijn veel verschillende vormen van depency injection, maar in de meeste gevallen kun je af met het injecteren bij opstarten. Veel programmeertalen hebben hiervoor een operatie die main heet, of iets soortgelijks. Het is de taak van deze operatie om de applicatie op te starten en alle afhankelijkheden op de juiste plaatsen te injecteren. Gezien de theorieën van objectgeoriënteerd programmeren is deze main-operatie een klein beetje een hack, maar als je deze isoleert in een apart stukje van je applicatie en alleen bij het opstarten gebruikt dan is dit te tolereren. Wederom eigenlijk net hetzelfde als de startmotor van de auto met verbrandingsmotor, wat natuurlijk ook gewoon een hack is.

full fig.4: De main-operatie geïsoleerd in een apart stukje. Dit is een beetje een hack, maar wordt alleen gebruikt om de applicatie op te starten.

De applicatie start en komt in de main-operatie, waarna deze als eerste een instantie van de klasse DataStore aanmaakt om deze vervolgens als parameter mee te geven aan de constructor van de klasse App in de package Application. Dit kan omdat DataStore de interface DataProvider implementeert en de parameter van de constructor is iets van het type DataProvider, dus de eerder gemaakte DataStore kan daar prima in. Op deze wijze heeft de main-operatie een object uit de package Persistance geinjecteerd in de package Application. Op dezelfde manier geeft het nu de instantie van de klasse App aan de constructor van de GUIController en injecteert zo een object uit de package Application in de package Presentation. De main-operatie is hier de dependency injector. Daarna gaat de main-operatie door met het starten van de applicatie zoals gewoonlijk.

Starten met een Factory-methode

Bij sommige programmeertalen en frameworks is het blijkbaar zo dat de main-operatie niet zo gemakkelijk in een aparte package gezet kan worden. Van een aantal studenten die werken met C# MVC kreeg ik de opmerking dat het zelfs onmogelijk is om de main-operatie uit de presentatielaag te nemen, waardoor een andere oplossing gezocht moest worden. Hieronder een voorbeeld waarin de main operatie in de presentatielaag blijft, en de dependency-injection uitbesteedt aan een andere methode. Dit patroon wordt ook wel Factory-method genoemd. De presentatielaag heeft nog steeds dezelfde behoefte als voorheen, namelijk een instantie van App, maar deze wordt nu niet vanuit extern geïnjecteerd, maar wordt opgevraagd door de factory-methode aan te roepen.

full fig.5: De main-operatie moet in de presentatielaag blijven, echter deze roept nu de factory-methode aan om de dependency-injection uit te voeren en een instantie van App in de presentatielaag te krijgen.

De applicatie start en komt in de main-operatie (in de presentatielaag), welke vervolgens de factory-methode createApp() aanroept in de Factory-package. Deze methode doorloopt nu hetzelfde proces als hierboven omschreven staat namelijk: aanmaken van een instantie van DataStore om deze als parameter te geven aan de constructor van App, waarna de instantie van App geretourneerd wordt en deze dus terecht komt in de presentatielaag. Hetzelfde einddoel wordt bereikt echter werkt het nu net iets anders.

Conclusie

Het idee van een lagenarchitectuur is om de verantwoordelijkheden in een informatiesysteem van elkaar te kunnen scheiden. Ten eerste levert dit gemak op tijdens het onderhoud omdat minder afhankelijkheden het gemakkelijker maakt om wijzigingen door te voeren, maar ook levert het de mogelijkheid op om gehele lagen uitwisselbaar te maken. Dat laatste heeft dan weer als voordeel dat een laag ook gesimuleerd kan worden om een andere laag in isolatie te testen, dit noemen we mocking. Het ontkoppelen van de verantwoordelijkheden levert dus voordelen op, maar zoals gebruikelijk levert elke verhoging van de totale complexiteit ook weer wat nadelen op. Het meest prominente nadeel is het feit dat een gelaagde applicatie zichzelf niet kan opstarten, hiervoor kan dan door middel van een main-operatie in een externe package of een factory-methode een oplossing gevonden worden.