Produkty Poradenství O nás Blog Kontakt English
arrow_back Zpět na blog

Migrace z Axon Framework 4 na 5: Co jsme se naučili

Migrace z Axon Framework 4 na 5: Co jsme se naučili

Provozujeme CQRS/Event Sourcing backend — nyní spokojeně na Java 25, Spring Boot 4 a Axon Framework 5. Ale cesta sem z Axon 4.12 nebyla jednoduchá. Codebase obsahuje desítky agregátů, více ság, přes stovku typů událostí a stovky anotovaných handler metod.

Tady je, co jsme se naučili.

Proč jsme chtěli Axon 5

Motivace byla jednoduchá: nativní reaktivní podpora v event handlerech.

Axon 4.x má známý problém se subscribing event procesory. Když @EventHandler vrátí Mono<Void>, framework se na něj nikdy nepřihlásí. Reaktivní řetězec se jednoduše neprovede. Přišli jsme na to tvrdým způsobem, když projekční handlery přestaly tiše zapisovat do databáze.

Řešení? Přidat .block() ke každému event handleru:

Java
// Axon 4.x — framework ignoruje vrácené Mono
@EventHandler
public void on(ClimbLoggedEvent event) {
    dlqService.withDLQ(...)
        .subscribeOn(Schedulers.boundedElastic())
        .timeout(Duration.ofSeconds(30))
        .block();  // Bez toho se nic nestane
}

Měli jsme stovky těchto .block() volání rozsetých po celém kódu. Každé z nich blokuje vlákno v reaktivní aplikaci. Axon 5.0 sliboval opravu díky async-native architektuře, kde framework automaticky subscribuje vrácené reaktivní typy:

Java
// Axon 5.0 — framework subscribuje za vás
@EventHandler
public Mono<Void> on(ClimbLoggedEvent event) {
    return dlqService.withDLQ(...);
}

Kromě reaktivní podpory Axon 5 také odstraňuje ThreadLocal (které kolidují s Project Reactor a virtual threads), zjednodušuje konfiguraci a zavádí Dynamic Consistency Boundaries.

První pokus: tisíce kompilačních chyb

Vytvořili jsme feature branch a aktualizovali závislosti. První překvapení: Axon 5 má jinou strukturu modulů.

KOTLIN
// Axon 4.x
implementation(platform("org.axonframework:axon-bom:4.12.2"))
implementation("org.axonframework:axon-spring-boot-starter")

// Axon 5.0
implementation(platform("org.axonframework:axon-framework-bom:5.0.1"))
implementation("org.axonframework.extensions.spring:axon-spring-boot-starter")
implementation("org.axonframework.extensions.tracing:axon-tracing-opentelemetry")

Spring moduly se přesunuly do org.axonframework.extensions.spring, tracing do org.axonframework.extensions.tracing. Dobře.

Pak jsme zkompilovali. Tisíce chyb.

Tohle není typický major-version bump s pár deprecacemi. Celé API bylo přepracováno. Každý package, ze kterého importujete, se přesunul nebo byl odstraněn:

Co jste používali v Axon 4Stav v Axon 5
org.axonframework.modelling.command.*Odstraněno
org.axonframework.commandhandling.*Odstraněno
org.axonframework.queryhandling.*Odstraněno
org.axonframework.eventhandling.*Odstraněno
@Aggregate / @AggregateIdentifierNahrazeno @EventSourced(idType=...)
@Revision("1.0")Nahrazeno @Event(version = "1.0")
AggregateLifecycle.apply(event)Nahrazeno EventAppender.append(event)
Konstruktor @CommandHandlerNahrazeno statickou metodou
UnitOfWorkNahrazeno ProcessingContext
MetaDataPřejmenováno na Metadata (malé ’d')
ResponseType<R>Odstraněno — použijte Class<R>
ReactorQueryGateway (extension)Nativní async v jádře

Tohle není inkrementální změna. Je to přepis vývojářského API.

Migrace: fáze po fázi

Sledovali jsme postup po fázích a psali shell skripty pro automatizaci mechanických změn.

Fáze 1: Přesuny balíčků

Většina kompilačních chyb byly změny import statementů. Třídy command handling, query handling a event handling se přesunuly pod org.axonframework.messaging.*. Je to únavné, ale automatizovatelné:

Bash
# Příklad z našich migračních skriptů
find . -name "*.java" -exec sed -i '' \
  's/org.axonframework.commandhandling/org.axonframework.messaging.commandhandling/g' {} +

Fáze 2: Změny anotací

@Aggregate se stal @EventSourced s povinným atributem idType. Každá agregátní třída vyžaduje aktualizaci a možná budete muset vytvořit typované ID třídy, které dříve neexistovaly:

Java
// Axon 4
@Aggregate
public class ClubAggregate {
    @AggregateIdentifier
    private String id;
}

// Axon 5
@EventSourced(idType = String.class)
@EventSourcedEntity(tagKey = "clubId")
public class ClubAggregate {
    private String id;

    @EntityCreator
    protected ClubAggregate() {
        this.members = new HashMap<>();  // Musí se inicializovat kolekce!
    }
}

Záludný detail: pokud má váš agregát Map nebo Collection pole s @Builder.Default, musí být také inicializována v @EntityCreator bezparametrovém konstruktoru. Axon používá reflexi pro vytváření instancí během event replay a obchází Lombok builder.

Fáze 3: Vzor command handlerů

Konstruktorové command handlery jsou pryč. Vytvoření agregátu nyní používá statické tovární metody s explicitním EventAppender:

Java
// Axon 4
@CommandHandler
public ClubAggregate(CreateClubCommand cmd) {
    AggregateLifecycle.apply(new ClubCreatedEvent(...));
}

// Axon 5
@CommandHandler
public static void create(CreateClubCommand cmd, EventAppender appender) {
    appender.append(new ClubCreatedEvent(...));
}

Je to čistší vzor — žádná magie přes statický AggregateLifecycle threadlocal — ale zasahuje do cesty vytváření každého agregátu.

Fáze 4: Verzování událostí

@Revision je pryč, nahrazeno @Event:

Java
// Axon 4
@Revision("1.0")
public record ClimbLoggedEvent(...) {}

// Axon 5
@Event(version = "1.0")
public record ClimbLoggedEvent(...) {}

Důležité: upcastery nejsou v Axon 5.0 k dispozici — jsou plánovány až pro 5.2.0. Pokud na event upcasting spoléháte, počítejte s tím.

Fáze 5: Ságy nahrazeny stavovými event handlery

Tohle byla největší architektonická změna. Axon 5 se odklání od tradičního modelu ság. Anotace @Saga technicky stále existují v přesunutém balíčku, ale doporučený vzor je nahradit ságy stavovými event handlery s persistencí v databázi.

Všechny naše ságy jsme označili jako deprecated a přepsali je jako běžné Spring @Component třídy s @EventHandler metodami. Stav, který dříve spravovala infrastruktura ság v Axonu, je nyní explicitně persistován v databázi přes ProcessStateService:

Java
@Slf4j
@Component
@RequiredArgsConstructor
public class SiteDataImportEventHandler {

    private static final String PROCESS_TYPE = "SITE_DATA_IMPORT";

    private final SiteBatchImportService siteBatchImportService;
    private final ProcessStateService processStateService;

    @EventHandler
    public Mono<Void> on(SiteDataImportRequestedEvent event) {
        Map<String, Object> metadata = Map.of(
            "siteId", event.siteId().value(),
            "format", event.format()
        );

        return processStateService
            .createProcess(PROCESS_TYPE, event.importId(), event.requestedBy().value(), 1, metadata)
            .doOnSuccess(process ->
                siteBatchImportService.processSiteImportAsync(
                    event.importId(), event.siteId().value(),
                    event.requestedBy().value(), event.fileContent(), event.format()))
            .then();
    }

    @EventHandler
    public Mono<Void> on(SiteBatchProcessedEvent event) {
        return processStateService.getProcess(PROCESS_TYPE, event.importId())
            .flatMap(process -> processStateService.markItemCompleted(PROCESS_TYPE, event.importId()))
            .then();
    }

    @EventHandler
    public Mono<Void> on(SiteDataImportCompletedEvent event) {
        return processStateService.getProcess(PROCESS_TYPE, event.importId())
            .flatMap(process -> processStateService.deleteProcess(PROCESS_TYPE, event.importId()))
            .then();
    }
}

Proč je to lepší než ságy:

  • Stav je explicitní a dotazovatelný. Žije ve vaší databázi, ne pohřbený v serializovaném saga store Axonu. Můžete se dotazovat na stav procesu, stavět admin dashboardy a debugovat přímo.
  • Plně reaktivní. Handlery vracejí Mono<Void> a framework nativně subscribuje — žádné .block() hacky.
  • Jednodušší testování. Žádné saga test fixture. Je to běžná Spring komponenta, kterou můžete testovat s mockovanými závislostmi.
  • Žádné problémy se serializací ság. Ságy serializují celý svůj stav do token store Axonu. Změňte typ pole a řešíte chyby deserializace při replay. Stav v databázi tento problém nemá.

Migrační vzor je přímočarý: pro každou ságu vytvořte @Component event handler třídu, přesuňte logiku z @SagaEventHandler metod do @EventHandler metod a nahraďte SagaLifecycle.associateWith() / end() explicitní správou stavu v databázi.

Fáze 6: Event tagging pro rekonstrukci stavu

Tohle byla nejpřekvapivější vyžadovaná změna. Axon 5 potřebuje anotace @EventTag na událostech, aby framework mohl směrovat události ke správné entitě během replay:

Java
public record ClubCreatedEvent(
    @EventTag(key = "clubId") String clubId,
    String name
) {}

A agregát musí deklarovat, který tag klíč používá:

Java
@EventSourced(idType = String.class)
@EventSourcedEntity(tagKey = "clubId")
public class ClubAggregate { ... }

Bez toho given().events(...) v testech tiše selže při rekonstrukci stavu. Chvíli nám trvalo zjistit, proč testovací fixture nepřehrávaly události.

Fáze 7: Přepracování testovacích fixture

Testovací API se výrazně změnilo:

GROOVY
// Axon 4
fixture.givenNoPriorActivity()
    .when(command)
    .expectEvents(expectedEvent)

// Axon 5
fixture.given().noPriorActivity()
    .when().command(command)
    .then().success().events(expectedEvent)

Další změny v testech: CommandGateway.send() nově vrací CommandResult místo CompletableFuture, EventGateway.publish() přijímá List<?> a queryMany() nahrazuje ResponseTypes.multipleInstancesOf().

Nový AxonTestFixture také vyžaduje explicitní fixture.stop() v cleanup — bez toho budete mít memory leak napříč testy.

Výsledek

Migraci jsme dokončili a nyní provozujeme Axon 5 v produkci. Volání .block() jsou pryč, event handlery jsou skutečně reaktivní a refaktoring ság na stavové event handlery ve skutečnosti zlepšil naši architekturu tím, že stav procesů je nyní explicitní a dotazovatelný.

Migrace byla rozsáhlá — napsali jsme shell skripty pro automatizaci mechanických částí (přejmenování balíčků, záměny anotací) a architektonické změny (ságy, vzor command handlerů, event tagging) jsme řešili po jednotlivých modulech.

Co bychom doporučili

Pokud zvažujete stejnou migraci:

  1. Automatizujte mechanickou práci. Přesuny balíčků a přejmenování anotací tvoří většinu kompilačních chyb, ale jsou triviálně skriptovatelné. Nedělejte je ručně.
  2. Řešte ságy brzy. Přechod ze ság na stavové event handlery je největší architektonická změna. Je to ale i ta nejhodnotnější — skončíte s čistějším, lépe testovatelným návrhem. Začněte jednoduchou ságou jako proof of concept.
  3. Nezapomeňte na @EventTag. Tohle je nedostatečně dokumentováno a tiše rozbije vaše agregátní testovací fixture. Každá událost potřebuje tag na svém ID poli entity a každý agregát potřebuje @EventSourcedEntity(tagKey = ...).
  4. Plánujte mezeru v upcasterech. Upcastery nejsou k dispozici do Axon 5.2.0. Pokud váš event store obsahuje události vyžadující upcasting, budete potřebovat strategii pro tuto mezeru.
  5. Zvažte čekání na OpenRewrite recepty pokud máte velký codebase. Axon 5.1 by měl přinést OpenRewrite recepty, které automatizují většinu transformace. Pro menší codebasy je ruční migrace se skripty zvládnutelná.

Rychlý přehled: Hlavní změny API

KonceptAxon 4Axon 5
Anotace agregátu@Aggregate@EventSourced(idType = X.class)
ID agregátu@AggregateIdentifierNastaví se v @EventSourcingHandler
Handler pro vytvořeníKonstruktor @CommandHandlerStatická metoda + EventAppender
Aplikování událostíAggregateLifecycle.apply()EventAppender.append()
Verzování událostí@Revision("1.0")@Event(version = "1.0")
Směrování událostíAutomatické@EventTag + @EventSourcedEntity(tagKey)
MetadataMetaDataMetadata (malé ’d')
Response typyResponseType<R>Class<R>
Dotaz na seznamyResponseTypes.multipleInstancesOf()queryMany()
Kontext zprávUnitOfWorkProcessingContext
Reaktivní gatewayExtension modulNativní v jádře
Ságy@Saga + @SagaEventHandlerStavový @Component + @EventHandler
RekonstrukceVýchozí konstruktor@EntityCreator bezparam. konstruktor
Setup testovací fixturenew AggregateTestFixture<>(X.class)AxonTestFixture.with(configurer, ...)
Test given/when/thenfixture.given().when().expect()fixture.given().when().command().then()

Zdroje

Další z blogu