Oracle / Stored Procedures · Prio 9 · Vorher/Nachher

Stored Procedure: Mahnlauf-Regeln herauslösen

Ein PL/SQL-Package entscheidet Mahnstufe, Gebühren, Sperren und Historie in einem datenbanknahen Block. Das Refactoring extrahiert Regeln kontrolliert in einen Domain Service.

← Refactoring Übersicht
Stored Procedure: Mahnlauf-Regeln herauslösen Diagramm
Fachlich-technischer Schnitt vom Legacy-Problem zur refactorbaren Zielstruktur.
VorherPL/SQL Package
Schritt 1Golden Master Dataset
Schritt 2Rule Specifications
NachherDomain Service + DB Adapter
Problemverständnis
AspektBeschreibung
SymptomFachregeln liegen in Cursor-Schleifen, Magic Numbers, Tabellenflags und Exception-Blöcken.
RisikoEine kleine Änderung am Mahnstatus wirkt auf Reporting, Sperrlogik, Schreiben von Historie und Monatsabschluss.
Refactoring-ZielRegeln lesbar machen, Golden-Master-Daten sichern und schrittweise neuen Domain Service parallel rechnen lassen.
Golden Master TestSpecificationRepositoryDomain ServiceAnti-Corruption LayerStrangler Fig
Vorher: kompakte, aber schwer änderbare PL/SQL-Logik
Warum kritisch: Dieser Code mischt Verantwortlichkeiten. Er ist nicht automatisch schlecht, aber Änderungen sind teuer, weil Fachlogik, Infrastruktur und Fehlerbehandlung verklebt sind.
Vorher: kompakte, aber schwer änderbare PL/SQL-Logik
CREATE OR REPLACE PACKAGE BODY DUNNING_PKG AS
  PROCEDURE RUN_DUNNING(p_run_id IN VARCHAR2) IS
    CURSOR c_open IS
      SELECT i.invoice_id, i.customer_id, i.due_date, i.amount_open,
             c.risk_class, c.vip_flag, c.blocked_flag,
             NVL(MAX(h.level_no), 0) AS last_level
        FROM invoice i
        JOIN customer c ON c.customer_id = i.customer_id
        LEFT JOIN dunning_history h ON h.invoice_id = i.invoice_id
       WHERE i.status = 'OPEN'
         AND i.amount_open > 0
         AND i.due_date < SYSDATE
       GROUP BY i.invoice_id, i.customer_id, i.due_date, i.amount_open,
                c.risk_class, c.vip_flag, c.blocked_flag;
    v_days_overdue NUMBER;
    v_next_level   NUMBER;
    v_fee          NUMBER;
  BEGIN
    FOR r IN c_open LOOP
      v_days_overdue := TRUNC(SYSDATE) - TRUNC(r.due_date);
      v_next_level := r.last_level;
      v_fee := 0;

      IF r.vip_flag = 'Y' THEN
        IF v_days_overdue > 30 AND r.amount_open > 1000 THEN
          v_next_level := 1;
          v_fee := 0;
        END IF;
      ELSE
        IF v_days_overdue BETWEEN 7 AND 13 THEN
          v_next_level := GREATEST(r.last_level, 1);
          v_fee := 5;
        ELSIF v_days_overdue BETWEEN 14 AND 29 THEN
          v_next_level := GREATEST(r.last_level, 2);
          v_fee := 12;
        ELSIF v_days_overdue >= 30 THEN
          v_next_level := GREATEST(r.last_level, 3);
          v_fee := CASE WHEN r.risk_class = 'HIGH' THEN 25 ELSE 18 END;
          UPDATE customer SET blocked_flag = 'Y' WHERE customer_id = r.customer_id;
        END IF;
      END IF;

      IF v_next_level > r.last_level THEN
        INSERT INTO dunning_history(run_id, invoice_id, customer_id, level_no, fee, created_at)
        VALUES(p_run_id, r.invoice_id, r.customer_id, v_next_level, v_fee, SYSTIMESTAMP);

        UPDATE invoice
           SET dunning_level = v_next_level,
               dunning_fee = NVL(dunning_fee,0) + v_fee,
               updated_at = SYSTIMESTAMP
         WHERE invoice_id = r.invoice_id;
      END IF;
    END LOOP;
    COMMIT;
  EXCEPTION
    WHEN OTHERS THEN
      INSERT INTO batch_error(run_id, error_text, created_at)
      VALUES(p_run_id, SQLERRM, SYSTIMESTAMP);
      ROLLBACK;
      RAISE;
  END;
END DUNNING_PKG;
Golden-Master-Vergleich für Mahnregeln
Refactoring-Regel: Erst Verhalten sichern, dann Struktur ändern. Ohne Golden Master oder Characterization Test kann ein schönes Design fachlich falsch sein.
Golden-Master-Vergleich für Mahnregeln
golden-master-dunning:
  business-date: 2026-07-07
  cases:
    - invoice: INV-1001
      customer: C-77
      vip: false
      days-overdue: 8
      last-level: 0
      expected-level: 1
      expected-fee: 5.00
    - invoice: INV-1002
      customer: C-88
      vip: true
      amount-open: 1200.00
      days-overdue: 31
      expected-level: 1
      expected-fee: 0.00
  compare:
    legacy-source: DUNNING_PKG.RUN_DUNNING
    new-source: DunningDecisionService
    tolerated-difference: none
Nachher: Domain Service entscheidet, Repository schreibt kontrolliert
Warum besser: Der fachliche Ablauf ist erkennbar. Technische Details sitzen hinter Ports/Adaptern, Regeln sind benannt und Tests können ohne kompletten Legacy-Stack laufen.
Nachher: Domain Service entscheidet, Repository schreibt kontrolliert
// Pattern: Domain Service - fachliche Entscheidung ohne JDBC/PLSQL-Seiteneffekte.
public final class DunningDecisionService {
    private final List<DunningSpecification> specifications;

    public DunningDecisionService() {
        this.specifications = List.of(
            new VipGracePeriodSpecification(),
            new FirstReminderSpecification(),
            new SecondReminderSpecification(),
            new FinalReminderSpecification()
        );
    }

    public DunningDecision decide(DunningCandidate candidate, LocalDate businessDate) {
        return specifications.stream()
            .filter(rule -> rule.matches(candidate, businessDate))
            .findFirst()
            .map(rule -> rule.decision(candidate, businessDate))
            .orElse(DunningDecision.noAction(candidate.invoiceId()));
    }
}

// Pattern: Specification - jede Regel ist benannt und einzeln testbar.
final class FinalReminderSpecification implements DunningSpecification {
    public boolean matches(DunningCandidate c, LocalDate businessDate) {
        return !c.isVip()
            && c.daysOverdue(businessDate) >= 30
            && c.lastLevel() < 3;
    }

    public DunningDecision decision(DunningCandidate c, LocalDate businessDate) {
        Money fee = c.riskClass() == RiskClass.HIGH ? Money.euros("25.00") : Money.euros("18.00");
        return DunningDecision.raiseToLevel(c.invoiceId(), 3, fee, true);
    }
}

@Stateless
public class DunningRunUseCase {
    @EJB private DunningCandidateRepository candidates; // Pattern: Repository
    @EJB private DunningHistoryRepository history;      // Pattern: Repository
    @EJB private CustomerBlockPort customerBlockPort;   // Pattern: Port/Adapter
    private final DunningDecisionService decisionService = new DunningDecisionService();

    @TransactionAttribute(TransactionAttributeType.REQUIRED)
    public DunningRunReport run(DunningRunId runId, LocalDate businessDate) {
        DunningRunReport report = new DunningRunReport(runId);
        for (DunningCandidate candidate : candidates.findOpenOverdueInvoices()) {
            DunningDecision decision = decisionService.decide(candidate, businessDate);
            if (decision.requiresHistoryEntry()) {
                history.append(runId, candidate, decision);
                candidates.applyDecision(candidate.invoiceId(), decision);
                if (decision.blockCustomer()) {
                    customerBlockPort.block(candidate.customerId(), "DUNNING_LEVEL_3");
                }
            }
            report.add(decision);
        }
        return report;
    }
}
Wirkung und Trade-offs

Regeltransparenz

Mahnregeln sind benannt statt in IF-Blöcken versteckt.

Parallelbetrieb

Alt-PL/SQL und neuer Service können Ergebnisse vergleichen.

Datenrisiko

DB-Schreiboperationen bleiben zunächst im Adapter kontrolliert.

Fachänderung

Neue Mahnstufe wird als neue Specification ergänzt.

Wichtig: Refactoring heißt nicht Big-Bang-Neuschreiben. Die alte Schnittstelle darf stabil bleiben, während innen schrittweise sauberere Strukturen entstehen.
⌂ Cockpit