byteNative
Entwicklung

Preislogik gehört nicht in den Controller

Jede gewachsene Anwendung hat eine Handvoll Regeln, bei denen ein Fehler direkt Geld kostet. Und genau die stehen erfahrungsgemäß an drei Stellen gleichzeitig, leicht unterschiedlich.

10 Min. LesezeitStand April 2026

Woran du es merkst

Die Preisberechnung steht im Controller. Eine leicht abweichende Variante steht im Frontend, damit die Vorschau schnell ist. Und für den nächtlichen Export gibt es noch eine dritte in einer Datenbankprozedur, die jemand vor vier Jahren geschrieben hat.

Solange alle drei dasselbe Ergebnis liefern, fällt es nicht auf. Sie tun es nie dauerhaft. Irgendwann meldet ein Kunde, dass sein Angebot einen anderen Betrag zeigt als seine Rechnung, und dann beginnt die Suche danach, welche der drei Fassungen eigentlich die richtige ist.

Das ist der Punkt, an dem es sich lohnt, diese Regeln aus der Anwendung herauszunehmen. Nicht aus Ordnungsliebe, sondern weil du sie sonst nicht testen kannst.

Vorher: Regel und Rahmenwerk verwoben

Der übliche Zustand. Die Regel selbst ist vielleicht zehn Zeilen wert, steckt aber zwischen Datenbankzugriff, HTTP-Antwort und Protokollierung. Um sie zu testen, brauchst du eine laufende Datenbank, einen angemeldeten Benutzer und einen HTTP-Client. Deshalb testet sie niemand.

ts
@Post("quote")
async createQuote(@Body() dto: QuoteDto, @Req() req: Request) {
    const customer = await this.customers.findOne(dto.customerId);
    const items = await this.items.findByIds(dto.itemIds);

    let total = 0;
    for (const item of items) {
        let price = item.basePrice;
        if (customer.tier === "gold") price = price * 0.85;
        else if (customer.tier === "silver") price = price * 0.92;
        if (dto.quantity >= 100) price = price * 0.95;
        total += price * dto.quantity;
    }
    if (total > 10000) total = total * 0.98;

    await this.audit.log(req.user.id, "quote.created", total);
    return { total: Math.round(total * 100) / 100 };
}
Die eigentliche Regel sind acht Zeilen. Testbar ist sie hier trotzdem nur mit halber Infrastruktur.

Nachher: eine reine Funktion mit eigenen Typen

Die Regel zieht in ein eigenes Paket, das nichts kennt außer seinen eigenen Ein- und Ausgabetypen. Keine Datenbank, kein Nest, kein React, keine Uhr. Was es nach außen braucht, etwa das Datum, bekommt es übergeben.

Der zweite Gewinn neben der Testbarkeit ist die Nachvollziehbarkeit. Wenn das Modul nicht nur die Summe, sondern auch die angewandten Schritte zurückgibt, kannst du einem Kunden erklären, wie sein Preis zustande kam, ohne im Code nachzusehen.

ts
// packages/pricing/src/index.ts
export type PricingInput = {
    items: readonly { id: string; basePrice: number; quantity: number }[];
    customerTier: CustomerTier;
    // hereingereicht statt intern gelesen, sonst ist nichts reproduzierbar
    validAt: Date;
};

export type PricingResult = {
    total: number;
    // jeder schritt einzeln, damit ein preis erklaerbar bleibt
    steps: readonly { label: string; delta: number }[];
};

const TIER_DISCOUNT: Record<CustomerTier, number> = {
    gold: 0.15,
    silver: 0.08,
    standard: 0,
};

export function calculatePrice({ items, customerTier, validAt }: PricingInput): PricingResult {
    const steps: { label: string; delta: number }[] = [];
    const gross = items.reduce((sum, item) => sum + item.basePrice * item.quantity, 0);

    const tierDelta = -gross * TIER_DISCOUNT[customerTier];
    if (tierDelta !== 0) steps.push({ label: "Kundenstufe " + customerTier, delta: tierDelta });

    const quantity = items.reduce((sum, item) => sum + item.quantity, 0);
    const bulkDelta = quantity >= 100 ? -(gross + tierDelta) * 0.05 : 0;
    if (bulkDelta !== 0) steps.push({ label: "Mengenrabatt", delta: bulkDelta });

    const total = round(gross + tierDelta + bulkDelta);
    return { total, steps };
}
Kein Import aus dem Rahmenwerk, kein Zugriff auf die Uhr. Alles, was das Ergebnis beeinflusst, kommt ueber die Parameter herein.

Erst jetzt sind Tests billig

Ohne Infrastruktur wird ein Test zu drei Zeilen, und plötzlich lohnt es sich, auch die unangenehmen Fälle abzudecken: den Betrag genau an der Rabattgrenze, die leere Bestellung, die Rundung auf halbe Cent. Genau dort sitzen die Fehler, die in Produktion weh tun.

Was sich dabei bewährt hat: die realen Streitfälle aus dem Support als Testfälle aufnehmen. Jede Rechnung, über die einmal diskutiert wurde, ist ein Testfall, der nie wieder auftreten darf.

ts
describe("calculatePrice", () => {
    it("staffelt an der grenze korrekt", () => {
        const below = calculatePrice({ ...base, items: [item({ quantity: 99 })] });
        const above = calculatePrice({ ...base, items: [item({ quantity: 100 })] });

        expect(below.steps.map((s) => s.label)).not.toContain("Mengenrabatt");
        expect(above.steps.map((s) => s.label)).toContain("Mengenrabatt");
    });

    it("rundet auf zwei nachkommastellen", () => {
        const result = calculatePrice({ ...base, items: [item({ basePrice: 33.333, quantity: 3 })] });
        expect(result.total).toBe(100);
    });

    // aus einem echten support-fall vom 12.02.
    it("kombiniert kundenstufe und mengenrabatt multiplikativ", () => {
        const result = calculatePrice({ ...base, customerTier: "gold", items: [item({ quantity: 120 })] });
        expect(result.total).toBe(969);
    });
});
Streitfaelle aus dem Support als Testfaelle aufzunehmen ist die guenstigste Qualitaetsmassnahme, die es gibt.

Versionieren, ausrollen, alte Fälle nachrechnen

Sobald die Regeln in einem eigenen Paket liegen, kannst du sie versionieren. Das klingt nach Bürokratie und ist der eigentliche Gewinn: Wenn sich zum ersten Januar eine Rabattstaffel ändert, willst du Rechnungen aus dem Vorjahr weiterhin mit den alten Regeln nachrechnen können.

In der Praxis reicht dafür meist, das Gültigkeitsdatum als Parameter zu führen und die Staffeln mit Zeiträumen zu versehen, statt sie zu überschreiben. Aufwendiger, aber unvermeidbar, wenn Buchhaltung oder Prüfer nachvollziehen müssen, wie ein Betrag zustande kam.

Im Monorepo liegt so ein Paket unter `packages/`, wird von Backend und Frontend gleichermaßen importiert, und die Vorschau im Browser rechnet garantiert dasselbe wie der Server. Damit ist die eingangs beschriebene Dreifach-Implementierung dauerhaft erledigt.

Häufige Fragen

Lohnt sich ein eigenes Paket schon bei einer kleinen Anwendung?

Wenn an der Logik Geld oder Fristen hängen, ja, und zwar von Anfang an. Es kostet dich eine Datei mehr. Nachträglich herauszulösen ist deutlich teurer, weil sich die Regel bis dahin in drei Varianten aufgeteilt hat.

Muss das wirklich ein separates npm-Paket sein?

Nicht zwingend. Ein eigener Ordner mit klarer Grenze und einer Lint-Regel gegen Fremdimporte bringt fast denselben Nutzen. Ein echtes Paket lohnt sich, sobald mehrere Anwendungen es benutzen oder du versionieren willst.

Wie hoch sollte die Testabdeckung darin sein?

In diesem einen Modul nahe hundert Prozent, und das ist realistisch, weil es keine Infrastruktur hat. Für den Rest der Anwendung ist eine solche Vorgabe meist Verschwendung. Der Punkt ist ja gerade, den kritischen Teil zu isolieren, damit man ihn anders behandeln kann.

Was ist mit Regeln, die aus der Datenbank kommen?

Die Daten dürfen aus der Datenbank kommen, die Auswertung nicht. Lade die Staffeln, reich sie als Parameter herein und lass das Modul rechnen. So bleibt es testbar, und die Konfiguration bleibt trotzdem änderbar, ohne dass jemand deployen muss.

Dieser Beitrag gibt den Stand von April 2026 wieder und ersetzt keine Rechtsberatung. Für verbindliche Auskünfte zu deinem konkreten Fall wende dich an eine Anwältin oder einen Anwalt.

Unsicher, wo deine Seite steht?

Wir schauen einmal drüber und sagen dir ehrlich, was zu tun ist. Das Erstgespräch kostet dich nichts.