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.
@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 };
}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.
// 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 };
}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.
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);
});
});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.