Testbarkeit
Pricing, Credit, Mapping und Persistenz getrennt testbar.
Eine zentrale Stateless Session Bean mischt Validierung, JPA, JDBC, SOAP, JMS, Audit, Rabatte, Fehlerbehandlung und Transaktionssteuerung in einer Methode.
| Aspekt | Beschreibung |
|---|---|
| Symptom | Eine Methode `createInvoice` hat zu viele Gründe, sich zu ändern: Fachregel, Datenzugriff, Integration, Audit, Message Publishing. |
| Risiko | Kleine Änderungen an Rabattlogik oder SOAP-Fehlern gefährden Transaktion, Rechnungsnummer und Versandereignis. |
| Refactoring-Ziel | Use Case bleibt fachlich gleich, aber Verantwortlichkeiten werden in Port/Adapter, Policy, Repository und Application Service getrennt. |
@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);
}
}
}
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());
}
}
// 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"));
}
}
Pricing, Credit, Mapping und Persistenz getrennt testbar.
Eine klare fachliche Transaktionsgrenze im Use Case.
Outbox macht Event-Verlust und Retry sichtbar.
Facade kann später REST, Batch oder Messaging bedienen.