Auf dieser Seite
Fachregeln als Daten: eine Rule Engine statt if-Kaskaden
„Produkt X nur anzeigen, wenn die Lieferung nach Deutschland geht.” „Bei
Bestellungen mit Kindern das Familienpaket nach oben sortieren.” Solche Regeln
beginnen als if im Code und enden als Kaskade, die niemand mehr anfassen
möchte. Jede Änderung braucht dann ein Ticket, ein Review und ein Release.
Ich habe für genau dieses Problem einen Service mit einer generischen Rule Engine gebaut. Die Regeln sind seitdem Daten. Dieser Artikel zeigt den Aufbau und die Stellen, an denen es knifflig wurde.
Eine Regel besteht aus Bedingungen und Aktionen
Eine Regel hat einen Namen, eine Priorität, einen Gültigkeitszeitraum, eine Liste von Bedingungen und eine Liste von Aktionen:
{
"name": "Familienpaket bei Kindern zuerst",
"resource": "OFFERS",
"priority": 20,
"validFrom": "2026-01-01T00:00:00Z",
"queries": [
{ "context": "CART", "query": { "persons": { "$elemMatch": { "type": "CHILD" } } } },
{ "context": "OFFER", "query": { "category": "FAMILY" } }
],
"actions": [
{ "type": "SORT", "mode": "CUSTOM", "field": "category", "values": ["FAMILY"] }
]
}
Jede Bedingung bezieht sich auf einen Kontext: den Warenkorb, das Angebot, die
Kategorie. Die Engine kennt drei Aktionen. FILTER schließt Ziele ein oder aus,
SORT ordnet sie alphabetisch, numerisch oder nach einer festen Werteliste, und
OVERWRITE setzt ein Feld auf einen anderen Wert.
Die Regeln laufen nach Priorität durch eine Pipeline aus Filtern, Sortieren und Überschreiben. Das Ergebnis einer Regel ist die Eingabe der nächsten. So lassen sich kleine Regeln kombinieren, statt eine große zu schreiben.
Eine Abfragesprache, die alle schon kennen
Für die Bedingungen brauchte ich eine Sprache. Ich habe keine neue erfunden,
sondern die Syntax und Operatoren von MongoDB-Abfragen übernommen: $eq, $ne, $gt, $lt,
$in, $nin, $exists, $elemMatch, $size, $and, $or, $not, dazu
Pfade mit Punkt wie items.0.price.
Das hat drei Vorteile:
- Wer MongoDB kennt, kann Regeln lesen und schreiben.
- Dieselbe Darstellung dient als API-Format und als Speicherformat.
- Die Engine bleibt generisch, weil sie nicht auf Klassen arbeitet, sondern auf Maps.
Der letzte Punkt ist der eigentliche Trick. Jedes Objekt wird vor der
Auswertung in eine Map<String, Object> umgewandelt:
Map<String, Object> target = objectMapper.convertValue(cart, MAP_TYPE);
boolean matches = queryEngine.matches(target, rule.query());
Die Engine weiß nichts über Warenkörbe oder Angebote. Kommt ein neuer Kontext hinzu, braucht die Engine keine Zeile neuen Code. Regel-Queries können ebenso maximal flexibel formuliert werden und bedeuten keinen zusätzlichen Entwicklungsaufwand.
Anfragen ohne Datenbank
Der Unterschied zu MongoDB liegt in der Herkunft der Daten: Die Daten, auf die die Regeln angewandt werden und auf denen die Ergebnisse basieren, liegen im Kundenprojekt nicht persistiert in einer Datenbank, sondern kommen zur Laufzeit als Anfragekontext in den Microservice. Nur die Regeln liegen in einer dokumentenorientierten Datenbank bzw. im verteilten Cache.
Mit der Anfrage verbundene Daten lassen sich anhand des Kontexts ebenso zur Laufzeit verknüpfen. So können die Regeln auf die Anfrage unmittelbar angewandt werden, um direkt im Anschluss eine personalisierte Antwort zu liefern.
Drei Stellen, an denen es knifflig wurde
Sonderzeichen in der Datenbank. Abfragen enthalten Schlüssel mit $ und
Pfade mit .. Beides erlaubt eine dokumentenorientierte Datenbank nicht als
Feldnamen. Die Lösung ist ein Konverter, der die Zeichen beim Speichern
maskiert und beim Laden wiederherstellt. Das fällt erst auf, wenn die erste
Regel gespeichert werden soll.
Logische Operatoren neben anderen Schlüsseln. Steht ein $or auf oberster
Ebene neben weiteren Bedingungen, ist nicht für alle offensichtlich, wie beides
zusammenwirkt. Ich schreibe deshalb in allen Regeln ein ausdrückliches $and
außen herum. Eine Zeile mehr, kein Missverständnis.
Bedingung oder Auswahl? Eine Abfrage auf das Angebot selbst ist keine Bedingung für die Regel, sondern wählt aus, auf welche Angebote die Aktion wirkt. Diese Unterscheidung muss in der Engine sauber getrennt sein, sonst überschreibt eine Regel plötzlich alle Preise statt einen.
Regeln sind Daten, aber nicht ungeprüft
Dass eine Regel ohne Release live geht, heißt nicht, dass sie ungetestet live geht:
- Startregeln liegen als JSON im Repository. Sie gehen durch ein Review wie Code und werden beim Start nur angelegt, wenn sie fehlen.
- Jede Fachregel hat einen Szenariotest, der die Engine mit echten Beispieldaten durchläuft. Die Engine selbst ist mit 170 Tests abgesichert.
- Die Verwaltungs-API ist getrennt gesichert, versioniert jede Änderung und hält fest, wer sie gemacht hat.
- Angewendete Regeln stehen im Log. Wenn ein Angebot fehlt, sehe ich, welche Regel es ausgefiltert hat.
Wann sich das lohnt
Eine Rule Engine ist zusätzlicher Code, der gepflegt werden will. Sie lohnt
sich, wenn sich Regeln häufiger ändern als der Code, wenn mehrere Abläufe
dieselben Regeln brauchen und wenn Fachleute die Regeln nachvollziehen sollen.
Für drei Regeln, die sich einmal im Jahr ändern, bleibt das if die bessere
Wahl.
Das zugehörige Fallbeispiel beschreibt, was die Engine im Einsatz gebracht hat.