Java EE / WebSphere Monolith · Prio 10 · Vorher/Nachher

Monster-EJB: Rechnung anlegen

Eine zentrale Stateless Session Bean mischt Validierung, JPA, JDBC, SOAP, JMS, Audit, Rabatte, Fehlerbehandlung und Transaktionssteuerung in einer Methode.

← Refactoring Übersicht
Monster-EJB: Rechnung anlegen Diagramm
Fachlich-technischer Schnitt vom Legacy-Problem zur refactorbaren Zielstruktur.
VorherMonster-Methode im EJB
Schritt 1Characterization Tests
Schritt 2Ports und Policies extrahieren
Nachherkleiner Application Service
Problemverständnis
AspektBeschreibung
SymptomEine Methode `createInvoice` hat zu viele Gründe, sich zu ändern: Fachregel, Datenzugriff, Integration, Audit, Message Publishing.
RisikoKleine Änderungen an Rabattlogik oder SOAP-Fehlern gefährden Transaktion, Rechnungsnummer und Versandereignis.
Refactoring-ZielUse Case bleibt fachlich gleich, aber Verantwortlichkeiten werden in Port/Adapter, Policy, Repository und Application Service getrennt.
FacadeApplication ServiceRepositoryPolicy/SpecificationAdapterOutbox PatternGolden Master Test
Vorher: typische Monster-Methode in einer Stateless Session Bean
Warum kritisch: Dieser Code mischt Verantwortlichkeiten. Er ist nicht automatisch schlecht, aber Änderungen sind teuer, weil Fachlogik, Infrastruktur und Fehlerbehandlung verklebt sind.
Vorher: typische Monster-Methode in einer Stateless Session Bean
@Stateless
@TransactionAttribute(TransactionAttributeType.REQUIRED)
public class LegacyInvoiceBean {

    @PersistenceContext(unitName = "BillingPU")
    private EntityManager em;

    @Resource(lookup = "jms/InvoiceEvents")
    private Queue invoiceQueue;

    @Resource
    private JMSContext jms;

    @EJB
    private AuditBean auditBean;

    @WebServiceRef
    private CustomerScoreService scoreService;

    public InvoiceResponse createInvoice(LegacyInvoiceRequest request) {
        long started = System.currentTimeMillis();
        Connection con = null;
        PreparedStatement ps = null;
        ResultSet rs = null;
        try {
            if (request == null) {
                return InvoiceResponse.error("REQ_NULL", "Request fehlt");
            }
            if (request.getCustomerNo() == null || request.getCustomerNo().trim().length() == 0) {
                auditBean.write("INVOICE_REJECTED", "Kundennummer fehlt");
                return InvoiceResponse.error("CUST_MISSING", "Kundennummer fehlt");
            }
            if (request.getLines() == null || request.getLines().isEmpty()) {
                return InvoiceResponse.error("NO_LINES", "Keine Positionen");
            }

            CustomerEntity customer = em.createQuery(
                    "select c from CustomerEntity c where c.customerNo = :no", CustomerEntity.class)
                .setParameter("no", request.getCustomerNo())
                .setLockMode(LockModeType.PESSIMISTIC_WRITE)
                .getSingleResult();

            if (customer.isBlocked()) {
                auditBean.write("INVOICE_REJECTED", "Kunde gesperrt: " + customer.getCustomerNo());
                return InvoiceResponse.error("CUSTOMER_BLOCKED", "Kunde ist gesperrt");
            }

            int score = scoreService.getPort().score(customer.getCustomerNo(), request.getTotalGross());
            if (score < 30 && request.getTotalGross().compareTo(new BigDecimal("500.00")) > 0) {
                return InvoiceResponse.error("CREDIT_LIMIT", "Bonitaetsgrenze erreicht");
            }

            BigDecimal net = BigDecimal.ZERO;
            BigDecimal tax = BigDecimal.ZERO;
            for (LegacyInvoiceLine line : request.getLines()) {
                if (line.getArticleNo() == null) {
                    return InvoiceResponse.error("ARTICLE_MISSING", "Artikel fehlt");
                }
                if (line.getQuantity() <= 0) {
                    return InvoiceResponse.error("QTY_INVALID", "Menge ungueltig");
                }
                BigDecimal lineNet = line.getUnitPrice().multiply(BigDecimal.valueOf(line.getQuantity()));
                if (customer.isPremium() && lineNet.compareTo(new BigDecimal("1000")) > 0) {
                    lineNet = lineNet.multiply(new BigDecimal("0.97")); // Rabatt versteckt in Service-Methode
                }
                net = net.add(lineNet);
                tax = tax.add(lineNet.multiply(new BigDecimal("0.20")));
            }

            // Direkter JDBC-Zugriff neben JPA, weil alte Sequenz ueber Stored Procedure kommt.
            con = em.unwrap(Connection.class);
            ps = con.prepareStatement("select BILLING_SEQ_PKG.next_invoice_no(?) from dual");
            ps.setString(1, request.getChannel());
            rs = ps.executeQuery();
            rs.next();
            String invoiceNo = rs.getString(1);

            InvoiceEntity invoice = new InvoiceEntity();
            invoice.setInvoiceNo(invoiceNo);
            invoice.setCustomer(customer);
            invoice.setNetAmount(net);
            invoice.setTaxAmount(tax);
            invoice.setGrossAmount(net.add(tax));
            invoice.setCreatedBy(request.getUser());
            invoice.setStatus("CREATED");
            em.persist(invoice);

            for (LegacyInvoiceLine line : request.getLines()) {
                InvoiceLineEntity entity = new InvoiceLineEntity();
                entity.setInvoice(invoice);
                entity.setArticleNo(line.getArticleNo());
                entity.setQuantity(line.getQuantity());
                entity.setUnitPrice(line.getUnitPrice());
                em.persist(entity);
            }

            TextMessage msg = jms.createTextMessage("{\"invoiceNo\":\"" + invoiceNo + "\"}");
            msg.setStringProperty("eventType", "InvoiceCreated");
            msg.setStringProperty("correlationId", request.getCorrelationId());
            jms.createProducer().send(invoiceQueue, msg);

            auditBean.write("INVOICE_CREATED", invoiceNo + " in " + (System.currentTimeMillis() - started) + "ms");
            return InvoiceResponse.ok(invoiceNo, net, tax);
        } catch (NoResultException ex) {
            return InvoiceResponse.error("CUSTOMER_UNKNOWN", "Kunde nicht gefunden");
        } catch (WebServiceException ex) {
            // Gefahr: fachlich unklar, ob Rechnung bereits teilweise angelegt wurde.
            throw new EJBException("Scoring-Service nicht erreichbar", ex);
        } catch (Exception ex) {
            auditBean.write("INVOICE_FAILED", ex.getMessage());
            throw new EJBException(ex);
        } finally {
            closeQuietly(rs);
            closeQuietly(ps);
        }
    }
}
Absicherung: Characterization Test vor dem Refactoring
Refactoring-Regel: Erst Verhalten sichern, dann Struktur ändern. Ohne Golden Master oder Characterization Test kann ein schönes Design fachlich falsch sein.
Absicherung: Characterization Test vor dem Refactoring
class LegacyInvoiceCharacterizationTest {
    @Test
    void premiumCustomerGetsSameDiscountAsLegacyBean() {
        LegacyInvoiceRequest request = fixture("premium-customer-large-invoice.json");

        InvoiceResponse legacy = legacyHarness.callLegacyBean(request);
        InvoiceResponse refactored = refactoredFacade.createInvoice(request);

        assertThat(refactored.getCode()).isEqualTo(legacy.getCode());
        assertThat(refactored.getNetAmount()).isEqualByComparingTo(legacy.getNetAmount());
        assertThat(refactored.getTaxAmount()).isEqualByComparingTo(legacy.getTaxAmount());
    }
}
Nachher: Use Case mit klaren Ports, Policies und Outbox
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: Use Case mit klaren Ports, Policies und Outbox
// Pattern: Facade - stabiler Einstieg fuer UI, SOAP oder Batch.
@Stateless
public class InvoiceApplicationFacade {
    @EJB private CreateInvoiceUseCase useCase;

    public InvoiceResponse createInvoice(LegacyInvoiceRequest legacyRequest) {
        CreateInvoiceCommand command = LegacyInvoiceMapper.toCommand(legacyRequest); // Pattern: Adapter
        CreateInvoiceResult result = useCase.handle(command);
        return LegacyInvoiceMapper.toResponse(result); // Anti-Corruption Layer
    }
}

@Stateless
@TransactionAttribute(TransactionAttributeType.REQUIRED)
public class CreateInvoiceUseCase {
    @EJB private CustomerRepository customerRepository;      // Pattern: Repository
    @EJB private InvoiceRepository invoiceRepository;        // Pattern: Repository
    @EJB private InvoiceNumberPort invoiceNumberPort;        // Pattern: Port/Adapter
    @EJB private CreditScorePort creditScorePort;            // Pattern: Port/Adapter
    @EJB private InvoicePricingPolicy pricingPolicy;         // Pattern: Policy/Specification
    @EJB private OutboxRepository outboxRepository;          // Pattern: Outbox

    public CreateInvoiceResult handle(CreateInvoiceCommand command) {
        command.validateTechnicalShape();

        Customer customer = customerRepository.loadForUpdate(command.customerNo());
        if (customer.isBlocked()) {
            return CreateInvoiceResult.rejected("CUSTOMER_BLOCKED");
        }

        CreditDecision credit = creditScorePort.check(customer.number(), command.estimatedGross());
        if (credit.isRejected()) {
            return CreateInvoiceResult.rejected(credit.reasonCode());
        }

        PricedInvoice priced = pricingPolicy.price(command.lines(), customer.customerClass());
        InvoiceNumber invoiceNo = invoiceNumberPort.next(command.channel());
        Invoice invoice = Invoice.create(invoiceNo, customer.id(), priced, command.auditUser());
        invoiceRepository.save(invoice);

        // DB-Commit und Event-Publikation werden entkoppelt, aber konsistent nachvollziehbar.
        outboxRepository.add(OutboxEvent.invoiceCreated(invoice.id(), invoice.number(), command.correlationId()));
        return CreateInvoiceResult.created(invoice.number(), priced.net(), priced.tax());
    }
}

@Stateless
public class InvoicePricingPolicy {
    public PricedInvoice price(List<CreateInvoiceLine> lines, CustomerClass customerClass) {
        Money net = Money.zero();
        for (CreateInvoiceLine line : lines) {
            line.validateBusinessRules();
            Money lineNet = line.unitPrice().multiply(line.quantity());
            if (customerClass == CustomerClass.PREMIUM && lineNet.isGreaterThan(Money.euros("1000.00"))) {
                lineNet = lineNet.discountPercent("3.0");
            }
            net = net.add(lineNet);
        }
        return new PricedInvoice(net, net.tax("20.0"));
    }
}
Wirkung und Trade-offs

Testbarkeit

Pricing, Credit, Mapping und Persistenz getrennt testbar.

Transaktion

Eine klare fachliche Transaktionsgrenze im Use Case.

Betrieb

Outbox macht Event-Verlust und Retry sichtbar.

Migration

Facade kann später REST, Batch oder Messaging bedienen.

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