#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.

text
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:

text
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:

text
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:

text
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:

text
application, domain und ports bleiben framework-arm.
adapters und config dürfen Spring kennen.

#Maven-Grundstruktur

Beispielhafte pom.xml für die Übung:

xml
<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:

text
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

java
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:

text
EAR/WAR Deployment auf Application Server
    ↓
executable Spring Boot application

Aber erst, wenn der Slice ausreichend abgesichert ist.


#application.yml

Beispiel:

yaml
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:

text
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.

java
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?

text
Der Kern bleibt testbar ohne Spring.
Spring verdrahtet nur die Abhängigkeiten.

#REST Controller als Ersatz für JSP/SOAP Entry Point

Legacy Entry:

text
create-order.jsp
  ↓
JNDI Lookup
  ↓
OrderServiceBean#createOrder

Neuer Entry:

text
POST /orders
  ↓
OrderController
  ↓
CreateOrderApplicationService
  ↓
CreateOrderUseCase

Controller:

java
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:

java
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:

java
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:

java
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:

text
@Stateless
@TransactionAttribute(REQUIRED)
OrderServiceBean#createOrder

wird zu:

@Service
@Transactional
CreateOrderApplicationService#createOrder

Wichtig:

text
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:

java
@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:

text
Spring-Transaktionen sind proxybasiert.
Self-Invocation kann Transaktionen umgehen.

Schlecht:

java
public void process() {
    markPaid(...); // interner Aufruf, Proxy wird umgangen
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markPaid(...) {
}

Besser:

text
REQUIRES_NEW-Methode in separaten Spring-Service legen
und von außen injiziert aufrufen.

#JPA Entity für Order

java
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:

java
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:

text
JPA Entity bleibt Persistenzmodell.
Order bleibt Domain-Modell.

#Spring Data Repository plus Port-Adapter

Spring Data Interface:

java
package com.example.ordermodernization.order.adapters.persistence;

import org.springframework.data.jpa.repository.JpaRepository;

interface SpringDataOrderJpaRepository extends JpaRepository<OrderEntity, String> {
}

Port-Adapter:

java
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:

java
OrderRepository

Nicht von:

java
JpaRepository
EntityManager
Hibernate
Spring Data

#Outbox mit Spring JPA

Entity:

java
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:

java
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:

java
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:

java
public interface PaymentGateway {
    PaymentResult charge(PaymentCommand command);
}

Properties:

java
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:

java
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:

text
PaymentGateway mappt technische Provider-Fehler in fachliche Fehler.
Der Use Case kennt keine SOAPFaultException.

#Scheduler für PaymentRequested Events

java
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:

text
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

java
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

java
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:

text
Alt:
@Resource Queue
ConnectionFactory
MessageProducer

Neu:
JmsTemplate

Aber die wichtigste Regel bleibt:

text
JMS Publish ist at-least-once.
Consumer muss idempotent sein.

#JMS Listener statt MDB

Legacy:

java
@MessageDriven
public class InvoiceMessageBean implements MessageListener {
    public void onMessage(Message message) {
        ...
    }
}

Spring:

java
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:

text
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:

java
@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:

java
interface SpringDataProcessedMessageJpaRepository
        extends JpaRepository<ProcessedMessageEntity, String> {
}

Adapter:

java
@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

java
@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

java
@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

java
@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:

text
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:

markdown
| 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:

text
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:

text
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:

text
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