De backend van Profit Next


pijl schuin blauw naar transparant
ERP-software bevat veel administratieve vastlegging, traditioneel aangeduid als CRUD: create, read, update en delete. Een applicatie die dit soort eenvoudige operaties moet ondersteunen kan goed ontwikkeld worden met een traditionele drie-lagen architectuur (een laag voor userinterface, business logica en data). En zolang je gedisciplineerd bent kan je ook de complexere operaties goed inpassen in deze architectuur. Dit is precies wat wij al jaren doen met onze ERP-software. Maar voor Profit Next, de nieuwe versie, willen we meer. Architectuur is één van de gebieden van deze versie (naast Cloud Management en Application Performance Management). De architectuur bepaald een aantal belangrijke karakteristieken: schaalbaarheid, betrouwbaarheid, en bijvoorbeeld performance.

Voorheen vonden klanten het prettig om de software zelf te beheren, het werd on-premise geïnstalleerd. Daarmee wordt de klant zelf verantwoordelijk voor het beheer en hij heeft geen last van andere klanten en gedeelde resources. Deze manier van software uitleveren is langzaam aan het veranderen. Klanten willen geen beheer meer en willen sneller toegang tot nieuwe versies (op een betrouwbare en niet verstorende manier). Iedereen verhuist meer en meer naar de cloud.

Maar on-premise client-server software vertaalt zich niet makkelijk naar een gedistribueerd cloud-platform. De drie-lagen architectuur is in gedistribueerde systemen een minder voor de hand liggende keuze. Doordat klanten resources gaan delen, wordt het ook belangrijk om die resources zo efficiënt mogelijk in te zetten.

Voorheen deed de klant de installatie en het beheer voor onze software zelf. Nu beheren wij de omgevingen van heel veel klanten. Om het beheer goed en efficiënt te kunnen doen hebben wij voorzieningen ontwikkeld. Maatwerk op een platform met duizenden klanten is niet efficiënt realiseerbaar, daarom hebben we alles gestandaardiseerd. Zoals gezegd zijn resources voor een cloud platform belangrijk, dus hebben wij natuurlijk ook goed gekeken naar het resource gebruik. Waar een enkele klant makkelijk meer capaciteit kan toevoegen door zijn server te upgraden is dat op een platform met honderden servers niet zo makkelijk. Hoe een applicatie omgaat met resources is goed te duiden aan de hand van twee concepten, die wij in de praktijk tegen zijn gekomen.

OLTP versus OLAP

ERP-software draait om data, data die vastgelegd wordt en data die geraadpleegd wordt. Maar het vastleggen en raadplegen zijn vanuit de database gezien twee conflicterende behoeftes. Voor het vastleggen van data moet het systeem geoptimaliseerd worden voor schrijfacties. Het dataschema wordt genormaliseerd zodat gegevens maar één keer worden vastgelegd zodat dit maar één schrijfacties kost. Een systeem met als primaire doel het vastleggen van gegevens wordt een Online Transaction Processing (OLTP) systeem genoemd. 

Een voorbeeld hiervan zijn orders met de klantgegevens. Deze gegevens zullen vaak in twee aparte tabellen opgeslagen worden: een order-tabel en een klant-tabel. De orders zullen daarbij verwijzen naar de bijhorende klant. Zowel het bijwerken van orders en klanten is eenvoudig: er is maar één locatie waar die gegevens zijn vastgelegd.

Bij het raadplegen van de gegevens heeft het echter de voorkeur dat deze gegevens gedenormaliseerd zijn. Gegevens die vaak samen geraadpleegd worden staan bij elkaar waardoor het systeem dit sneller kan ophalen. Een systeem met dit doel wordt een Online Analytical Processing (OLAP) systeem genoemd. 

Voor het lezen van data werkt de splitsing van orders en klanten over twee tabellen niet zo goed. Als orders gelezen worden zijn de klantgegevens eigenlijk altijd nodig. Dus moeten er bij deze operatie  twee tabellen geraadpleegd worden.In een relationele database gaan we dus een join gebruiken. En hoe groter de join wordt, en hoe meer joins je moet doen, hoe zwaarder het systeem belast wordt. De gegevens samenvoegen tot één tabel zorgt er echter voor dat meer gegevens bijgewerkt worden: de klantgegevens staan namelijk bij elke order, en moeten ze dus ook daar bijgewerkt worden.

De conflicterende eisen die het schrijven en het lezen van gegevens leggen op het systeem komen in de praktijk vaak voor. Als gegevens over tientallen tabellen verspreid zijn, en een join over al deze tabellen nodig is, zal dat het systeem zwaar belasten. De gegevens gedenormaliseerd opslaan (en dus gedupliceerd) maakt dat het bijwerken echter weer een zware belasting wordt voor het systeem.

CAP-theorema

Een cloud-platform is per definitie gedistribueerd. Er zijn meerdere (vele) machines die samen een bepaalde service leveren. Het doel van het cloud-platform is om er voor te zorgen dat deze service beschikbaar is en dat een verhoging in het aantal gebruikers niet zal leiden tot een vertraging van het systeem. Daarnaast kan het platform meer capaciteit leveren in de periodes dat dit nodig is, zonder dat er voor deze capaciteit betaald hoeft te worden in de periodes dat het overbodig is.

De machines in een cloud-platform moeten wel samen werken. Het is belangrijk dat zij naar de gebruiker toe dezelfde waarheid vertellen, ze moeten consistent zijn. Ook moeten ze samen werken om te zorgen dat de service voor de gebruiker altijd beschikbaar is. Maar wat als de vele machines niet meer met elkaar kunnen communiceren? Wat als het cloud-platform gepartitioneerd raakt en er dus eigenlijk meerdere cloud-platformen actief zijn?
Deze drie eigenschappen en hoe de onderlinge interactie is wordt beschreven met het CAP-theorema

A web service can have at most two of the three following properties: Consistency, Availability, and tolerance to network Partitions. 

Neem een systeem om bankrekeningen te beheren: het is mogelijk om saldi op te vragen en om geld op te nemen. Het systeem bestaat uit drie machines. Als dit systeem consistent wil zijn, zullen de drie machines moeten communiceren. Het opnemen van geld kan dan bijvoorbeeld alleen maar doorgang vinden als alle drie de machines hier akkoord op geven. Maar als er een machine niet antwoord, weet je niet of deze uitgevallen is, of misschien alleen maar heel traag is? De machines kunnen gepartitioneerd geraakt zijn. Maar betekent dat dat het geld opnemen niet mogelijk is? In dat geval wordt er dus gekozen voor consistentie in plaats van beschikbaarheid.

Dit principe maakt duidelijk dat een gedistribueerd systeem niet perfect kan zijn en dat wij dus keuzes moeten maken.

Architectuur

Deze twee uitdagingen moeten wij oplossen in de architectuur voor de backend van Profit Next, de nieuwe versie van de ERP-software die wij maken. En deze oplossing lijkt te liggen in het opknippen van de backend. Niet één grote service, maar meerdere kleine services. Ieder met zijn eigen verantwoordelijkheid. Niet één laag waar alle verantwoordelijkheden rondom data zitten. Een scheiding van de verantwoordelijkheid voor schrijfacties en leesacties.

Wij zien dat de twee genoemde uitdagingen aangepakt worden in het patroon Command and Query Responsibility Segregation (CQRS). CQRS is een patroon dat oorspronkelijk is begonnen als Command-Query Seperation (CQS) dat is ontstaan door het werk van Bertrand Meyers aan de Eiffel programmeer taal (lees hier en hier verder). Vanuit dat patroon hebben Greg Young, Udi Dahan en andere CQRS beschreven. Het patroon beschrijft een systeem dat in twee aparte delen gesplitst wordt. Eén deel is verantwoordelijk voor het afhandelen van wijzigingen aan data. Hier worden controles gedaan en wordt de consistentie van de data bewaard. Het andere deel, het leesgedeelte, is verantwoordelijk voor het antwoorden geven op vragen over deze data. In de architectuur van Profit Next komt dit tot uiting door de twee subsystemen: het command-systeem en het query-systeem.


Het command-systeem

Het command- systeem is verantwoordelijk voor schrijfacties. Een client stuurt wijzigingen door middel van een command naar dit subsysteem. Deze wijzigingen worden gevalideerd en vervolgens geaccepteerd of afgewezen. De client krijgt bij het indienen van een command nooit data terug, het antwoord is of een akkoord of een fout. Een command kan niet gebruikt worden om gegevens op te halen, omdat dat de OLAP/OLTP-keuze onmogelijk zou maken.

Het command-systeem slaat events op in de event store. Elk event representeert een gebeurtenis in het command-systeem. Zodra een command is geaccepteerd worden er events opgeslagen. Dit kunnen er één of meerdere zijn.

Het query-systeem

Het query-systeem is verantwoordelijk voor de leesacties. Een client vraagt gegevens op via queries. Deze aanvragen kunnen geen gegevens wijzigen, er kunnen alleen gegevens gelezen worden. 

Het query-systeem luistert naar events die opgeslagen zijn in de event store. Door middel van sequence numbers weet het query-systeem welke events al verwerkt zijn. Als er events bij zijn gekomen worden deze verwerkt in een projectie van de gebeurtenissen. De data in het query-systeem is dus afgeleid van gebeurtenissen die hebben plaats gevonden in het command-systeem. In deze projectie kan de data gedenormaliseerd worden, zodat deze optimaal is voor het soort vragen dat ondersteund moet worden. 

Dataopslag en event sourcing

De data van het query-systeem is, omdat het afgeleid wordt, vervangbaar. De bron van de data zijn namelijk de events die opgeslagen zijn. Op die manier kan het query-systeem altijd hersteld worden door de events opnieuw in te lezen. 

Het command-systeem heeft echter ook state nodig, namelijk die state die van belang is voor het controleren van nieuwe commands. Voor bijvoorbeeld het controleren van unieke namen voor medewerkers is er een lijst nodig met alle medewerkers in het software-systeem. Omdat de events die gepubliceerd zijn alle gebeurtenissen representeren kan de state van het command-systeem ook uit deze events afgeleid worden. Deze manier van omgaan met state heet event sourcing.

De stroom van events representeert een compleet logboek van alles wat in het systeem heeft plaats gevonden. Het query-systeem heeft zijn eigen dataopslag, waarin de data gedenormaliseerd wordt geprojecteerd.

Uitdagingen

Hebben we nu dan helemaal geen uitdagingen meer? Zijn alle problemen opgelost met deze architectuur? Nee, we hebben nog genoeg te doen. En elke uitdaging geeft weer dat we keuzes moeten maken. “There is no such thing as a free lunch”. Twee uitdagingen die ontstaan door de keuze voor CQRS zijn bijvoorbeeld eventual consistency en transacties. En derde uitdaging heeft maken met de flexibiliteit die we graag willen behouden in onze architectuur.

Eventual consistency

De events die het command-systeem publiceert en die door het query-systeem worden opgevangen zijn asynchroon. Het gevolg is dat een client die een command stuurt bevestiging krijgt voordat het query-systeem de gepubliceerde events verwerkt heeft. Het query-systeem is eventually consistent:

The system guarantees that if no new updates are made to an object, eventually all accesses will return the last updated value. 

Het query-systeem zal dus uiteindelijk alle events hebben verwerkt en de juiste waarde teruggeven. Er is alleen geen garantie binnen welke tijd dat zal zijn. En we moeten dus rekening houden met deze vertraging. Als we na het aanmaken van een nieuwe klant dus meteen de lijst met alle klanten zou laten zien aan onze gebruikers, kan het zijn dat de nieuwe klant nog niet aanwezig is. 

Transacties

In het command-systeem vormen juist de transacties een uitdaging. Het command-systeem is opgedeeld in kleine componenten: AggregateRoots. Elke AggregateRoot staat voor een gegeven in het systeem. Bijvoorbeeld een klant of een order (met orderregels). Deze gegevens zijn niet beschikbaar voor andere componenten, het is alleen beschikbaar voor de AggregateRoot zelf. Een command wordt altijd door één AggregateRoot afgehandeld. Daardoor zijn binnen een AggregateRoot transacties makkelijk, er wordt maximaal één command tegelijk afgehandeld en alle gegevens om dit command te verwerken zijn automatisch gelocked.

De uitdaging wordt gevormd als we validaties over gegevens uit meerdere AggregateRoots moeten doen. De simpele controle of de klant op een order wel geldig is voor de ‘order’-AggregateRoot niet direct mogelijk. Hij mag immers niet de data van een ‘klant’-AggregateRoot. Een mogelijkheid is om dit dan te controleren in het query-systeem. Maar doordat deze eventually consistent is kan het antwoord zowel vals-negatief (de klant lijkt niet geldig, maar wordt het door events die nog verwerkt moeten worden wel) als vals-positief (de klant lijkt geldig, maar er moet nog een event verwerkt worden dat hem niet geldig maakt) zijn. 

Flexibiliteit

Door het CQRS-patroon zijn er in de backend geen sterke koppelingen tussen de verschillende onderdelen. Elk onderdeel heeft zijn eigen verantwoordelijkheid en de keuzes die we moeten maken op het gebied van bijvoorbeeld beschikbaarheid en consistentie kunnen we ook per onderdeel afwegen. 

De architectuur die wij gecreëerd hebben voor Profit Next is gebaseerd op een lange-termijn visie. We streven naar een onderhoudbaar en flexibel systeem dat toegepast kan worden voor elke klantomvang. Daarom hebben we ook gekozen voor een sterke plug-in architectuur. Zaken als datastores en eventbussen kennen meerdere implementaties die gebenchmarkt kunnen worden. Maar ook het deployen van Profit Next is flexibel. Via frameworks als Microsoft Orleans en Microsoft Service Fabric kunnen we het command-systeem en het query-systeem verspreiden over meerdere machines, maar we kunnen ook alles als één webapplicatie installeren. De uitdaging blijft om deze flexibiliteit te behouden, zonder dat dit onnodige complexiteit tot gevolg heeft.

Met Profit Next hebben we een moderne, gedistribueerde architectuur gecreëerd. Op die manier kan er optimaal gebruik gemaakt worden van de cloud en de bijhorende schaalbaarheidsoplossingen. Daarnaast zorgt de flexibiliteit van de plug-in architectuur ervoor dat strategische beslissingen met betrekking tot het gebruik van software van derde partijen uitgesteld worden tot op het moment dat deze beslissing daadwerkelijk van belang is.