#Zielplattform Spring Boot
Migration des Plain-Java-Kerns nach Spring Boot mit REST, JPA, JMS, Scheduler und Transaktionen.
#Spring-Boot-Zielimplementierung für das Mini-Repo
Dieser Abschnitt zeigt, wie der zuvor extrahierte Plain-Java-Kern aus dem Legacy-EJB/SOAP/JTA/JMS/JSP-Beispiel in eine Spring-Boot-Zielimplementierung eingebettet werden kann.
Wichtig: Der fachliche Kern bleibt möglichst unverändert. Spring Boot kommt außen herum als Laufzeit-, Transaktions-, Web-, Persistence-, Scheduler- und Messaging-Plattform hinzu.
Nicht:
Legacy EJB löschen und alles neu schreiben.
Sondern:
Plain-Java-Kern behalten
↓
Spring Adapter ergänzen
↓
Transaktionsgrenzen sauber setzen
↓
Legacy Entry Points schrittweise ersetzen
#Ziel der Spring-Boot-Implementierung
Ziel ist nicht, sofort ein perfektes Microservice-System zu bauen.
Ziel ist:
CreateOrderUseCase bleibt Plain Java.
PaymentRequestedHandler bleibt Plain Java.
OrderPaidMessageHandler bleibt Plain Java.
Spring Boot übernimmt:
- REST Entry Point
- Dependency Injection
- Transaktionsgrenzen
- JPA Integration
- Scheduler
- JMS Listener
- Konfiguration
- Observability Hooks
Damit entsteht diese Zielarchitektur:
HTTP REST Controller
↓
Spring Application Service / Facade
↓
@Transactional Boundary
↓
Plain Java Use Case
↓
Ports
↓
Spring Adapter:
- JPA Repository Adapter
- SOAP/HTTP Payment Adapter
- Outbox JPA Adapter
- JMS Publisher
#Zielstruktur des Spring-Boot-Projekts
Empfohlene Struktur:
legacy-modernization-demo/
pom.xml
src/
main/
java/
com/example/ordermodernization/
OrderModernizationApplication.java
order/
application/
CreateOrderUseCase.java
CreateOrderCommand.java
CreateOrderResult.java
PaymentRequestedHandler.java
OrderPaidMessageHandler.java
domain/
Order.java
OrderId.java
OrderStatus.java
Money.java
ports/
OrderRepository.java
PaymentGateway.java
AuditPort.java
OutboxPort.java
ProcessedMessageRepository.java
InvoiceRepository.java
adapters/
web/
OrderController.java
CreateOrderHttpRequest.java
CreateOrderHttpResponse.java
OrderWebMapper.java
persistence/
OrderEntity.java
OutboxEventEntity.java
InvoiceEntity.java
ProcessedMessageEntity.java
SpringJpaOrderRepository.java
SpringJpaOutboxRepository.java
SpringJpaInvoiceRepository.java
SpringJpaProcessedMessageRepository.java
payment/
SpringSoapPaymentGateway.java
PaymentClientProperties.java
messaging/
SpringJmsOrderPaidPublisher.java
SpringJmsOrderPaidListener.java
OrderPaidMessageMapper.java
scheduling/
PaymentRequestedScheduler.java
OutboxPublisherScheduler.java
audit/
LoggingAuditAdapter.java
config/
OrderBeanConfiguration.java
TransactionConfiguration.java
test/
java/
com/example/ordermodernization/
order/
application/
adapters/
Regel:
application, domain und ports bleiben framework-arm.
adapters und config dürfen Spring kennen.
#Maven-Grundstruktur
Beispielhafte pom.xml für die Übung:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>legacy-modernization-demo</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<spring.boot.version>${spring.boot.version}</spring.boot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring.boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-activemq</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Hinweis:
Im echten Projekt die Spring-Boot-Version zentral und bewusst festlegen.
Nicht blind die neueste Version nutzen, wenn Altbibliotheken oder App-Server-Migrationen betroffen sind.
#Application Entry Point
package com.example.ordermodernization;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.jms.annotation.EnableJms;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@EnableJms
public class OrderModernizationApplication {
public static void main(String[] args) {
SpringApplication.run(OrderModernizationApplication.class, args);
}
}
Damit ersetzt du langfristig:
EAR/WAR Deployment auf Application Server
↓
executable Spring Boot application
Aber erst, wenn der Slice ausreichend abgesichert ist.
#application.yml
Beispiel:
server:
port: 8080
spring:
datasource:
url: jdbc:h2:mem:orders;DB_CLOSE_DELAY=-1
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
format_sql: true
jms:
listener:
acknowledge-mode: auto
order:
payment:
endpoint-url: http://localhost:9090/payment
timeout-millis: 5000
outbox:
payment-requested:
batch-size: 20
publisher:
batch-size: 50
Wichtiger Legacy-Vergleich:
Alt:
JNDI, Server-Konsole, XML-Deskriptoren, manuelle Datasource-Konfiguration
Neu:
application.yml, Environment Variables, Secrets, Profile
#Bean-Konfiguration für den Plain-Java-Kern
Die Use Cases bleiben ohne Spring-Annotationen.
package com.example.ordermodernization.order.config;
import com.example.ordermodernization.order.application.CreateOrderUseCase;
import com.example.ordermodernization.order.application.PaymentRequestedHandler;
import com.example.ordermodernization.order.application.OrderPaidMessageHandler;
import com.example.ordermodernization.order.ports.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OrderBeanConfiguration {
@Bean
CreateOrderUseCase createOrderUseCase(
OrderRepository orderRepository,
AuditPort auditPort,
OutboxPort outboxPort
) {
return new CreateOrderUseCase(
orderRepository,
auditPort,
outboxPort
);
}
@Bean
PaymentRequestedHandler paymentRequestedHandler(
OrderRepository orderRepository,
PaymentGateway paymentGateway,
AuditPort auditPort,
OutboxPort outboxPort
) {
return new PaymentRequestedHandler(
orderRepository,
paymentGateway,
auditPort,
outboxPort
);
}
@Bean
OrderPaidMessageHandler orderPaidMessageHandler(
ProcessedMessageRepository processedMessageRepository,
InvoiceRepository invoiceRepository,
CreateInvoiceUseCase createInvoiceUseCase
) {
return new OrderPaidMessageHandler(
processedMessageRepository,
invoiceRepository,
createInvoiceUseCase
);
}
}
Warum so?
Der Kern bleibt testbar ohne Spring.
Spring verdrahtet nur die Abhängigkeiten.
#REST Controller als Ersatz für JSP/SOAP Entry Point
Legacy Entry:
create-order.jsp
↓
JNDI Lookup
↓
OrderServiceBean#createOrder
Neuer Entry:
POST /orders
↓
OrderController
↓
CreateOrderApplicationService
↓
CreateOrderUseCase
Controller:
package com.example.ordermodernization.order.adapters.web;
import com.example.ordermodernization.order.application.CreateOrderCommand;
import com.example.ordermodernization.order.application.CreateOrderUseCase;
import com.example.ordermodernization.order.domain.OrderId;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/orders")
public class OrderController {
private final CreateOrderApplicationService applicationService;
public OrderController(CreateOrderApplicationService applicationService) {
this.applicationService = applicationService;
}
@PostMapping
public ResponseEntity<CreateOrderHttpResponse> createOrder(
@Valid @RequestBody CreateOrderHttpRequest request
) {
OrderId orderId = applicationService.createOrder(
new CreateOrderCommand(
request.customerId(),
request.amount()
)
);
return ResponseEntity.accepted().body(
new CreateOrderHttpResponse(orderId.value(), "PAYMENT_PENDING")
);
}
}
DTO:
package com.example.ordermodernization.order.adapters.web;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
public record CreateOrderHttpRequest(
@NotBlank String customerId,
@NotNull @DecimalMin("0.01") BigDecimal amount
) {
}
Response:
package com.example.ordermodernization.order.adapters.web;
public record CreateOrderHttpResponse(
String orderId,
String status
) {
}
#Application Service als Transaktionsgrenze
Der Plain-Java-Use-Case bleibt ohne @Transactional.
Die Transaktion liegt außen:
package com.example.ordermodernization.order.adapters.web;
import com.example.ordermodernization.order.application.CreateOrderCommand;
import com.example.ordermodernization.order.application.CreateOrderUseCase;
import com.example.ordermodernization.order.domain.OrderId;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CreateOrderApplicationService {
private final CreateOrderUseCase createOrderUseCase;
public CreateOrderApplicationService(CreateOrderUseCase createOrderUseCase) {
this.createOrderUseCase = createOrderUseCase;
}
@Transactional
public OrderId createOrder(CreateOrderCommand command) {
return createOrderUseCase.execute(command);
}
}
Mapping vom EJB-Legacy:
@Stateless
@TransactionAttribute(REQUIRED)
OrderServiceBean#createOrder
wird zu:
@Service
@Transactional
CreateOrderApplicationService#createOrder
Wichtig:
Nicht jede Repository-Methode bekommt automatisch eigene Transaktionen.
Der Use Case definiert die fachliche Transaktion.
#Transaction Mapping von EJB nach Spring
| EJB TransactionAttribute | Spring Propagation | Bemerkung |
|---|---|---|
| REQUIRED | REQUIRED | Standardfall |
| REQUIRES_NEW | REQUIRES_NEW | für Audit/Status-Updates bewusst setzen |
| MANDATORY | MANDATORY | nur innerhalb vorhandener TX erlaubt |
| SUPPORTS | SUPPORTS | nutzt TX, falls vorhanden |
| NOT_SUPPORTED | NOT_SUPPORTED | läuft ohne TX |
| NEVER | NEVER | Fehler, falls TX vorhanden |
Beispiel für REQUIRES_NEW:
@Service
public class PaymentStatusApplicationService {
private final OrderRepository orderRepository;
private final AuditPort auditPort;
private final OutboxPort outboxPort;
public PaymentStatusApplicationService(
OrderRepository orderRepository,
AuditPort auditPort,
OutboxPort outboxPort
) {
this.orderRepository = orderRepository;
this.auditPort = auditPort;
this.outboxPort = outboxPort;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markPaid(OrderId orderId, String providerReference) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.markPaid();
orderRepository.update(order);
auditPort.orderPaid(order.id());
outboxPort.store(OutboxEvent.orderPaid(order));
}
}
Achtung:
Spring-Transaktionen sind proxybasiert.
Self-Invocation kann Transaktionen umgehen.
Schlecht:
public void process() {
markPaid(...); // interner Aufruf, Proxy wird umgangen
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markPaid(...) {
}
Besser:
REQUIRES_NEW-Methode in separaten Spring-Service legen
und von außen injiziert aufrufen.
#JPA Entity für Order
package com.example.ordermodernization.order.adapters.persistence;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "orders")
public class OrderEntity {
@Id
private String id;
@Column(name = "customer_id", nullable = false)
private String customerId;
@Column(name = "amount", nullable = false)
private BigDecimal amount;
@Column(name = "status", nullable = false)
private String status;
protected OrderEntity() {
}
// Getter und Setter
}
Mapper:
package com.example.ordermodernization.order.adapters.persistence;
import com.example.ordermodernization.order.domain.*;
public final class OrderEntityMapper {
private OrderEntityMapper() {
}
public static OrderEntity toEntity(Order order) {
OrderEntity entity = new OrderEntity();
entity.setId(order.id().value());
entity.setCustomerId(order.customerId());
entity.setAmount(order.amount());
entity.setStatus(order.status().name());
return entity;
}
public static Order toDomain(OrderEntity entity) {
return Order.restore(
OrderId.of(entity.getId()),
entity.getCustomerId(),
entity.getAmount(),
OrderStatus.valueOf(entity.getStatus())
);
}
}
Wichtig:
JPA Entity bleibt Persistenzmodell.
Order bleibt Domain-Modell.
#Spring Data Repository plus Port-Adapter
Spring Data Interface:
package com.example.ordermodernization.order.adapters.persistence;
import org.springframework.data.jpa.repository.JpaRepository;
interface SpringDataOrderJpaRepository extends JpaRepository<OrderEntity, String> {
}
Port-Adapter:
package com.example.ordermodernization.order.adapters.persistence;
import com.example.ordermodernization.order.domain.Order;
import com.example.ordermodernization.order.domain.OrderId;
import com.example.ordermodernization.order.ports.OrderRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public class SpringJpaOrderRepository implements OrderRepository {
private final SpringDataOrderJpaRepository repository;
public SpringJpaOrderRepository(SpringDataOrderJpaRepository repository) {
this.repository = repository;
}
@Override
public void save(Order order) {
repository.save(OrderEntityMapper.toEntity(order));
}
@Override
public void update(Order order) {
repository.save(OrderEntityMapper.toEntity(order));
}
@Override
public Optional<Order> findById(OrderId orderId) {
return repository.findById(orderId.value())
.map(OrderEntityMapper::toDomain);
}
}
Dadurch bleibt dein Use Case abhängig von:
OrderRepository
Nicht von:
JpaRepository
EntityManager
Hibernate
Spring Data
#Outbox mit Spring JPA
Entity:
package com.example.ordermodernization.order.adapters.persistence;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "outbox_event")
public class OutboxEventEntity {
@Id
private String id;
@Column(name = "aggregate_id", nullable = false)
private String aggregateId;
@Column(name = "aggregate_type", nullable = false)
private String aggregateType;
@Column(name = "event_type", nullable = false)
private String eventType;
@Lob
@Column(name = "payload", nullable = false)
private String payload;
@Column(name = "status", nullable = false)
private String status;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "published_at")
private Instant publishedAt;
@Column(name = "retry_count", nullable = false)
private int retryCount;
@Lob
@Column(name = "last_error")
private String lastError;
protected OutboxEventEntity() {
}
// Getter und Setter
}
Spring Data:
package com.example.ordermodernization.order.adapters.persistence;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
interface SpringDataOutboxJpaRepository extends JpaRepository<OutboxEventEntity, String> {
List<OutboxEventEntity> findTop50ByStatusAndEventTypeOrderByCreatedAtAsc(
String status,
String eventType
);
}
Adapter:
package com.example.ordermodernization.order.adapters.persistence;
import com.example.ordermodernization.order.application.OutboxEvent;
import com.example.ordermodernization.order.ports.OutboxPort;
import org.springframework.stereotype.Repository;
@Repository
public class SpringJpaOutboxRepository implements OutboxPort {
private final SpringDataOutboxJpaRepository repository;
public SpringJpaOutboxRepository(SpringDataOutboxJpaRepository repository) {
this.repository = repository;
}
@Override
public void store(OutboxEvent event) {
OutboxEventEntity entity = new OutboxEventEntity();
entity.setId(event.id());
entity.setAggregateId(event.aggregateId());
entity.setAggregateType(event.aggregateType());
entity.setEventType(event.eventType());
entity.setPayload(event.payload());
entity.setStatus("PENDING");
entity.setCreatedAt(event.createdAt());
entity.setRetryCount(0);
repository.save(entity);
}
}
#Payment Gateway als Spring Adapter
Port:
public interface PaymentGateway {
PaymentResult charge(PaymentCommand command);
}
Properties:
package com.example.ordermodernization.order.adapters.payment;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "order.payment")
public class PaymentClientProperties {
private String endpointUrl;
private int timeoutMillis;
public String getEndpointUrl() {
return endpointUrl;
}
public void setEndpointUrl(String endpointUrl) {
this.endpointUrl = endpointUrl;
}
public int getTimeoutMillis() {
return timeoutMillis;
}
public void setTimeoutMillis(int timeoutMillis) {
this.timeoutMillis = timeoutMillis;
}
}
SOAP/HTTP Adapter, vereinfacht:
package com.example.ordermodernization.order.adapters.payment;
import com.example.ordermodernization.order.application.PaymentCommand;
import com.example.ordermodernization.order.application.PaymentResult;
import com.example.ordermodernization.order.ports.PaymentGateway;
import org.springframework.stereotype.Component;
@Component
public class SpringSoapPaymentGateway implements PaymentGateway {
private final PaymentClientProperties properties;
public SpringSoapPaymentGateway(PaymentClientProperties properties) {
this.properties = properties;
}
@Override
public PaymentResult charge(PaymentCommand command) {
// In der echten Implementierung:
// - SOAP Client aufrufen
// - Timeout setzen
// - SOAP Faults mappen
// - Provider Reference speichern
// - Idempotency Key übertragen, falls Provider es unterstützt
return PaymentResult.success("provider-reference-demo");
}
}
Wichtig:
PaymentGateway mappt technische Provider-Fehler in fachliche Fehler.
Der Use Case kennt keine SOAPFaultException.
#Scheduler für PaymentRequested Events
package com.example.ordermodernization.order.adapters.scheduling;
import com.example.ordermodernization.order.application.PaymentRequestedEvent;
import com.example.ordermodernization.order.application.PaymentRequestedHandler;
import com.example.ordermodernization.order.adapters.persistence.OutboxEventEntity;
import com.example.ordermodernization.order.adapters.persistence.SpringDataOutboxJpaRepository;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.List;
@Component
public class PaymentRequestedScheduler {
private final SpringDataOutboxJpaRepository outboxRepository;
private final PaymentRequestedHandler handler;
public PaymentRequestedScheduler(
SpringDataOutboxJpaRepository outboxRepository,
PaymentRequestedHandler handler
) {
this.outboxRepository = outboxRepository;
this.handler = handler;
}
@Scheduled(fixedDelayString = "${outbox.payment-requested.fixed-delay:30000}")
public void processPaymentRequestedEvents() {
List<OutboxEventEntity> events = outboxRepository
.findTop50ByStatusAndEventTypeOrderByCreatedAtAsc(
"PENDING",
"PaymentRequested"
);
for (OutboxEventEntity event : events) {
processOne(event);
}
}
private void processOne(OutboxEventEntity event) {
try {
event.setStatus("PROCESSING");
outboxRepository.save(event);
PaymentRequestedEvent paymentEvent = PaymentRequestedEvent.fromJson(event.getPayload());
handler.handle(paymentEvent);
event.setStatus("PUBLISHED");
event.setPublishedAt(Instant.now());
outboxRepository.save(event);
} catch (Exception ex) {
event.setRetryCount(event.getRetryCount() + 1);
event.setLastError(ex.getMessage());
if (event.getRetryCount() >= 10) {
event.setStatus("DEAD");
} else {
event.setStatus("PENDING");
}
outboxRepository.save(event);
}
}
}
Achtung:
Für Produktion brauchst du Locking gegen parallele Scheduler-Instanzen.
Beispiele:
- SELECT FOR UPDATE SKIP LOCKED
- Status PROCESSING mit Timeout
- ShedLock
- DB-basiertes Claiming
#Outbox Publisher für OrderPaid Events
package com.example.ordermodernization.order.adapters.scheduling;
import com.example.ordermodernization.order.adapters.messaging.SpringJmsOrderPaidPublisher;
import com.example.ordermodernization.order.adapters.persistence.OutboxEventEntity;
import com.example.ordermodernization.order.adapters.persistence.SpringDataOutboxJpaRepository;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.List;
@Component
public class OutboxPublisherScheduler {
private final SpringDataOutboxJpaRepository outboxRepository;
private final SpringJmsOrderPaidPublisher publisher;
public OutboxPublisherScheduler(
SpringDataOutboxJpaRepository outboxRepository,
SpringJmsOrderPaidPublisher publisher
) {
this.outboxRepository = outboxRepository;
this.publisher = publisher;
}
@Scheduled(fixedDelayString = "${outbox.publisher.fixed-delay:20000}")
public void publishOrderPaidEvents() {
List<OutboxEventEntity> events = outboxRepository
.findTop50ByStatusAndEventTypeOrderByCreatedAtAsc(
"PENDING",
"OrderPaid"
);
for (OutboxEventEntity event : events) {
publishOne(event);
}
}
private void publishOne(OutboxEventEntity event) {
try {
publisher.publish(event.getPayload());
event.setStatus("PUBLISHED");
event.setPublishedAt(Instant.now());
} catch (Exception ex) {
event.setRetryCount(event.getRetryCount() + 1);
event.setLastError(ex.getMessage());
if (event.getRetryCount() >= 10) {
event.setStatus("DEAD");
}
}
outboxRepository.save(event);
}
}
#JMS Publisher
package com.example.ordermodernization.order.adapters.messaging;
import org.springframework.jms.core.JmsTemplate;
import org.springframework.stereotype.Component;
@Component
public class SpringJmsOrderPaidPublisher {
private final JmsTemplate jmsTemplate;
public SpringJmsOrderPaidPublisher(JmsTemplate jmsTemplate) {
this.jmsTemplate = jmsTemplate;
}
public void publish(String payload) {
jmsTemplate.convertAndSend("OrderPaidQueue", payload);
}
}
Mapping:
Alt:
@Resource Queue
ConnectionFactory
MessageProducer
Neu:
JmsTemplate
Aber die wichtigste Regel bleibt:
JMS Publish ist at-least-once.
Consumer muss idempotent sein.
#JMS Listener statt MDB
Legacy:
@MessageDriven
public class InvoiceMessageBean implements MessageListener {
public void onMessage(Message message) {
...
}
}
Spring:
package com.example.ordermodernization.order.adapters.messaging;
import com.example.ordermodernization.order.application.OrderPaidMessage;
import com.example.ordermodernization.order.application.OrderPaidMessageHandler;
import org.springframework.jms.annotation.JmsListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
public class SpringJmsOrderPaidListener {
private final OrderPaidMessageHandler handler;
public SpringJmsOrderPaidListener(OrderPaidMessageHandler handler) {
this.handler = handler;
}
@Transactional
@JmsListener(destination = "OrderPaidQueue")
public void receive(String payload) {
OrderPaidMessage message = OrderPaidMessageMapper.fromJson(payload);
handler.handle(message);
}
}
Wichtig:
Die Transaktion des Listeners muss bewusst konfiguriert werden.
DB-Update und Message-Acknowledgement müssen zum gewünschten Retry-Verhalten passen.
#Idempotenz im Spring Consumer
Processed Message Entity:
@Entity
@Table(name = "processed_message")
public class ProcessedMessageEntity {
@Id
private String messageId;
@Column(name = "processed_at", nullable = false)
private Instant processedAt;
protected ProcessedMessageEntity() {
}
public ProcessedMessageEntity(String messageId, Instant processedAt) {
this.messageId = messageId;
this.processedAt = processedAt;
}
}
Spring Data:
interface SpringDataProcessedMessageJpaRepository
extends JpaRepository<ProcessedMessageEntity, String> {
}
Adapter:
@Repository
public class SpringJpaProcessedMessageRepository implements ProcessedMessageRepository {
private final SpringDataProcessedMessageJpaRepository repository;
public SpringJpaProcessedMessageRepository(
SpringDataProcessedMessageJpaRepository repository
) {
this.repository = repository;
}
@Override
public boolean alreadyProcessed(String messageId) {
return repository.existsById(messageId);
}
@Override
public void markProcessed(String messageId) {
repository.save(new ProcessedMessageEntity(messageId, Instant.now()));
}
}
Dadurch ist der Consumer robust gegen doppelte Messages.
#Tests für Spring-Boot-Adapter
Der Plain-Java-Kern wird weiter mit normalen Unit Tests getestet.
Für Spring brauchst du zusätzlich Adapter-Tests.
#Web-Test
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private CreateOrderApplicationService applicationService;
@Test
void createOrder_returnsAccepted() throws Exception {
when(applicationService.createOrder(any()))
.thenReturn(OrderId.of("order-1"));
mockMvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"customerId": "customer-1",
"amount": 99.90
}
"""))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.orderId").value("order-1"))
.andExpect(jsonPath("$.status").value("PAYMENT_PENDING"));
}
}
#JPA-Test
@DataJpaTest
class SpringJpaOrderRepositoryTest {
@Autowired
private SpringDataOrderJpaRepository springDataRepository;
@Test
void saveAndLoadOrder() {
SpringJpaOrderRepository repository = new SpringJpaOrderRepository(
springDataRepository
);
Order order = Order.create("customer-1", new BigDecimal("99.90"));
repository.save(order);
Optional<Order> loaded = repository.findById(order.id());
assertTrue(loaded.isPresent());
assertEquals(OrderStatus.PAYMENT_PENDING, loaded.get().status());
}
}
#Transaction-Test
@SpringBootTest
class CreateOrderTransactionTest {
@Autowired
private CreateOrderApplicationService applicationService;
@Autowired
private SpringDataOrderJpaRepository orderRepository;
@Autowired
private SpringDataOutboxJpaRepository outboxRepository;
@Test
void createOrder_persistsOrderAndOutboxInSameTransaction() {
OrderId orderId = applicationService.createOrder(
new CreateOrderCommand("customer-1", new BigDecimal("99.90"))
);
assertTrue(orderRepository.existsById(orderId.value()));
assertFalse(outboxRepository.findAll().isEmpty());
}
}
#Spring-Boot-Migration als kontrollierter Cutover
Du solltest nicht sofort alle Clients auf Spring Boot umstellen.
Sichere Reihenfolge:
1. Plain-Java-Kern aus Legacy extrahieren
2. Kern im Legacy-System testen
3. Spring-Boot-App mit gleichem Kern aufbauen
4. REST API oder JMS Consumer parallel betreiben
5. Shadow Mode nutzen
6. Ergebnisse vergleichen
7. Traffic schrittweise umleiten
8. Legacy Entry Point deaktivieren
9. Legacy Code entfernen
Cutover-Tabelle:
| Phase | Legacy aktiv? | Spring aktiv? | Traffic | Ziel |
|---|---|---|---|---|
| Analyse | ja | nein | Legacy | Verhalten verstehen |
| Kern extrahiert | ja | nein | Legacy | Testbarer Kern |
| Parallel Build | ja | ja | Legacy | Spring App startet |
| Shadow Mode | ja | ja | Legacy + Vergleich | Unterschiede finden |
| Teil-Cutover | ja | ja | 5–20% Spring | Risiko reduzieren |
| Full Cutover | standby | ja | Spring | Betrieb migriert |
| Cleanup | nein | ja | Spring | Legacy entfernen |
Merksatz:
Spring Boot ist nicht die Modernisierung.
Spring Boot ist nur die neue Laufzeit.
Die eigentliche Modernisierung ist:
- fachliche Grenzen
- klare Transaktionen
- Ports und Adapter
- Idempotenz
- Outbox
- Tests
- beobachtbarer Betrieb
#Übung nach der Spring-Boot-Zielimplementierung
Baue das Mini-Repo in dieser Reihenfolge:
1. Plain-Java-Domain und Use Cases aus der Mini-Repo-Übung übernehmen
2. Spring Boot Application hinzufügen
3. CreateOrderApplicationService mit @Transactional ergänzen
4. REST Controller bauen
5. JPA Adapter implementieren
6. Outbox Tabelle und Repository ergänzen
7. PaymentRequestedScheduler ergänzen
8. OrderPaidOutboxPublisher ergänzen
9. SpringJmsOrderPaidListener ergänzen
10. Tests für Web, JPA, Transaktion und Idempotenz schreiben
Erfolgsdefinition:
POST /orders erzeugt:
- Order mit Status PAYMENT_PENDING
- OutboxEvent PaymentRequested
Payment Scheduler verarbeitet:
- PaymentRequested
- Order wird PAID oder PAYMENT_FAILED
- bei PAID entsteht OutboxEvent OrderPaid
Outbox Publisher sendet:
- OrderPaid Message
JMS Listener verarbeitet:
- OrderPaid Message
- Invoice wird genau einmal erzeugt