← Blog

Kumi: Berechnungslogik in Ruby, die auch in JavaScript läuft

Viele meiner Projekte sind Webshops und Abrechnungssysteme. Dabei taucht immer wieder dasselbe Problem auf: Die Preislogik lebt auf dem Server, aber die Kundin möchte den Gesamtbetrag schon im Browser sehen, während sie die Mengen ändert. Also werden die Regeln zweimal geschrieben, einmal in Ruby und einmal in JavaScript, und früher oder später rechnen die beiden Versionen unterschiedlich.

Kumi von André Muta ist ein Ruby-Gem, das dieses Problem ernst nimmt. Es ist eine deklarative DSL für Berechnungslogik wie Steuerregeln, Preise und Bewertungen, die aus einem einzigen Schema gewöhnlichen Ruby- und JavaScript-Code erzeugt. Ich habe es ausprobiert, und es gefällt mir sehr.

Was Kumi macht

Sie beschreiben die Form Ihrer Eingabedaten und die Werte, die berechnet werden sollen. Schleifen und die Reihenfolge der Berechnung schreiben Sie nicht selbst. Der Compiler ermittelt die Abhängigkeiten, prüft die Typen und erzeugt für jede Zielsprache geradlinigen Code ohne Abhängigkeiten.

Zwei Ideen unterscheiden Kumi von gewöhnlichen Ruby-Methoden:

Installation:

gem install kumi

Kumi braucht Ruby 3.1 oder neuer und steht unter der MIT-Lizenz. Die aktuelle Version ist 0.1.0 vom Juni 2026. Das README bezeichnet das Projekt als experimentell: Die öffentliche API kann sich noch ändern.

Ein Beispiel: eine Schweizer Rechnung

Zum Ausprobieren habe ich eine kleine Rechnung geschrieben. Positionen ab 10 Stück erhalten 10% Mengenrabatt, dazu kommen 8,1% Mehrwertsteuer, und der Gesamtbetrag wird auf 5 Rappen gerundet, wie in der Schweiz bei Barbeträgen üblich.

require "kumi"

module Invoice
  extend Kumi::Schema

  schema do
    input do
      array :items do
        hash :item do
          integer :quantity
          decimal :unit_price
        end
      end
    end

    trait :bulk, input.items.item.quantity >= 10

    let :gross, input.items.item.quantity * input.items.item.unit_price
    value :line_totals, select(bulk, gross * 0.9, gross)
    value :subtotal, fn(:sum, line_totals)
    value :vat, subtotal * 0.081
    # no round() yet: scale to 5 Rappen, add 0.5, truncate
    value :total, fn(:to_integer, (subtotal + vat) * 20 + 0.5) / 20.0
  end
end

result = Invoice.from(items: [
  { quantity: 2,  unit_price: 50.0 },
  { quantity: 12, unit_price: 10.0 }
])

result[:line_totals] # => [100.0, 108.0]
result[:subtotal]    # => 208.0
result[:vat]         # => 16.848
result[:total]       # => 224.85

Ein paar Dinge fallen auf:

Dieselbe Logik in JavaScript

Dieser Teil interessiert mich für Webshops am meisten. Ein einziger Aufruf schreibt ein JavaScript-Modul:

Invoice.write_source("invoice.mjs", platform: :javascript)

Jedes value, let und trait wird zu einer exportierten Funktion. So sieht das erzeugte _total aus, unverändert:

export function _total(input) {
  let t88 = input["items"];
  let acc95 = 0;
  for (let items_i90 = 0; items_i90 < t88.length; items_i90++) {
    let items_el89 = t88[items_i90];
    let t91 = items_el89["quantity"];
    let t92 = 10;
    let t41 = t91 >= t92;
    let t93 = items_el89["unit_price"];
    let t48 = t91 * t93;
    let t94 = 0.9;
    let t51 = t48 * t94;
    let t59 = t41 ? t51 : t48;
    acc95 += t59;
  }
  let t96 = 0.081;
  let t87 = acc95 * t96;
  let t28 = acc95 + t87;
  let t97 = 20;
  let t30 = t28 * t97;
  let t98 = 0.5;
  let t32 = t30 + t98;
  let t33 = __core_to_integer(t32);
  let t99 = 20.0;
  let t35 = t33 / t99;
  return t35;
}

Genau so wünsche ich mir generierten Code: eine einzige Schleife mit Rabatt, Summe, Mehrwertsteuer und Rundung, ohne Bibliothek und ohne Interpreter dahinter. Man kann ihn lesen, im Debugger durchgehen und so ausliefern, wie er ist. Ich habe das Modul mit Node.js auf denselben Daten ausgeführt und genau dieselben Ergebnisse wie in Ruby erhalten: [100, 108], 208, 16.848 und 224.85.

Kumi in ruby.wasm

Ich arbeite viel mit ruby.wasm, zum Beispiel für die Ruby Koans im Browser. Deshalb wollte ich wissen, ob Kumi selbst auch in einem nach WebAssembly kompilierten Ruby läuft. Das tut es, mit ein paar Zeilen Vorbereitung:

# before require "kumi"
ENV["KUMI_PASS_BUDGET_MS"] = "0"   # no threads in WebAssembly: no compile time limit
require "tmpdir"
Dir.mkdir("/tmp") unless Dir.exist?("/tmp")
def Dir.tmpdir = "/tmp"            # a place for Kumi's code cache
require "kumi"

Kumi begrenzt jeden Compiler-Durchgang mit einem Zeitlimit über Timeout. Das startet einen Thread, und ruby.wasm kennt keine Threads. Die Umgebungsvariable schaltet das Limit ab. Ausserdem schreibt Kumi den erzeugten Ruby-Code in ein Cache-Verzeichnis, bevor es ihn lädt, und im In-Memory-Dateisystem des Browsers findet Ruby von selbst kein temporäres Verzeichnis.

Mit dieser Vorbereitung liefert das Rechnungsbeispiel oben in ruby.wasm genau dieselben Ergebnisse, bis zu den 224.85. Getestet habe ich das mit ruby.wasm 2.10.1 (Ruby 4.0) in Node.js, mit demselben In-Memory-Dateisystem, das ruby.wasm im Browser verwendet. Kumi ist reines Ruby, und seine Abhängigkeiten zeitwerk und mutex_m laden ohne Änderungen.

Damit gibt es zwei Wege, Ihre Regeln in den Browser zu bringen: der Export nach JavaScript, der schlankste Weg, oder, wenn Ihr Frontend ohnehin schon Ruby in WebAssembly ausführt, Kumi-Schemas direkt dort definieren und ausführen.

Fehler werden früh erkannt

Kumi analysiert Bedingungen schon beim Definieren des Schemas. Wenn Sie festlegen, dass ein Alter zwischen 0 und 150 liegt, und danach eine Bedingung schreiben, die nie wahr sein kann, schlägt das Laden des Schemas fehl:

schema do
  input { integer :age, domain: 0..150 }
  trait :impossible_age, input.age == 200
end
# Kumi::Core::Errors::SemanticError: conjunction `impossible_age` is impossible

Bei Geschäftsregeln ist so eine Bedingung meist ein Tippfehler oder ein Missverständnis der Anforderungen. Kumi meldet das sofort beim Laden des Codes, statt dass Sie Monate später merken, dass ein Rabatt nie gegriffen hat. Schon das allein macht Kumi interessant.

Regeln zusammensetzen

Schemas können andere Schemas mit import :tax, from: TaxPolicy einbinden. Eine Regel, die nur einen einzelnen Betrag kennt, lässt sich dann auf jede Position einer Bestellung anwenden, und der Compiler baut sie direkt in den erzeugten Code ein. Die Dokumentation zu Schema-Imports (Englisch) zeigt, wie das funktioniert.

Gut zu wissen

Im Browser ausprobieren

Am schnellsten bekommen Sie ein Gefühl für Kumi im interaktiven Playground. Dort gibt es eine Einführung in die Sprache und grössere Beispiele wie einen US-Steuerrechner und eine Lohnabrechnung über 10’000 Angestellte.

Kumi passt perfekt überall dort, wo dieselbe Berechnung auf dem Server und im Browser laufen muss: Preisrechner, Offertkonfiguratoren, Versandkosten. Ein Schema, zwei Sprachen, identische Ergebnisse. Ich freue mich darauf, es in eigenen Projekten einzusetzen, und bin gespannt, wie es weitergeht.