In het vorige Java-magazine stond een artikel van Jan Ouwens over hoe we bij de Rabobank gekomen zijn tot een DSL in Scala om de code voor financiële berekeningen beter leesbaar te maken. Dit artikel gaat door op deze verbeterslag en laat zien hoe we aansluitend een Rule Engine met uitgebreide DSL hebben ontwikkeld om de cruciale financiële berekeningen beter inzichtelijk en onderhoudbaar te maken. De doelstelling is hierbij veel breder getrokken: de code moet niet alleen leesbaarder zijn, maar de business analisten moeten zelf direct uitvoerbare berekeningen schrijven in IntelliJ!
Dit artikel laat zien hoe – aan de hand van Facts, Conditions en Evaluations – een complex netwerk van berekeningen kan worden gemodelleerd. Met deze tooling, te vinden op http://scala-rules.org, is het niet meer nodig voor programmeurs om berekeningen-specificaties uit te programmeren: deze specificaties zijn nu de berekeningen. Hiermee is een belangrijke kerntaak van de business terug waar deze hoort: bij de business.
Wegnemen van de scheiding
De traditionele werkwijze bij veel financiële bedrijven kent een diepe verdeling van werkzaamheden omtrent berekeningen. De business bepaalt de opbouw en componenten van de berekening en stelt hiermee een functioneel ontwerp op. Met dit functioneel ontwerp gaan de ontwikkelaars de berekening programmeren in bijvoorbeeld Java. Doordat de ontwikkelaars de functionele beschrijving van de berekening moeten vertalen naar een code-implementatie bestaat er, zelfs in de “concrete” wereld van berekeningen, veel ruimte voor interpretatie- en daarmee implementatie-verschillen. Juist in de financiële sector is dit zeer onwenselijk: de berekeningen zijn de kern van de bedrijfsvoering en moeten absoluut correct zijn. Ook verantwoording afleggen is lastig voor de business: komt het functionele ontwerp eigenlijk wel overeen met de geprogrammeerde werkelijkheid? Om de berekening weer onder het beheer van de business te brengen, is het noodzakelijk om een belangrijke belemmering weg te nemen: de business moet in staat worden gesteld om zelf de berekening te implementeren en onderhouden.
Eerdere pogingen om de business te betrekken bij de technische implementatie gebruikten Cucumber om met table-based tests inzicht te geven in testscenario’s. Het ontwikkel- en schrijfwerk van de tests en de bijbehorende gluecode bleef echter in handen van de programmeurs en testers. Aan de fragmentatie van verantwoordelijkheden rondom de berekeningen zelf veranderde helemaal niets. Er was een radicalere aanpak nodig om de business te verleiden om de implementatie naar zich toe te trekken. Om berekeningen begrijpelijk en overzichtelijk te maken, hebben we toegewerkt naar een oplossing waar deze worden opgebouwd uit vele, kleine stapjes. Het resultaat is een flexibele, begrijpbare DSL die wordt ondersteund door een rule engine en waarmee de business analist berekeningen kan schrijven (zie Listing 1 en 2).
Gegeven (<conditie>)
Bereken <outputFact> is (<expressie>)
Listing 1: Syntax van een berekening
class Marktwaarde extends Berekening(
Gegeven (NHG is true) Bereken
MeerwaardeVerbouwingNHG is (KostenVerbouwing * MeerwaardeVerbouwingsPercentageNHG)
,
Gegeven (NHG is false) Bereken
MeerwaardeVerbouwingGeenNHG is (KostenVerbouwing * MeerwaardeVerbouwingsPercentageGeenNHG)
,
Gegeven (altijd) Bereken
MarktwaardeVoorVerbouwing is (Koopsom - KostenRoerendeGoederen) en
MarktwaardeNaVerbouwing is (MarktwaardeVoorVerbouwing + MeerwaardeVerbouwing) en
MeerwaardeVerbouwing is eerste (MeerwaardeVerbouwingNHG, MeerwaardeVerbouwingGeenNHG) en
Marktwaarde is eerste (MarktwaardeNaVerbouwing, TaxatieWaarde, MarktwaardeVoorVerbouwing)
)
Listing 2: Marktwaarde berekenen in de DSL
De Rule Engine
De rule engine maakt onder water gebruik van vijf concepten: Fact, Context, Evaluation, Condition en Derivation (zie Listing 3).
trait Fact[+A] {
def name: String
def description: String
def toEval: Evaluation[A]
}
type Context = Map[Fact[Any], Any]
trait Evaluation[+A] {
def apply(c: Context): Option[A]
}
type Condition = Context => Boolean
case class Derivation(input: List[Fact[Any]], output: Fact[Any], condition: Condition, operation: Evaluation[Any])
Listing 3: Bouwblokken van de Rule Engine
Fact
De basisblokken zijn Facts. Deze vertegenwoordigen domeinwaarden: zij kunnen onderdeel zijn van de invoer (bijvoorbeeld: Koopsom) of de uitkomst van een berekening (MarktwaardeVoorVerbouwing). De engine kan op basis van Facts de bijbehorende waarde uit de Context halen.
Context
Als invoer voor de engine worden alle waarden van Facts verzameld in een Context. Eigenlijk is een Context niets meer dan een map van Facts naar hun bijbehorende waardes. Na iedere stap in een berekening maakt de engine een nieuwe Context, waar het resultaat van de stap aan is toegevoegd. Deze nieuwe Context is vervolgens weer de invoer voor de volgende stap.
Evaluation
Een Evaluation houdt niets anders in dan het bepalen van een waarde vanuit de al aanwezige waarden in een Context. Een Fact heeft een methode toEval die een Evaluation oplevert om de waarde van dat Fact uit de Context te halen. Dit is de meest eenvoudige Evaluation binnen de engine. De DSL gaat hier echter verder. Door Evaluations aan elkaar te knopen, kunnen veel ingewikkeldere afleidingen worden opgebouwd. Zo zet de DSL bijvoorbeeld het optellen van twee Facts om in een Evaluation die twee Fact-evaluations uitvoert en de waarden bij elkaar optelt. Deze Evaluation levert weer een Fact aan de Context om mee verder te werken.
Condition
Voor veel berekeningen en andere business logica geldt dat sommige aspecten enkel moeten worden meegewogen wanneer er aan bepaalde voorwaarden wordt voldaan: de Condition.
Derivation
Derivations zijn de sluitstenen die ervoor zorgen dat Facts, Conditions en Evaluations samenkomen. De Condition wordt door de engine uitgevoerd en als het resultaat true is, wordt de bijbehorende Evaluation uitgevoerd en het resultaat, indien aanwezig, opgenomen onder het Fact dat als output is bestempeld. Dit Fact kan vervolgens weer als input dienen voor een Condition of Evaluation.
Bringing it all together
Bij de start krijgt de engine een verzameling Derivations aangeleverd en hiermee wordt als eerste stap een afhankelijkhedengraaf opgebouwd. De engine bepaalt op basis van de Conditions en de Facts in welke volgorde hij de Derivations moet uitvoeren om te garanderen dat een Fact wordt gevuld voordat deze ergens anders wordt gebruikt. Alle resultaten worden in de juiste volgorde aan de Context toegevoegd, welke uiteindelijk het resultaat oplevert (zie Listing 4). Dit levert een aantal belangrijke voordelen op:
- doordat de engine de uitvoervolgorde bepaalt, worden berekeningen nooit in de verkeerde volgorde uitgevoerd;
- doordat de initiële volgorde voor de rule engine niet uitmaakt, kan de volgorde van opschrijven volledig worden aangepast om de leesbaarheid zo hoog mogelijk te houden
De rule engine is daarnaast zeer generiek opgezet. Wij gebruiken hem momenteel voor financiële berekeningen, maar hij is veel breder inzetbaar en kan bijvoorbeeld ook voor andere business logica worden ingezet.
val inputContext: Context = Map(
NHG -> true,
Koopsom -> 250000.euro,
KostenRoerendeGoederen -> 12000.euro,
KostenVerbouwing -> 25000.euro,
// Constanten
MeerwaardeVerbouwingsPercentageNHG -> 80.procent,
MeerwaardeVerbouwingsPercentageGeenNHG -> 100.procent
)
val resultContext: Context = FactEngine.runNormalDerivations(inputContext, new Marktwaarde().berekeningen)
println(PrettyPrinter.printContext(result))
Values in context:
Koopsom = € 250.000,00
KostenRoerendeGoederen = € 12.000,00
KostenVerbouwing = € 25.000,00
Marktwaarde = € 258.000,00
MarktwaardeNaVerbouwing = € 258.000,00
MarktwaardeVoorVerbouwing = € 238.000,00
MeerwaardeVerbouwing = € 20.000,00
MeerwaardeVerbouwingNHG = € 20.000,00
MeerwaardeVerbouwingsPercentageGeenNHG = 100%
MeerwaardeVerbouwingsPercentageNHG = 80%
NHG = true
Listing 4: Het resultaat toont alle ingevoerde en uitgerekende waarden van een gegeven context en berekening combinatie
De DSL
De rule engine heeft dus een set Facts, Conditions en Evaluations nodig om te bepalen wat hij moet berekenen. Deze componenten moeten door de business kunnen worden aangeroepen op een manier die flexibel en natuurlijk is. De DSL biedt de handvatten om dit leesbaar en zonder al te veel boilerplate te kunnen opschrijven. Voor Facts, Conditions en Evaluations hebben we nog wat moeten doen.
Fact
Voor de Facts is er een Glossary waar deze gedefinieerd worden met wat extra metadata. Zo moet er aangegeven worden wat het type is (bijvoorbeeld String, of natuurlijk Bedrag), waardoor meteen type-checking beschikbaar is bij het schrijven van de Berekening. Daarnaast krijgt het Fact ook een naam en eventueel een beschrijving. Elk Fact wordt aan een variabele met dezelfde naam toegewezen, zodat ook code-completion beschikbaar is (zie Listing 5).
object MarktwaardeGlossary extends Glossary {
val NHG = SingularFact[Boolean]("NHG")
val Koopsom = Fact[Bedrag]("Koopsom")
val MeerwaardeVerbouwingsPercentageGeenNHG = Fact[Percentage]("MeerwaardeVerbouwingsPercentageGeenNHG")
// ...
}
Listing 5: Met een Glossary worden Facts gedefinieerd
DslEvaluation en DslCondition
Voor de DSL hebben we uitgebreidere varianten van Evaluation en Condition aangemaakt: DslEvaluation en DslCondition. Deze variaties bieden convenience methoden om wiskundige operaties in natuurlijke taal uit te drukken. Uiteindelijk worden deze omgezet naar de Evaluations en Conditions die de engine nodig heeft. Aan de hand van Listing 1 en 2 leggen we de werking van de DSL-varianten verder uit.
De berekeningcode
Als we kijken naar de eerste berekening van MeerwaardeVerbouwingNHG, dan begint deze met een Gegeven. Gegeven is de methode die het startpunt vormt voor iedere berekening en vereist een DslCondition. Als deze true is, wordt de berekening uitgevoerd, anders niet. Gegeven is altijd het startpunt, maar veel berekeningen zullen geen expliciete afhankelijkheid hebben van een invoerwaarde. Daarvoor is het keyword “altijd”, welke resulteert in een Condition die naar true evalueert. Dit betekent overigens niet dat de berekening gegarandeerd wordt uitgevoerd: de aanwezigheid van een benodigd Fact werkt ook als Condition, zodat de engine alleen uitvoert wat beschikbaar is.
Na een Gegeven moet met de Bereken-methode een Fact worden aangegeven dat als output zal dienen. Rechts van de methode “is” staat vervolgens een opgebouwde Evaluation, waarvan de engine de uitkomst toewijst aan het output-Fact. Met “en” hergebruik je de Condition door meerdere outputfacts en evaluations aan elkaar te koppelen. Het geheel biedt een overzichtelijke manier om complexe berekeningen uit te schrijven in werkzame code.
Implicit
In het Evaluation-gedeelte gebeurt nog iets speciaals. Het Fact-type heeft namelijk geen * methode, maar deze wordt toch aangeroepen. Hier zien we de kracht van Scala’s implicits. Scala probeert eerst een methode op de class zelf te vinden met de juiste signature. Lukt dat niet, dan wordt er gekeken of er een implicit def of class is die wel voldoet. Aangezien Fact geen * methode heeft, gaat Scala dus op zoek naar manieren om het Fact in iets anders om te zetten: er bestaat een implicit def om van een Fact een DslEvaluation te maken en DslEvaluation heeft wel een * methode. Scala zal dus het eerste Fact met de implicit def wrappen in een DslEvaluation en vervolgens wordt hiervan de * operator aangeroepen. Rechts van deze nieuwe DslEvaluation staat weer een Fact, maar DslEvaluation kan alleen operaties doen met andere DslEvaluations. Ook hier wrapt Scala de Fact als DslEvaluation. Zo kan je meerdere Facts met elkaar vermenigvuldigen, optellen, delen, etc. Ook voor de overgang van Facts naar Conditions bestaan implicits, bijvoorbeeld om de Fact-status (wel of niet gevuld) te gebruiken als Condition. Gebruik van implicits zorgt ervoor dat exponentiele groei van code duplicatie bij het toevoegen van nieuwe types tot het verleden behoort.
DSL functies
Soms ligt de complexiteit van een berekening niet in de functionele werking, maar in de benodigde omslachtige notatie. We hebben er in overleg met de business voor gekozen om generieke functionaliteit die alleen maar afleidt van de daadwerkelijke berekening in speciale DSL-functies te stoppen. Een voorbeeld hiervan is “eerste”. Deze bouwt een Evaluation die de waarde teruggeeft van het eerstgenoemde Fact dat een waarde heeft. Hiermee wordt een aaneenschakeling van leesbaarheid verknallende if-else constructies voorkomen. Ook het berekenen van de AnnuiteitenFactor, zoals Jan in zijn artikel beschrijft, is in onze DSL in een functie gestopt. Deze functie is voor de business meteen duidelijk en gemakkelijk herbruikbaar.
Testen
Om het testen te vereenvoudigen en goed inzichtelijk te maken voor de business hebben wij ook een DSL gemaakt specifiek voor het testen. Hierin is het mogelijk om berekeningen te testen met standaard en ad hoc gespecificeerde waarden. De testcases zijn, net als de berekeningen, goed te lezen en schrijven door de business. Ook de tester kan zonder hulp of programmeerkennis de tests maken en onderhouden. Hiermee zorgen we ervoor dat ook de verificatie volledig komt te liggen bij de partijen die daarvoor verantwoordelijk moeten zijn: de business en de tester.
Conclusie
Bij het maken van een DSL en het gebruik daarin van implicits staat het comfort van de gebruiker van de DSL, de business, voorop. Daardoor voelt het coderen van de DSL soms wat raar aan: codingconventies die diep zijn ingesleten, moeten soms plaatsmaken voor oplossingen die een voor de business goed leesbare, natuurlijke interface opleveren. Met name de naamgevingconventies gaan stevig op de schop. Belangrijk is dat je met de business in gesprek gaat en blijft over wat vanuit het domein goed te begrijpen is. Wanneer bij de Rabobank nu een nieuwe berekening wordt geïntroduceerd, gaat de business analist in samenspraak met andere businesspartijen uitzoeken hoe deze berekening in elkaar steekt. Vervolgens voert hij/zij deze berekening met behulp van de gebouwde DSL zelf op in IntelliJ. Daarnaast programmeert de tester nu zelf de testgevallen in samenspraak met de analist. Zolang geen nieuwe DSL functionaliteit nodig is, is hiervoor dus geen tussenkomst van de programmeur meer nodig. Zo kan iedereen zich weer aan zijn kerntaken wijden: de programmeur kan toffe, nieuwe dingen bouwen, de tester kan zelf zijn tests maken en de business maakt de berekeningen.
Open Source
Het gebruik van de concepten uit dit artikel is niet alleen weggelegd voor mensen die werken op een project van de Rabobank. Vanwege de generieke opzet van de onderliggende engine en DSL hebben we besloten om de broncode op GitHub te zetten met MIT-licentie. We zijn dan ook erg blij dat we dit artikel af kunnen sluiten met de mededeling dat alles wat we hiervoor hebben beschreven terug te vinden is op http://scala-rules.org en dat de artifacts in Maven Central zijn gedeployed. In de GitHub repository staan bovendien uitgebreidere instructies om de engine zelf toe te passen. De komende periode gaan wij de documentatie verder uitbreiden en daarnaast de DSL vertalen naar het Engels, zodat deze voor een breder publiek inzetbaar is.
We nodigen je van harte uit te gaan spelen met de DSL en als je de kans ziet: probeer je niet-programmeur-collega’s eens een berekening te laten definiëren!
De afhankelijkheden laten zich ook leuk visualiseren.