diff --git a/coverage-report/pom.xml b/coverage-report/pom.xml
index 24a5e69..b216a80 100644
--- a/coverage-report/pom.xml
+++ b/coverage-report/pom.xml
@@ -21,7 +21,7 @@
axon-multitenancy-parentorg.axonframework.extensions.multitenancy
- 4.12.1-SNAPSHOT
+ 5.1.0-SNAPSHOTaxon-coverage-report
@@ -38,13 +38,19 @@
org.axonframework.extensions.multitenancy
- axon-multitenancy-spring-boot-autoconfigure
+ axon-multitenancy-spring${project.version}runtimeorg.axonframework.extensions.multitenancy
- axon-multitenancy-spring-boot-3-integrationtests
+ axon-multitenancy-axon-server-connector
+ ${project.version}
+ runtime
+
+
+ org.axonframework.extensions.multitenancy
+ axon-multitenancy-spring-boot-autoconfigure${project.version}runtime
diff --git a/docs/reference/modules/ROOT/pages/configuration.adoc b/docs/reference/modules/ROOT/pages/configuration.adoc
deleted file mode 100644
index 12e5ae6..0000000
--- a/docs/reference/modules/ROOT/pages/configuration.adoc
+++ /dev/null
@@ -1,276 +0,0 @@
-:navtitle: Configuration
-= Configuration
-
-Minimal configuration is needed to get extension up and running.
-
-== Static tenants configuration
-
-If you know list of contexts that you want your application to connect in advanced configure them coma separated in `application.properties` via following properties: `axon.axonserver.contexts=tenant-context-1,tenant-context-2,tenant-context-3`
-
-== Dynamic tenants configuration
-
-If you don't know tenants in advance and you plan to create them in runtime, you can define a predicate which will tell application to which contexts to connect to in runtime:
-
-[source,java]
-----
-@Bean
-public TenantConnectPredicate tenantFilterPredicate() {
- return context -> context.tenantId().startsWith("tenant-");
-}
-----
-
-Note that in this case you need to remove axon.axonserver.contexts property.
-
-== Route message to specific tenant
-
-By default, to route message to specific tenant you need to tag initial message that enters your system with metadata . This is done with meta-data helper, and you need to add tenant name to metadata with key `TenantConfiguration.TENANT_CORRELATION_KEY`.
-
-[source,java]
-----
-message.andMetaData(Collections.singletonMap(TENANT_CORRELATION_KEY, "tenant-context-1")
-----
-
-Metadata needs to be added only to initial message that enters your system. Any message that is produced by consequence of initial message will have this metadata copied automatically using to `CorrelationProvider`.
-
-=== Custom resolver
-
-If you wish to disable default meta-data based routing define following property:
-
-[source,java]
-----
-axon.multi-tenancy.use-metadata-helper=false
-----
-
-And define custom tenant resolver bean. For example following imaginary bean can use message payload to route message to specific tenant:
-
-[source,java]
-----
-
-@Bean
-public TargetTenantResolver> customTargetTenantResolver() {
- return (message, tenants) -> //<.>
- TenantDescriptor.tenantWithId(
- message.getPayload().getField("tenantName")
- );
-}
-----
-<.> First lambda parameter message represents message to be routed, while second parameter tenants represents list of currently registered tenants, if you wish to use is to route only to one of connected tenants.
-
-== Multi-tenant projections
-
-If you wish to use distinct database to store projections and token store for each tenant, configure following bean:
-
-[source,java]
-----
-@Bean
-public Function tenantDataSourceResolver() {
- return tenant -> {
- DataSourceProperties properties = new DataSourceProperties();
- properties.setUrl("jdbc:postgresql://localhost:5432/"+tenant.tenantId());
- properties.setDriverClassName("org.postgresql.Driver");
- properties.setUsername("postgres");
- properties.setPassword("postgres");
- return properties;
- };
-}
-----
-
-Note that this works by using JPA multi-tenancy support, that means only SQL Databases are supported out of the box. If you wish to implement multi-tenancy for a different type of databases (for example, NoSQL) make sure that your projection database supports multi-tenancy. While in transaction you may find out which tenant owns transaction by calling: `TenantWrappedTransactionManager.getCurrentTenant()`.
-
-For more hints how to enable multi-tenancy for NoSQL databases check on how JPA SQL version is link:https://github.com/AxonFramework/extension-multitenancy/blob/main/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extensions/multitenancy/autoconfig/MultiTenantDataSourceManager.java[implemented,window=_blank,role=external]
-
-IMPORTANT: In this case Liquibase or Flyway will not be able to initialise schemas for dynamic data sources. Any datasource that you use needs to have pre-initialized schema.
-
-== Query update emitter
-
-In order to correctly resolve right query update emitter inject update emitter in following style:
-
-[source,java]
-----
-@EventHandler
-public void on(Event event, QueryUpdateEmitter queryUpdateEmitter) {
- //queryUpdateEmitter will route updates to same tenant as event will be
- ...
-}
-----
-
-=== Resetting projections
-
-Resetting projections works a bit different because you have instanced of each event processor group for each tenant.
-
-Reset specific tenant event processor group:
-
-[source,java]
-----
-TrackingEventProcessor trackingEventProcessor =
- configuration.eventProcessingConfiguration()
- .eventProcessor("com.demo.query-ep@tenant-context-1",
- TrackingEventProcessor.class)
- .get();
-----
-
-Name of each event processor is: `{even processor name}@{tenant name}`
-
-Access all tenant event processors by retrieving `MultiTenantEventProcessor` only. `MultiTenantEventProcessor` acts as a proxy Event Processor that references all tenant event processors.
-
-==== Dead-letter queue
-The configuration of a dead-letter queue is similar to a non-multi-tenant environment. The tenant will be resolved through the Message's `MetaData` and routed to the corresponding DLQ. If you wish to have different enqueuing policies per tenant, you can use the `MetaData` from the dead letter message to determine to which tenant the message belongs to act accordingly.
-
-Do note that processing dead letters from the queue is slightly different, as you need the specific tenant context to process dead-letter from.
-
-To select the tenant for which you want to process a dead letter, you need to cast the `SequencedDeadLetterProcessor` to a `MultiTenantDeadLetterProcessor`. From the `MultiTenantDeadLetterProcessor`, you need to use the `forTenant` method to select the tenant-specific `SequencedDeadLetterProcessor`.
-
-[source,java]
-----
-public class DlqManagement {
-
- private MultiTenantDeadLetterProcessor multiTenantDeadLetterProcessor;
-
- // Axon Framework's org.axonframework.config.Configuration
- public DlqManagement(Configuration configuration) {
- SequencedDeadLetterProcessor deadLetterProcessor = configuration.sequencedDeadLetterProcessor();
- this.multiTenantDeadLetterProcessor = (MultiTenantDeadLetterProcessor) deadLetterProcessor;
- }
-
- public void processDeadLetterSequenceForTenant(String tenantId,
- Predicate>> sequenceFilter) {
- multiTenantDeadLetterProcessor.forTenant(tenantId)
- .process(sequenceFilter);
- }
-}
-----
-
-Here is a full example of a REST endpoint to retry dead letters for a specific tenant:
-
-[source,java]
-----
-public class DlqManagementController {
-
- // Axon Framework's org.axonframework.config.Configuration
- private Configuration configuration;
-
- @PostMapping(path = "/retry-dlq")
- public void retryDLQ(@RequestParam String tenant, @RequestParam String processingGroup) {
- configuration.eventProcessingConfiguration()
- .sequencedDeadLetterProcessor(processingGroup)
- .map(p -> (MultiTenantDeadLetterProcessor) p)
- .map(mp -> mp.forTenant(TenantDescriptor.tenantWithId(tenant)))
- .ifPresent(SequencedDeadLetterProcessor::processAny);
- }
-}
-----
-
-WARNING: Only JPA Dead letter queue and In-Memory queues are supported.
-
-=== Deadline manager
-
-As of now, there is no plan to support deadline manager out of the box. None of deadline manager implementation support multi-tenancy. See Event scheduler section as alternative.
-
-=== Event scheduler
-
-You can use the `MultiTenantEventScheduler` to schedule events for specific tenants. To do so, you can inject the `EventScheduler` and use it to schedule events:
-
-[source,java]
-----
-public class EventHandlingComponentSchedulingEvents {
-
- private EventScheduler eventScheduler;
-
- @EventHandler
- public void eventHandler(Event event) {
- // Schedules the given event to be published in 10 days.
- ScheduledToken token = eventScheduler.schedule(Instant.now().plusDays(10), event);
- // The token returned by EventScheduler#schedule can be used to, for example, cancel the scheduled task.
- eventScheduler.cancelSchedule(token);
- }
-}
-----
-
-If you use the `EventScheduler` from any message handling method, it will automatically pick up tenant from `Message#metadata`. Hence, there is no need to specify the tenant you want to schedule an event for. If you wish to use the `EventScheduler` outside of message handlers, you are inclined to wrap the execution into a so-called `TenantWrappedTransactionManager`. Within this `TenantWrappedTransactionManager` you can schedule the event:
-
-[source,java]
-----
-public class EventSchedulingComponent {
-
- private EventScheduler eventScheduler;
-
- public void schedule(Event event) {
- ScheduledToken token;
- // Schedules the given event to be published in 10 days.
- new TenantWrappedTransactionManager(
- TenantDescriptor.tenantWithId(tenantName))
- .executeInTransaction(
- () -> token = eventScheduler.schedule(Instant.now().plusDays(10), event)
- );
- // The token returned by EventScheduler#schedule can be used to, for example, cancel the scheduled task.
- new TenantWrappedTransactionManager(
- TenantDescriptor.tenantWithId(tenantName))
- .executeInTransaction(
- () -> eventScheduler.cancelSchedule(token)
- );
- }
-}
-----
-
-=== Advanced configuration
-
-==== Overriding default message source
-
-You can override the default message source for each tenant by defining the following bean:
-
-[source,java]
-----
-@Bean
-public MultiTenantStreamableMessageSourceProvider multiTenantStreamableMessageSourceProvider(AxonServerEventStore customSource) {
- return (defaultTenantSource, processorName, tenantDescriptor, configuration) -> {
- if (tenantDescriptor.tenantId().startsWith("tenant-custom")) {
- return customSource;
- }
- return defaultTenantSource;
- };
-}
-----
-
-This bean should return a `StreamableMessageSource` that will be used for specific tenants. This lambda will be called for each tenant and each event processor, so be sure to return a default tenant source if you don't want to override it.
-
-==== Disable multi-tenancy for specific event processor
-
-In certain cases, you may want to disable multi-tenancy for specific Event Processor which does not have any tenants.
-For example, when you have an event processor that is consuming events from an external context.
-Per default, each event processor is scaled, and duplicated for each tenant. To disable this behavior for a specific processing, you can define following bean:
-
-[source,java]
-----
-@Bean
-public MultiTenantEventProcessorPredicate multiTenantEventProcessorPredicate() {
- return (processorName) -> !processorName.equals("external-context");
-}
-----
-
-This bean should return `true` for each processor that you want to be multi-tenant, and `false` for each processor that you want to be single tenant.
-
-=== Tenant Segment Factories
-
-This extension provides several factory interfaces that are used to create tenant-specific segments for various Axon components, such as Command Bus, Query Bus, Event Store, and Event Scheduler. These factories allow you to configure and customize the behavior of these components for each tenant.
-
-The following tenant segment factories are available:
-
-==== TenantCommandSegmentFactory
-
-This factory is responsible for creating a `CommandBus` instance for each tenant. By default, it creates an `AxonServerCommandBus` that uses a `SimpleCommandBus` as the local segment and connects to Axon Server. You can override this factory to provide a custom implementation of the `CommandBus` for specific tenants.
-
-==== TenantQuerySegmentFactory
-
-This factory creates a `QueryBus` instance for each tenant. By default, it creates an `AxonServerQueryBus` that uses a `SimpleQueryBus` as lhe local segment and connects to Axon Server. You can override this factory to provide a custom implementation of the `QueryBus` for specific tenants.
-
-==== TenantEventSegmentFactory
-
-This factory is responsible for creating an `EventStore` instance for each tenant. By default, it creates an `AxonServerEventStore` that connects to Axon Server. You can override this factory to provide a custom implementation of the `EventStore` for specific tenants.
-
-==== TenantEventSchedulerSegmentFactory
-
-This factory creates an `EventScheduler` instance for each tenant. By default, it creates an `AxonServerEventScheduler` that connects to Axon Server. You can override this factory to provide a custom implementation of the `EventScheduler` for specific tenants.
-
-==== TenantEventProcessorControlSegmentFactory
-
-This factory creates a `TenantDescriptor` for each event processor, which is used to identify the tenant associated with the event processor. By default, it uses the tenant identifier as the `TenantDescriptor`. You can override this factory to provide a custom implementation of the `TenantDescriptor` for specific event processors.
diff --git a/docs/reference/modules/ROOT/pages/configuration/dynamic-tenants.adoc b/docs/reference/modules/ROOT/pages/configuration/dynamic-tenants.adoc
new file mode 100644
index 0000000..25992dc
--- /dev/null
+++ b/docs/reference/modules/ROOT/pages/configuration/dynamic-tenants.adoc
@@ -0,0 +1,513 @@
+:navtitle: Dynamic Tenants
+= Dynamic Tenant Management
+
+Tenants can be added and removed at runtime without restarting the application. This page covers how to manage tenant lifecycle dynamically.
+
+== Overview
+
+Dynamic tenant management enables:
+
+* Adding new tenants when customers sign up
+* Removing tenants when subscriptions end
+* Filtering which tenants the application connects to
+* Responding to external tenant events (e.g., from Axon Server)
+
+== TenantProvider Interface
+
+The `TenantProvider` interface manages the set of known tenants:
+
+[source,java]
+----
+public interface TenantProvider {
+
+ Registration subscribe(MultiTenantAwareComponent component);
+
+ List getTenants();
+}
+----
+
+When tenants are added or removed, the provider notifies all subscribed `MultiTenantAwareComponent` instances, which create or destroy their tenant-specific resources.
+
+== SimpleTenantProvider
+
+`SimpleTenantProvider` is the standard implementation for programmatic tenant management:
+
+=== Creating with Initial Tenants
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.SimpleTenantProvider;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+
+// From a list
+SimpleTenantProvider provider = new SimpleTenantProvider(List.of(
+ TenantDescriptor.tenantWithId("stanford"),
+ TenantDescriptor.tenantWithId("mit"),
+ TenantDescriptor.tenantWithId("berkeley")
+));
+
+// Or empty, to be populated later
+SimpleTenantProvider provider = new SimpleTenantProvider();
+----
+
+=== Adding Tenants at Runtime
+
+[source,java]
+----
+TenantDescriptor newTenant = TenantDescriptor.tenantWithId("caltech");
+
+boolean added = provider.addTenant(newTenant);
+// Returns true if added, false if already registered
+
+// Add multiple tenants
+provider.addTenants(List.of(
+ TenantDescriptor.tenantWithId("caltech"),
+ TenantDescriptor.tenantWithId("princeton")
+));
+----
+
+When a tenant is added:
+
+1. The tenant is added to the provider's registry
+2. All subscribed `MultiTenantAwareComponent` instances are notified
+3. Each component creates and starts its tenant-specific resources
+4. The new tenant becomes available for message routing
+
+=== Removing Tenants at Runtime
+
+[source,java]
+----
+// Remove by descriptor
+boolean removed = provider.removeTenant(TenantDescriptor.tenantWithId("caltech"));
+
+// Or by ID
+boolean removed = provider.removeTenant("caltech");
+// Returns true if removed, false if not registered
+----
+
+When a tenant is removed:
+
+1. The tenant is removed from the provider's registry
+2. All `Registration` handles are cancelled
+3. Tenant-specific resources are cleaned up (event processors stopped, connections closed, etc.)
+4. Messages to the removed tenant will throw `NoSuchTenantException`
+
+=== Checking Tenant Status
+
+[source,java]
+----
+// Check if tenant exists
+boolean exists = provider.hasTenant(TenantDescriptor.tenantWithId("stanford"));
+boolean exists = provider.hasTenant("stanford");
+
+// Get all tenants
+List tenants = provider.getTenants();
+----
+
+== Subscription Lifecycle
+
+The `subscribe()` method connects components to tenant lifecycle events:
+
+[source,java]
+----
+// Components subscribe to receive tenant notifications
+Registration registration = tenantProvider.subscribe(multiTenantEventStore);
+
+// On subscribe:
+// 1. Existing tenants are registered with the component
+// 2. Future tenant additions/removals will notify the component
+
+// To unsubscribe (e.g., during shutdown):
+registration.cancel();
+----
+
+=== MultiTenantAwareComponent Interface
+
+Components that manage per-tenant resources implement this interface:
+
+[source,java]
+----
+public interface MultiTenantAwareComponent {
+
+ // Called when a tenant is registered (at startup or dynamically)
+ Registration registerTenant(TenantDescriptor tenantDescriptor);
+
+ // Called when a tenant is registered AND should be started immediately
+ Registration registerAndStartTenant(TenantDescriptor tenantDescriptor);
+}
+----
+
+The returned `Registration` is called when the tenant is removed, allowing cleanup.
+
+== TenantConnectPredicate
+
+Filter which tenants the application connects to:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.TenantConnectPredicate;
+
+// Accept all tenants (default)
+TenantConnectPredicate all = tenant -> true;
+
+// Filter by tenant ID pattern
+TenantConnectPredicate production = tenant ->
+ !tenant.tenantId().startsWith("test-");
+
+// Filter by tenant properties
+TenantConnectPredicate euRegion = tenant ->
+ "eu".equals(tenant.properties().get("region"));
+----
+
+=== Configuration
+
+==== Axon Framework
+
+[source,java]
+----
+MultiTenancyConfigurer.enhance(configurer)
+ .registerTenantProvider(config -> tenantProvider)
+ .registerTenantConnectPredicate(config -> tenant ->
+ !tenant.tenantId().startsWith("_admin")
+ );
+----
+
+==== Spring Boot
+
+[source,java]
+----
+@Bean
+public TenantConnectPredicate tenantConnectPredicate() {
+ return tenant -> {
+ // Only connect to production tenants
+ return !tenant.tenantId().contains("test");
+ };
+}
+----
+
+== AxonServerTenantProvider
+
+When using Axon Server, `AxonServerTenantProvider` discovers tenants from Axon Server contexts:
+
+=== Predefined Contexts
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.axonserver.AxonServerTenantProvider;
+
+AxonServerTenantProvider provider = AxonServerTenantProvider.builder()
+ .axonServerConnectionManager(connectionManager)
+ .preDefinedContexts("stanford,mit,berkeley") // Comma-separated
+ .build();
+----
+
+=== Dynamic Context Discovery
+
+Without predefined contexts, the provider discovers tenants via Axon Server's Admin API:
+
+[source,java]
+----
+AxonServerTenantProvider provider = AxonServerTenantProvider.builder()
+ .axonServerConnectionManager(connectionManager)
+ .tenantConnectPredicate(tenant -> !tenant.tenantId().startsWith("_"))
+ .build();
+
+// Discovers all contexts and filters out admin contexts
+----
+
+=== Subscribing to Context Updates
+
+The provider automatically subscribes to Axon Server context events:
+
+[source,java]
+----
+// When a context is created in Axon Server:
+// 1. Provider receives CREATED event
+// 2. If predicate allows, tenant is added
+// 3. All components create resources for new tenant
+
+// When a context is deleted:
+// 1. Provider receives DELETED event
+// 2. Tenant is removed
+// 3. All tenant resources are cleaned up
+// 4. Connection to Axon Server context is closed
+----
+
+=== Spring Boot Configuration
+
+[source,yaml]
+----
+# application.yml
+axon:
+ multi-tenancy:
+ enabled: true
+ axon-server:
+ # Predefined contexts (optional)
+ contexts: stanford,mit,berkeley
+
+ # Filter admin contexts (default: true)
+ filter-admin-contexts: true
+----
+
+Without `contexts` configured, tenants are discovered dynamically from Axon Server.
+
+== Complete Example: REST API for Tenant Management
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.SimpleTenantProvider;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/admin/tenants")
+public class TenantManagementController {
+
+ private final SimpleTenantProvider tenantProvider;
+
+ public TenantManagementController(SimpleTenantProvider tenantProvider) {
+ this.tenantProvider = tenantProvider;
+ }
+
+ @GetMapping
+ public List listTenants() {
+ return tenantProvider.getTenants().stream()
+ .map(TenantDescriptor::tenantId)
+ .toList();
+ }
+
+ @PostMapping("/{tenantId}")
+ public ResponseEntity addTenant(@PathVariable String tenantId) {
+ TenantDescriptor tenant = TenantDescriptor.tenantWithId(tenantId);
+
+ if (tenantProvider.addTenant(tenant)) {
+ return ResponseEntity.ok("Tenant added: " + tenantId);
+ } else {
+ return ResponseEntity.badRequest()
+ .body("Tenant already exists: " + tenantId);
+ }
+ }
+
+ @DeleteMapping("/{tenantId}")
+ public ResponseEntity removeTenant(@PathVariable String tenantId) {
+ if (tenantProvider.removeTenant(tenantId)) {
+ return ResponseEntity.ok("Tenant removed: " + tenantId);
+ } else {
+ return ResponseEntity.notFound().build();
+ }
+ }
+
+ @GetMapping("/{tenantId}/exists")
+ public boolean tenantExists(@PathVariable String tenantId) {
+ return tenantProvider.hasTenant(tenantId);
+ }
+}
+----
+
+== Complete Example: Event-Driven Tenant Provisioning
+
+[source,java]
+----
+@Component
+public class TenantProvisioningHandler {
+
+ private final SimpleTenantProvider tenantProvider;
+
+ public TenantProvisioningHandler(SimpleTenantProvider tenantProvider) {
+ this.tenantProvider = tenantProvider;
+ }
+
+ @EventHandler
+ public void on(UniversityOnboarded event) {
+ TenantDescriptor tenant = new TenantDescriptor(
+ event.universityId(),
+ Map.of(
+ "name", event.universityName(),
+ "region", event.region(),
+ "tier", event.subscriptionTier()
+ )
+ );
+
+ tenantProvider.addTenant(tenant);
+ log.info("Provisioned tenant for university: {}", event.universityName());
+ }
+
+ @EventHandler
+ public void on(UniversityDecommissioned event) {
+ tenantProvider.removeTenant(event.universityId());
+ log.info("Decommissioned tenant: {}", event.universityId());
+ }
+}
+----
+
+== Tenant Lifecycle Events
+
+Understanding the full lifecycle when tenants are added/removed:
+
+=== Tenant Addition Flow
+
+[source]
+----
+tenantProvider.addTenant(tenant)
+ |
+ +-- For each subscribed component:
+ |
+ +-- MultiTenantCommandBus.registerAndStartTenant(tenant)
+ | +-- Creates SimpleCommandBus for tenant
+ | +-- Registers command handlers
+ | +-- Returns Registration for cleanup
+ |
+ +-- MultiTenantQueryBus.registerAndStartTenant(tenant)
+ | +-- Creates SimpleQueryBus for tenant
+ | +-- Registers query handlers
+ | +-- Returns Registration for cleanup
+ |
+ +-- MultiTenantEventStore.registerAndStartTenant(tenant)
+ | +-- Creates EventStore segment for tenant
+ | +-- Returns Registration for cleanup
+ |
+ +-- MultiTenantEventProcessor.registerAndStartTenant(tenant)
+ +-- Creates PooledStreamingEventProcessor for tenant
+ +-- Creates TokenStore for tenant
+ +-- Starts processing events
+ +-- Returns Registration for cleanup
+----
+
+=== Tenant Removal Flow
+
+[source]
+----
+tenantProvider.removeTenant(tenant)
+ |
+ +-- Cancels all Registrations for tenant (in reverse order):
+ |
+ +-- EventProcessor Registration cancelled
+ | +-- Stops processing
+ | +-- Cleans up token store
+ |
+ +-- EventStore Registration cancelled
+ | +-- Closes event store segment
+ |
+ +-- QueryBus Registration cancelled
+ | +-- Unregisters query handlers
+ | +-- Closes query bus
+ |
+ +-- CommandBus Registration cancelled
+ +-- Unregisters command handlers
+ +-- Closes command bus
+----
+
+== Best Practices
+
+=== 1. Validate Before Adding
+
+[source,java]
+----
+public void provisionTenant(String tenantId) {
+ // Validate tenant ID format
+ if (!isValidTenantId(tenantId)) {
+ throw new IllegalArgumentException("Invalid tenant ID: " + tenantId);
+ }
+
+ // Check for duplicates
+ if (tenantProvider.hasTenant(tenantId)) {
+ throw new TenantAlreadyExistsException(tenantId);
+ }
+
+ // Verify resources are available (database, etc.)
+ verifyResourcesAvailable(tenantId);
+
+ tenantProvider.addTenant(TenantDescriptor.tenantWithId(tenantId));
+}
+----
+
+=== 2. Handle Removal Gracefully
+
+[source,java]
+----
+public void decommissionTenant(String tenantId) {
+ // Drain in-flight messages first
+ drainMessagesForTenant(tenantId);
+
+ // Remove tenant
+ tenantProvider.removeTenant(tenantId);
+
+ // Archive data if needed
+ archiveTenantData(tenantId);
+}
+----
+
+=== 3. Use TenantConnectPredicate for Filtering
+
+[source,java]
+----
+// Filter at the provider level, not in business logic
+TenantConnectPredicate predicate = tenant -> {
+ String region = tenant.properties().get("region");
+ return "eu".equals(region); // This instance only handles EU tenants
+};
+----
+
+=== 4. Monitor Tenant Operations
+
+[source,java]
+----
+public void addTenantWithMonitoring(TenantDescriptor tenant) {
+ Instant start = Instant.now();
+
+ boolean added = tenantProvider.addTenant(tenant);
+
+ Duration duration = Duration.between(start, Instant.now());
+
+ if (added) {
+ meterRegistry.timer("tenant.provisioning")
+ .record(duration);
+ log.info("Tenant {} provisioned in {}ms", tenant.tenantId(), duration.toMillis());
+ }
+}
+----
+
+=== 5. Expose Health Information
+
+[source,java]
+----
+@Component
+public class TenantHealthIndicator implements HealthIndicator {
+
+ private final TenantProvider tenantProvider;
+
+ @Override
+ public Health health() {
+ List tenants = tenantProvider.getTenants();
+
+ return Health.up()
+ .withDetail("tenantCount", tenants.size())
+ .withDetail("tenants", tenants.stream()
+ .map(TenantDescriptor::tenantId)
+ .toList())
+ .build();
+ }
+}
+----
+
+== Thread Safety
+
+`SimpleTenantProvider` is thread-safe:
+
+* Uses `ConcurrentHashMap.newKeySet()` for tenant storage
+* Uses `CopyOnWriteArrayList` for subscribers
+* Safe to add/remove tenants from any thread
+
+[source,java]
+----
+// Safe to call from multiple threads
+executor.submit(() -> tenantProvider.addTenant(tenant1));
+executor.submit(() -> tenantProvider.addTenant(tenant2));
+executor.submit(() -> tenantProvider.removeTenant("old-tenant"));
+----
+
+== See Also
+
+* xref:tenant-resolution.adoc[Tenant Resolution] - How tenant context is determined
+* xref:event-processors.adoc[Event Processors] - Per-tenant event processing
+* xref:../tenant-management.adoc[Tenant Management Overview] - High-level concepts
diff --git a/docs/reference/modules/ROOT/pages/configuration/event-processors.adoc b/docs/reference/modules/ROOT/pages/configuration/event-processors.adoc
new file mode 100644
index 0000000..2cc087c
--- /dev/null
+++ b/docs/reference/modules/ROOT/pages/configuration/event-processors.adoc
@@ -0,0 +1,481 @@
+:navtitle: Event Processors
+= Event Processor Configuration
+
+Multi-tenant event processing creates isolated event processors per tenant. This page covers how to configure multi-tenant event processors and their token stores.
+
+== Overview
+
+In a multi-tenant application, each tenant needs its own event processor to:
+
+* Process events from their own event store
+* Track progress independently via per-tenant token stores
+* Scale separately based on tenant workload
+
+[source]
+----
+MultiTenantEventProcessor ["courseProjection"]
+ |
+ +-- PooledStreamingEventProcessor ["courseProjection@stanford"]
+ | └── TokenStore (stanford)
+ |
+ +-- PooledStreamingEventProcessor ["courseProjection@mit"]
+ | └── TokenStore (mit)
+ |
+ +-- PooledStreamingEventProcessor ["courseProjection@berkeley"]
+ └── TokenStore (berkeley)
+----
+
+== MultiTenantPooledStreamingEventProcessorModule
+
+This module creates a `MultiTenantEventProcessor` that wraps per-tenant `PooledStreamingEventProcessor` instances.
+
+=== Basic Usage
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.messaging.eventhandling.processing.MultiTenantPooledStreamingEventProcessorModule;
+
+configurer.messaging(m -> m
+ .eventProcessing(ep -> ep
+ .pooledStreaming(ps -> ps
+ .processor(
+ MultiTenantPooledStreamingEventProcessorModule
+ .create("courseProjection")
+ .eventHandlingComponents(c -> c
+ .autodetected(cfg -> new CourseProjector())
+ )
+ )
+ )
+ )
+);
+----
+
+=== With Configuration Customization
+
+[source,java]
+----
+MultiTenantPooledStreamingEventProcessorModule
+ .create("courseProjection")
+ .eventHandlingComponents(c -> c
+ .autodetected(cfg -> new CourseProjector())
+ )
+ .customized((cfg, config) -> config
+ .batchSize(100)
+ .initialSegmentCount(4)
+ .maxClaimedSegments(2)
+ )
+----
+
+=== With Tenant Components
+
+Register tenant-scoped dependencies for injection into handlers:
+
+[source,java]
+----
+MultiTenantPooledStreamingEventProcessorModule
+ .create("courseProjection")
+ .eventHandlingComponents(c -> c
+ .autodetected(cfg -> new CourseProjector())
+ )
+ .tenantComponent(CourseRepository.class, tenant -> new InMemoryCourseRepository())
+ .tenantComponent(MetricsService.class, tenant -> new TenantMetrics(tenant.tenantId()))
+----
+
+== TenantTokenStoreFactory
+
+The `TenantTokenStoreFactory` creates per-tenant token stores for tracking event processing progress:
+
+[source,java]
+----
+@FunctionalInterface
+public interface TenantTokenStoreFactory extends Function {
+
+ @Override
+ TokenStore apply(TenantDescriptor tenant);
+}
+----
+
+=== Available Implementations
+
+==== InMemoryTenantTokenStoreFactory (Default)
+
+Creates in-memory token stores. Suitable for testing and development:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.messaging.eventhandling.processing.InMemoryTenantTokenStoreFactory;
+
+// Default factory - creates InMemoryTokenStore per tenant
+TenantTokenStoreFactory factory = new InMemoryTenantTokenStoreFactory();
+----
+
+WARNING: In-memory token stores lose all progress on restart. Use JPA or JDBC implementations for production.
+
+==== JpaTenantTokenStoreFactory
+
+Creates JPA-based token stores with tenant-specific `EntityManagerFactory`:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.messaging.eventhandling.processing.JpaTenantTokenStoreFactory;
+import org.axonframework.conversion.Converter;
+import jakarta.persistence.EntityManagerFactory;
+
+// Provider for tenant-specific EntityManagerFactory
+Function emfProvider = tenant -> {
+ // Return EMF configured for tenant's database
+ return createEntityManagerFactory(tenant);
+};
+
+// Create factory with converter from configuration
+Converter converter = config.getComponent(Converter.class);
+TenantTokenStoreFactory factory = new JpaTenantTokenStoreFactory(emfProvider, converter);
+----
+
+With custom JPA configuration:
+
+[source,java]
+----
+import org.axonframework.messaging.eventhandling.processing.streaming.token.store.jpa.JpaTokenStoreConfiguration;
+
+JpaTokenStoreConfiguration jpaConfig = JpaTokenStoreConfiguration.builder()
+ .schema("tokens")
+ .build();
+
+TenantTokenStoreFactory factory = new JpaTenantTokenStoreFactory(
+ emfProvider,
+ converter,
+ jpaConfig
+);
+----
+
+==== Custom Implementation
+
+Create custom factories for other storage backends:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.messaging.eventhandling.processing.TenantTokenStoreFactory;
+import org.axonframework.messaging.eventhandling.processing.streaming.token.store.jdbc.JdbcTokenStore;
+
+public class JdbcTenantTokenStoreFactory implements TenantTokenStoreFactory {
+
+ private final Map tokenStores = new ConcurrentHashMap<>();
+ private final TenantDataSourceProvider dataSourceProvider;
+ private final Converter converter;
+
+ public JdbcTenantTokenStoreFactory(
+ TenantDataSourceProvider dataSourceProvider,
+ Converter converter) {
+ this.dataSourceProvider = dataSourceProvider;
+ this.converter = converter;
+ }
+
+ @Override
+ public TokenStore apply(TenantDescriptor tenant) {
+ return tokenStores.computeIfAbsent(tenant, this::createTokenStore);
+ }
+
+ private TokenStore createTokenStore(TenantDescriptor tenant) {
+ DataSource dataSource = dataSourceProvider.getDataSource(tenant);
+ ConnectionProvider connectionProvider = new DataSourceConnectionProvider(dataSource);
+ return JdbcTokenStore.builder()
+ .connectionProvider(connectionProvider)
+ .converter(converter)
+ .build();
+ }
+}
+----
+
+== Configuration
+
+=== Global Token Store Factory
+
+Configure a global factory used by all multi-tenant processors:
+
+==== Axon Framework
+
+[source,java]
+----
+configurer.componentRegistry(cr -> cr.registerComponent(
+ TenantTokenStoreFactory.class,
+ config -> new JpaTenantTokenStoreFactory(
+ tenant -> getEntityManagerFactory(tenant),
+ config.getComponent(Converter.class)
+ )
+));
+----
+
+==== Spring Boot
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.messaging.eventhandling.processing.TenantTokenStoreFactory;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class EventProcessorConfiguration {
+
+ @Bean
+ public TenantTokenStoreFactory tenantTokenStoreFactory(
+ TenantDataSourceProvider dataSourceProvider,
+ Converter converter) {
+ return new JdbcTenantTokenStoreFactory(dataSourceProvider, converter);
+ }
+}
+----
+
+=== Per-Processor Token Store Factory
+
+Override the global factory for a specific processor:
+
+[source,java]
+----
+MultiTenantPooledStreamingEventProcessorModule
+ .create("auditLogProjection")
+ .eventHandlingComponents(c -> c.autodetected(cfg -> new AuditLogProjector()))
+ .customized((cfg, config) -> config
+ // Use dedicated token store for this processor
+ .tenantTokenStoreFactory(new JpaTenantTokenStoreFactory(
+ tenant -> getAuditEntityManagerFactory(tenant),
+ cfg.getComponent(Converter.class)
+ ))
+ )
+----
+
+== MultiTenantEventProcessorPredicate
+
+Control which processing groups use multi-tenant processors:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.configuration.MultiTenantEventProcessorPredicate;
+
+// Enable multi-tenancy for all processors (default)
+MultiTenantEventProcessorPredicate.enableMultiTenancy()
+
+// Disable multi-tenancy for all processors
+MultiTenantEventProcessorPredicate.disableMultiTenancy()
+
+// Custom predicate - enable for specific processors
+MultiTenantEventProcessorPredicate predicate = processorName ->
+ processorName.startsWith("tenant-") || processorName.equals("courseProjection");
+----
+
+=== Spring Boot Configuration
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.configuration.MultiTenantEventProcessorPredicate;
+import org.springframework.context.annotation.Bean;
+
+@Bean
+public MultiTenantEventProcessorPredicate multiTenantEventProcessorPredicate() {
+ return processorName -> {
+ // Only these processors are multi-tenant
+ Set multiTenantProcessors = Set.of(
+ "courseProjection",
+ "enrollmentProjection",
+ "gradeProjection"
+ );
+ return multiTenantProcessors.contains(processorName);
+ };
+}
+----
+
+== Processing Group Configuration
+
+Each processing group becomes a multi-tenant event processor. Configure groups via annotations:
+
+[source,java]
+----
+import org.axonframework.messaging.eventhandling.annotation.EventHandler;
+import org.axonframework.messaging.eventhandling.annotation.ProcessingGroup;
+
+@ProcessingGroup("courseProjection")
+@Component
+public class CourseProjector {
+
+ @EventHandler
+ public void on(CourseCreated event, CourseRepository repository) {
+ repository.save(new CourseReadModel(
+ event.courseId(),
+ event.name(),
+ event.capacity()
+ ));
+ }
+}
+----
+
+Multiple handlers in the same group share the same multi-tenant processor:
+
+[source,java]
+----
+@ProcessingGroup("courseProjection")
+@Component
+public class CourseStatsProjector {
+
+ @EventHandler
+ public void on(StudentEnrolled event, CourseStatsRepository repository) {
+ repository.incrementEnrollmentCount(event.courseId());
+ }
+}
+----
+
+== Per-Tenant Processor Names
+
+The extension creates tenant-specific processor names by appending `@tenantId`:
+
+[source]
+----
+Processing Group: "courseProjection"
+
+Per-Tenant Processors:
+ - courseProjection@stanford
+ - courseProjection@mit
+ - courseProjection@berkeley
+----
+
+This naming convention helps with:
+
+* Logging and debugging
+* Metrics collection per tenant
+* Token store isolation
+
+== Lifecycle Management
+
+=== Starting Processors
+
+Multi-tenant processors start during application startup:
+
+[source,java]
+----
+// Lifecycle phases:
+// 1. TenantProvider registers tenants
+// 2. MultiTenantEventProcessor subscribes to TenantProvider
+// 3. Per-tenant processors created for each registered tenant
+// 4. All processors started
+----
+
+=== Dynamic Tenant Addition
+
+When a tenant is added at runtime:
+
+[source,java]
+----
+// Adding a new tenant
+tenantProvider.addTenant(TenantDescriptor.tenantWithId("caltech"));
+
+// Automatically triggers:
+// 1. New PooledStreamingEventProcessor created: "courseProjection@caltech"
+// 2. Token store created for caltech
+// 3. Processor started, begins processing from initial token
+----
+
+=== Tenant Removal
+
+When a tenant is removed:
+
+[source,java]
+----
+// Removing a tenant
+tenantProvider.removeTenant(TenantDescriptor.tenantWithId("caltech"));
+
+// Automatically triggers:
+// 1. Processor "courseProjection@caltech" stopped
+// 2. Resources cleaned up
+----
+
+== Spring Boot Auto-Configuration
+
+Spring Boot auto-configures multi-tenant event processing when enabled:
+
+[source,yaml]
+----
+# application.yml
+axon:
+ multi-tenancy:
+ enabled: true # Default
+----
+
+The auto-configuration provides:
+
+* `MultiTenantMessageHandlerLookup` - Creates multi-tenant processors for `@EventHandler` components
+* `InMemoryTenantTokenStoreFactory` - Default token store factory (override for production)
+
+=== Overriding Auto-Configuration
+
+[source,java]
+----
+@Configuration
+public class CustomEventProcessorConfig {
+
+ // Override token store factory
+ @Bean
+ public TenantTokenStoreFactory tenantTokenStoreFactory(
+ TenantDataSourceProvider dataSourceProvider,
+ Converter converter) {
+ return new JpaTenantTokenStoreFactory(
+ tenant -> dataSourceProvider.getEntityManagerFactory(tenant),
+ converter
+ );
+ }
+
+ // Control which processors are multi-tenant
+ @Bean
+ public MultiTenantEventProcessorPredicate multiTenantEventProcessorPredicate() {
+ return processorName -> !processorName.equals("globalAuditProcessor");
+ }
+}
+----
+
+== Best Practices
+
+=== 1. Use Persistent Token Stores in Production
+
+[source,java]
+----
+// Development/Testing
+TenantTokenStoreFactory factory = new InMemoryTenantTokenStoreFactory();
+
+// Production
+TenantTokenStoreFactory factory = new JpaTenantTokenStoreFactory(
+ emfProvider, converter
+);
+----
+
+=== 2. Configure Appropriate Batch Sizes
+
+[source,java]
+----
+.customized((cfg, config) -> config
+ .batchSize(100) // Balance throughput vs memory
+)
+----
+
+=== 3. Monitor Per-Tenant Processing
+
+Use metrics to track processing lag per tenant:
+
+[source,java]
+----
+.customized((cfg, config) -> config
+ .messageMonitor(new TenantAwareMetricsMonitor())
+)
+----
+
+=== 4. Handle Tenant-Specific Errors
+
+[source,java]
+----
+.customized((cfg, config) -> config
+ .errorHandler(new TenantAwareErrorHandler())
+)
+----
+
+== See Also
+
+* xref:tenant-components.adoc[Tenant Components] - Injecting tenant-scoped dependencies
+* xref:../event-processors.adoc[Event Processors Overview] - General event processor concepts
+* xref:dynamic-tenants.adoc[Dynamic Tenants] - Adding/removing tenants at runtime
diff --git a/docs/reference/modules/ROOT/pages/configuration/tenant-components.adoc b/docs/reference/modules/ROOT/pages/configuration/tenant-components.adoc
new file mode 100644
index 0000000..9c08a43
--- /dev/null
+++ b/docs/reference/modules/ROOT/pages/configuration/tenant-components.adoc
@@ -0,0 +1,523 @@
+:navtitle: Tenant Components
+= Tenant-Scoped Components
+
+Tenant-scoped components provide per-tenant dependencies to message handlers. This page covers how to define, register, and use tenant components.
+
+== Overview
+
+In multi-tenant applications, handlers often need tenant-specific resources like repositories, services, or configuration. Tenant components solve this by:
+
+* Creating isolated instances per tenant
+* Automatically resolving the correct instance based on message context
+* Managing lifecycle (creation and cleanup) per tenant
+
+[source]
+----
+@EventHandler
+void on(CourseCreated event, CourseRepository repository) {
+ // repository is automatically the correct tenant's instance
+ repository.save(new CourseReadModel(event));
+}
+----
+
+== TenantComponentFactory Interface
+
+The `TenantComponentFactory` is the core interface for creating tenant-scoped components:
+
+[source,java]
+----
+@FunctionalInterface
+public interface TenantComponentFactory extends Function {
+
+ T apply(TenantDescriptor tenant);
+
+ default void cleanup(TenantDescriptor tenant, T component) {
+ if (component instanceof AutoCloseable autoCloseable) {
+ try {
+ autoCloseable.close();
+ } catch (Exception e) {
+ // Logged but not propagated
+ }
+ }
+ }
+}
+----
+
+=== Simple Factory
+
+Use a lambda for simple cases:
+
+[source,java]
+----
+TenantComponentFactory factory =
+ tenant -> new InMemoryCourseRepository();
+----
+
+=== Factory with Tenant Context
+
+Access tenant information when creating components:
+
+[source,java]
+----
+TenantComponentFactory factory =
+ tenant -> new JpaCourseRepository(
+ getDataSourceForTenant(tenant.tenantId())
+ );
+----
+
+=== Factory with Custom Cleanup
+
+Override cleanup for resources requiring special handling:
+
+[source,java]
+----
+TenantComponentFactory factory = new TenantComponentFactory<>() {
+
+ @Override
+ public EntityManagerFactory apply(TenantDescriptor tenant) {
+ return createEntityManagerFactory(tenant);
+ }
+
+ @Override
+ public void cleanup(TenantDescriptor tenant, EntityManagerFactory emf) {
+ emf.close();
+ logger.info("Closed EMF for tenant {}", tenant.tenantId());
+ }
+};
+----
+
+== Registering Tenant Components
+
+=== Axon Framework (Programmatic)
+
+Use `MultiTenancyConfigurer.tenantComponent()`:
+
+[source,java]
+----
+import org.axonframework.eventsourcing.configuration.EventSourcingConfigurer;
+import org.axonframework.extension.multitenancy.core.configuration.MultiTenancyConfigurer;
+
+EventSourcingConfigurer configurer = EventSourcingConfigurer.create();
+
+MultiTenancyConfigurer.enhance(configurer)
+ .registerTenantProvider(config -> tenantProvider)
+ .registerTargetTenantResolver(config -> new MetadataBasedTenantResolver())
+ // Register tenant components
+ .tenantComponent(CourseRepository.class, tenant -> new InMemoryCourseRepository())
+ .tenantComponent(MetricsService.class, tenant -> new TenantMetrics(tenant.tenantId()));
+----
+
+With custom cleanup:
+
+[source,java]
+----
+MultiTenancyConfigurer.enhance(configurer)
+ .tenantComponent(
+ ConnectionPool.class,
+ tenant -> createPoolForTenant(tenant),
+ (tenant, pool) -> {
+ pool.drain();
+ pool.close();
+ }
+ );
+----
+
+=== MultiTenantPooledStreamingEventProcessorModule
+
+Register per-processor tenant components:
+
+[source,java]
+----
+MultiTenantPooledStreamingEventProcessorModule
+ .create("courseProjection")
+ .eventHandlingComponents(c -> c.autodetected(cfg -> new CourseProjector()))
+ .tenantComponent(CourseRepository.class, tenant -> new InMemoryCourseRepository())
+ .tenantComponent(AuditService.class, tenant -> new TenantAuditService(tenant.tenantId()))
+----
+
+=== Spring Boot with TenantComponent Interface
+
+For Spring Boot applications, implement the `TenantComponent` interface for automatic discovery:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.spring.TenantComponent;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+
+// Note: Do NOT add @Component annotation!
+public class TenantAuditService implements TenantComponent {
+
+ private final Clock clock; // Spring-injected dependency
+ private final String tenantId; // Tenant context
+
+ // Constructor for factory instance - Spring injects Clock
+ public TenantAuditService(Clock clock) {
+ this.clock = clock;
+ this.tenantId = null;
+ }
+
+ // Private constructor for tenant-specific instances
+ private TenantAuditService(Clock clock, String tenantId) {
+ this.clock = clock;
+ this.tenantId = tenantId;
+ }
+
+ @Override
+ public TenantAuditService createForTenant(TenantDescriptor tenant) {
+ return new TenantAuditService(clock, tenant.tenantId());
+ }
+
+ public void recordAudit(String action) {
+ Instant timestamp = clock.instant();
+ // ... record audit entry for tenantId
+ }
+
+ public String getTenantId() {
+ return tenantId;
+ }
+}
+----
+
+IMPORTANT: Do NOT annotate `TenantComponent` implementations with `@Component`, `@Service`, or similar. The auto-configuration discovers them via classpath scanning and creates factory instances with Spring DI without registering them as beans.
+
+=== Spring Data JPA Repositories
+
+For JPA repositories, use `TenantRepositoryParameterResolverFactory`:
+
+[source,java]
+----
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface CourseStatsJpaRepository extends JpaRepository {
+ List findAll();
+}
+----
+
+The repository is automatically injected as tenant-scoped when `TenantDataSourceProvider` is configured:
+
+[source,java]
+----
+@EventHandler
+public void on(CourseCreated event, CourseStatsJpaRepository repository) {
+ // repository uses the tenant's EntityManagerFactory
+ repository.save(new CourseStatsReadModel(
+ event.courseId().raw(),
+ event.name(),
+ event.capacity()
+ ));
+}
+----
+
+== TenantComponentRegistry
+
+The `TenantComponentRegistry` manages component instances per tenant:
+
+[source,java]
+----
+TenantComponentRegistry registry = new TenantComponentRegistry<>(
+ CourseRepository.class,
+ tenant -> new InMemoryCourseRepository()
+);
+
+// Get (or create) component for a tenant
+CourseRepository repo = registry.getComponent(TenantDescriptor.tenantWithId("stanford"));
+
+// Check registered tenants
+Set tenants = registry.getTenants();
+----
+
+=== Lazy Creation
+
+Components are created lazily on first access, not when a tenant is registered:
+
+[source,java]
+----
+// Tenant registered, but no component created yet
+registry.registerTenant(tenant);
+
+// Now component is created and cached
+CourseRepository repo = registry.getComponent(tenant);
+
+// Subsequent calls return cached instance
+CourseRepository sameRepo = registry.getComponent(tenant); // Same instance
+----
+
+=== Cleanup on Tenant Removal
+
+When a tenant is unregistered, the registry cleans up its component:
+
+[source,java]
+----
+Registration registration = registry.registerTenant(tenant);
+
+// Later, when tenant is removed:
+registration.cancel(); // Calls factory.cleanup(tenant, component)
+----
+
+== TenantAwareProcessingContext
+
+Access tenant components via `ProcessingContext.component()`:
+
+[source,java]
+----
+@EventHandler
+void on(CourseCreated event, ProcessingContext context) {
+ CourseRepository repository = context.component(CourseRepository.class);
+ // repository is the tenant-scoped instance
+ repository.save(new CourseReadModel(event));
+}
+----
+
+This uses `TenantAwareProcessingContext` which wraps the standard context and intercepts `component()` calls for registered tenant types.
+
+== Per-Tenant Spring Data JPA Repositories
+
+For full tenant isolation with separate databases, configure per-tenant JPA:
+
+=== Configuration
+
+[source,yaml]
+----
+# application.yml
+axon:
+ multi-tenancy:
+ enabled: true
+ jpa:
+ tenant-repositories: true
+----
+
+=== TenantDataSourceProvider
+
+Implement the provider to supply tenant-specific data sources:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.spring.data.jpa.TenantDataSourceProvider;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import javax.sql.DataSource;
+
+@Component
+public class UniversityDataSourceProvider implements TenantDataSourceProvider {
+
+ private final Map dataSources = new ConcurrentHashMap<>();
+
+ @Override
+ public DataSource getDataSource(TenantDescriptor tenant) {
+ return dataSources.computeIfAbsent(tenant.tenantId(), this::createDataSource);
+ }
+
+ private DataSource createDataSource(String tenantId) {
+ HikariConfig config = new HikariConfig();
+ config.setJdbcUrl("jdbc:postgresql://localhost:5432/university_" + tenantId);
+ config.setUsername("app");
+ config.setPassword("secret");
+ return new HikariDataSource(config);
+ }
+}
+----
+
+=== Using TenantJpaRepositoryFactory
+
+For programmatic registration:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.spring.data.jpa.TenantJpaRepositoryFactory;
+import org.axonframework.extension.multitenancy.spring.data.jpa.TenantEntityManagerFactoryBuilder;
+import org.axonframework.extension.multitenancy.spring.data.jpa.TenantTransactionManagerBuilder;
+
+TenantJpaRepositoryFactory factory =
+ TenantJpaRepositoryFactory.forRepository(
+ CourseStatsJpaRepository.class,
+ emfBuilder,
+ txBuilder
+ );
+
+MultiTenancyConfigurer.enhance(configurer)
+ .tenantComponent(CourseStatsJpaRepository.class, factory);
+----
+
+== Complete Example
+
+=== Domain Model
+
+[source,java]
+----
+// Read model entity
+@Entity
+@Table(name = "course_stats")
+public class CourseStatsReadModel {
+
+ @Id
+ private String courseId;
+ private String name;
+ private int capacity;
+ private int enrollmentCount = 0;
+
+ // constructors, getters, setters
+}
+
+// Repository interface
+public interface CourseStatsJpaRepository extends JpaRepository {
+ List findAll();
+}
+----
+
+=== Tenant-Scoped Service
+
+[source,java]
+----
+// Note: No @Component annotation!
+public class EnrollmentMetricsService implements TenantComponent {
+
+ private final MeterRegistry meterRegistry; // Spring dependency
+ private final String tenantId;
+ private final Counter enrollmentCounter;
+
+ public EnrollmentMetricsService(MeterRegistry meterRegistry) {
+ this.meterRegistry = meterRegistry;
+ this.tenantId = null;
+ this.enrollmentCounter = null;
+ }
+
+ private EnrollmentMetricsService(MeterRegistry meterRegistry, String tenantId) {
+ this.meterRegistry = meterRegistry;
+ this.tenantId = tenantId;
+ this.enrollmentCounter = Counter.builder("enrollments")
+ .tag("tenant", tenantId)
+ .register(meterRegistry);
+ }
+
+ @Override
+ public EnrollmentMetricsService createForTenant(TenantDescriptor tenant) {
+ return new EnrollmentMetricsService(meterRegistry, tenant.tenantId());
+ }
+
+ public void recordEnrollment() {
+ enrollmentCounter.increment();
+ }
+}
+----
+
+=== Event Handler
+
+[source,java]
+----
+@ProcessingGroup("courseProjection")
+@Component
+public class CourseStatsProjector {
+
+ @EventHandler
+ public void on(CourseCreated event,
+ CourseStatsJpaRepository repository,
+ EnrollmentMetricsService metricsService) {
+
+ repository.save(new CourseStatsReadModel(
+ event.courseId().raw(),
+ event.name(),
+ event.capacity()
+ ));
+ }
+
+ @EventHandler
+ public void on(StudentEnrolled event,
+ CourseStatsJpaRepository repository,
+ EnrollmentMetricsService metricsService) {
+
+ repository.findById(event.courseId().raw())
+ .ifPresent(stats -> {
+ stats.setEnrollmentCount(stats.getEnrollmentCount() + 1);
+ repository.save(stats);
+ metricsService.recordEnrollment();
+ });
+ }
+}
+----
+
+== Best Practices
+
+=== 1. Keep Factory Instances Stateless
+
+Factory instances should only hold shared dependencies, not tenant state:
+
+[source,java]
+----
+// CORRECT - Factory holds shared dependency
+public class TenantService implements TenantComponent {
+ private final SharedDependency shared; // Shared across all tenants
+
+ public TenantService(SharedDependency shared) {
+ this.shared = shared;
+ }
+
+ @Override
+ public TenantService createForTenant(TenantDescriptor tenant) {
+ return new TenantService(shared, tenant.tenantId());
+ }
+}
+
+// INCORRECT - Factory accumulates state
+public class BadTenantService implements TenantComponent {
+ private final List processedIds = new ArrayList<>(); // Don't do this!
+}
+----
+
+=== 2. Use Private Constructors for Tenant Instances
+
+Prevent accidental creation of tenant instances:
+
+[source,java]
+----
+public class TenantAuditService implements TenantComponent {
+
+ // Public constructor for factory (Spring DI)
+ public TenantAuditService(Clock clock) {
+ this.clock = clock;
+ this.tenantId = null;
+ }
+
+ // Private constructor for tenant instances
+ private TenantAuditService(Clock clock, String tenantId) {
+ this.clock = clock;
+ this.tenantId = tenantId;
+ }
+}
+----
+
+=== 3. Implement AutoCloseable for Resource Cleanup
+
+[source,java]
+----
+public class TenantConnectionPool implements TenantComponent,
+ AutoCloseable {
+
+ @Override
+ public void close() {
+ pool.shutdown();
+ }
+
+ // cleanupForTenant will automatically call close()
+}
+----
+
+=== 4. Validate Tenant Context
+
+Guard against usage on factory instances:
+
+[source,java]
+----
+public void recordAudit(String action) {
+ if (tenantId == null) {
+ throw new IllegalStateException(
+ "Cannot record audit on factory instance - no tenant context"
+ );
+ }
+ // ... implementation
+}
+----
+
+== See Also
+
+* xref:event-processors.adoc[Event Processors] - Configuring multi-tenant processors
+* xref:tenant-resolution.adoc[Tenant Resolution] - How tenant context is determined
+* xref:dynamic-tenants.adoc[Dynamic Tenants] - Adding/removing tenants at runtime
diff --git a/docs/reference/modules/ROOT/pages/configuration/tenant-resolution.adoc b/docs/reference/modules/ROOT/pages/configuration/tenant-resolution.adoc
new file mode 100644
index 0000000..65dd176
--- /dev/null
+++ b/docs/reference/modules/ROOT/pages/configuration/tenant-resolution.adoc
@@ -0,0 +1,410 @@
+:navtitle: Tenant Resolution
+= Tenant Resolution
+
+Tenant resolution determines which tenant a message belongs to. This page covers how to configure tenant resolution and propagation.
+
+== Overview
+
+When a message enters the multi-tenant system, the `TargetTenantResolver` extracts the target tenant:
+
+[source]
+----
+Incoming Message
+ |
+ v
+TargetTenantResolver.resolveTenant(message, tenants)
+ |
+ v
+TenantDescriptor --> Route to tenant's infrastructure
+----
+
+== TargetTenantResolver Interface
+
+The `TargetTenantResolver` interface is the core contract for tenant resolution:
+
+[source,java]
+----
+public interface TargetTenantResolver
+ extends BiFunction, TenantDescriptor> {
+
+ default TenantDescriptor resolveTenant(M message, Collection tenants) {
+ return this.apply(message, Collections.unmodifiableCollection(tenants));
+ }
+}
+----
+
+The resolver receives:
+
+* The message being processed
+* The collection of currently registered tenants
+
+It returns the `TenantDescriptor` identifying the target tenant.
+
+== MetadataBasedTenantResolver (Default)
+
+The default implementation extracts tenant ID from message metadata:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.MetadataBasedTenantResolver;
+
+// Using default key "tenantId"
+TargetTenantResolver resolver = new MetadataBasedTenantResolver();
+
+// Using custom key
+TargetTenantResolver resolver = new MetadataBasedTenantResolver("universityTenant");
+----
+
+=== Default Metadata Key
+
+The default key is `tenantId`:
+
+[source,java]
+----
+public static final String DEFAULT_TENANT_KEY = "tenantId";
+----
+
+=== Error Handling
+
+When the metadata key is missing, `NoSuchTenantException` is thrown:
+
+[source,java]
+----
+// Command without tenantId in metadata
+commandGateway.sendAndWait(new EnrollStudent(studentId, courseId));
+// Throws: NoSuchTenantException("No tenant identifier found in message metadata under key 'tenantId'")
+----
+
+== Custom Tenant Resolvers
+
+Implement custom resolvers for alternative routing strategies.
+
+=== Payload-Based Resolution
+
+Extract tenant from the message payload:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.TargetTenantResolver;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.extension.multitenancy.core.NoSuchTenantException;
+import org.axonframework.messaging.core.Message;
+
+public class PayloadBasedTenantResolver implements TargetTenantResolver {
+
+ @Override
+ public TenantDescriptor apply(Message message, Collection tenants) {
+ Object payload = message.payload();
+
+ if (payload instanceof UniversityCommand cmd) {
+ return TenantDescriptor.tenantWithId(cmd.getUniversityId());
+ }
+
+ throw new NoSuchTenantException("Cannot determine university from command");
+ }
+}
+----
+
+=== Header-Based Resolution with Fallback
+
+Try multiple sources for tenant identification:
+
+[source,java]
+----
+public class FallbackTenantResolver implements TargetTenantResolver {
+
+ private static final String PRIMARY_KEY = "tenantId";
+ private static final String FALLBACK_KEY = "university";
+
+ @Override
+ public TenantDescriptor apply(Message message, Collection tenants) {
+ Metadata metadata = message.metadata();
+
+ // Try primary key first
+ String tenantId = metadata.get(PRIMARY_KEY);
+
+ // Fall back to secondary key
+ if (tenantId == null) {
+ tenantId = metadata.get(FALLBACK_KEY);
+ }
+
+ if (tenantId == null) {
+ throw new NoSuchTenantException("No tenant found in metadata");
+ }
+
+ return TenantDescriptor.tenantWithId(tenantId);
+ }
+}
+----
+
+== Tenant Correlation Provider
+
+The `TenantCorrelationProvider` ensures tenant context propagates from one message to subsequent messages:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.autoconfig.TenantCorrelationProvider;
+
+// Creates a provider that propagates the "tenantId" key
+TenantCorrelationProvider provider = new TenantCorrelationProvider("tenantId");
+----
+
+=== How Correlation Works
+
+When a command handler publishes events, the correlation provider copies tenant metadata:
+
+[source]
+----
+EnrollStudentCommand {tenantId: "stanford"}
+ |
+ v
+Command Handler
+ |
+ +-- StudentEnrolledEvent {tenantId: "stanford"} <-- auto-propagated
+ |
+ v
+Event Handler
+ |
+ +-- SendWelcomeEmailCommand {tenantId: "stanford"} <-- auto-propagated
+----
+
+=== Default Tenant Handling
+
+If the tenant key is missing, the provider uses `"unknownTenant"`:
+
+[source,java]
+----
+@Override
+public Map correlationDataFor(Message message) {
+ Map result = new HashMap<>();
+ String tenantId = metadata.containsKey(tenantCorrelationKey)
+ ? metadata.get(tenantCorrelationKey)
+ : "unknownTenant";
+ result.put(tenantCorrelationKey, tenantId);
+ return result;
+}
+----
+
+== Configuration
+
+=== Axon Framework (Programmatic)
+
+[source,java]
+----
+import org.axonframework.eventsourcing.configuration.EventSourcingConfigurer;
+import org.axonframework.extension.multitenancy.core.MetadataBasedTenantResolver;
+import org.axonframework.extension.multitenancy.core.configuration.MultiTenancyConfigurer;
+import org.axonframework.extension.multitenancy.core.SimpleTenantProvider;
+
+// Create configurer
+EventSourcingConfigurer configurer = EventSourcingConfigurer.create();
+
+// Create tenant provider
+SimpleTenantProvider tenantProvider = new SimpleTenantProvider(List.of(
+ TenantDescriptor.tenantWithId("stanford"),
+ TenantDescriptor.tenantWithId("mit"),
+ TenantDescriptor.tenantWithId("berkeley")
+));
+
+// Enhance with multi-tenancy
+MultiTenancyConfigurer.enhance(configurer)
+ .registerTenantProvider(config -> tenantProvider)
+ .registerTargetTenantResolver(config -> new MetadataBasedTenantResolver("tenantId"));
+
+// Register correlation provider for propagation
+configurer.messaging(mc -> mc.registerCorrelationDataProvider(config -> message -> {
+ Map result = new HashMap<>();
+ if (message.metadata().containsKey("tenantId")) {
+ result.put("tenantId", message.metadata().get("tenantId"));
+ }
+ return result;
+}));
+
+// Build and start
+AxonConfiguration configuration = configurer.start();
+----
+
+=== Spring Boot
+
+Spring Boot auto-configures both the resolver and correlation provider:
+
+[source,yaml]
+----
+# application.yml
+axon:
+ multi-tenancy:
+ enabled: true
+ tenant-key: tenantId # Used by both resolver and correlation provider
+----
+
+==== Custom Resolver Bean
+
+Override the default resolver by defining a bean:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.TargetTenantResolver;
+import org.axonframework.messaging.core.Message;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class TenantConfiguration {
+
+ @Bean
+ public TargetTenantResolver targetTenantResolver() {
+ return new PayloadBasedTenantResolver();
+ }
+}
+----
+
+== Adding Tenant to Entry Point Messages
+
+At your application's entry point, add tenant context to the first message:
+
+=== REST Controller Example
+
+[source,java]
+----
+import org.axonframework.messaging.commandhandling.gateway.CommandGateway;
+import org.axonframework.messaging.core.Metadata;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/universities/{universityId}/courses")
+public class CourseController {
+
+ private final CommandGateway commandGateway;
+
+ public CourseController(CommandGateway commandGateway) {
+ this.commandGateway = commandGateway;
+ }
+
+ @PostMapping
+ public CompletableFuture createCourse(
+ @PathVariable String universityId,
+ @RequestBody CreateCourseRequest request) {
+
+ return commandGateway.send(
+ new CreateCourse(
+ CourseId.random(),
+ request.getName(),
+ request.getCapacity()
+ ),
+ Metadata.with("tenantId", universityId)
+ ).getResultMessage()
+ .thenApply(result -> result.payload().toString());
+ }
+}
+----
+
+=== Message Listener Example
+
+[source,java]
+----
+@Component
+public class ExternalEventListener {
+
+ private final CommandGateway commandGateway;
+
+ @KafkaListener(topics = "enrollment-requests")
+ public void handleEnrollmentRequest(EnrollmentRequestMessage message) {
+ commandGateway.send(
+ new EnrollStudent(
+ StudentId.from(message.getStudentId()),
+ CourseId.from(message.getCourseId())
+ ),
+ Metadata.with("tenantId", message.getUniversityId())
+ );
+ }
+}
+----
+
+== Best Practices
+
+=== 1. Extract Tenant Early
+
+Determine tenant at the API boundary (authentication, headers, path):
+
+[source,java]
+----
+@RestController
+public class UniversityApiController {
+
+ @PostMapping("/courses")
+ public CompletableFuture> createCourse(
+ @RequestHeader("X-University-Id") String universityId, // From auth system
+ @RequestBody CreateCourseRequest request) {
+ // universityId validated by security filter
+ return commandGateway.send(
+ new CreateCourse(...),
+ Metadata.with("tenantId", universityId)
+ ).getResultMessage();
+ }
+}
+----
+
+=== 2. Use Consistent Metadata Key
+
+Use the same key throughout your application:
+
+[source,yaml]
+----
+# application.yml - Single source of truth
+axon:
+ multi-tenancy:
+ tenant-key: tenantId
+----
+
+=== 3. Validate Tenant Existence
+
+Consider validating tenant before processing:
+
+[source,java]
+----
+@Component
+public class TenantValidationInterceptor implements MessageDispatchInterceptor {
+
+ private final TenantProvider tenantProvider;
+
+ @Override
+ public CommandMessage handle(CommandMessage message) {
+ String tenantId = message.metadata().get("tenantId");
+ if (tenantId != null) {
+ boolean exists = tenantProvider.getTenants().stream()
+ .anyMatch(t -> t.tenantId().equals(tenantId));
+ if (!exists) {
+ throw new NoSuchTenantException("Unknown tenant: " + tenantId);
+ }
+ }
+ return message;
+ }
+}
+----
+
+=== 4. Never Hardcode Tenant in Business Logic
+
+Let infrastructure handle routing:
+
+[source,java]
+----
+// WRONG - Tenant logic in command handler
+@CommandHandler
+public void handle(CreateCourse command) {
+ if (command.getUniversityId().equals("stanford")) { // Don't do this
+ // special logic
+ }
+}
+
+// RIGHT - Command handler is tenant-agnostic
+@CommandHandler
+public static void handle(CreateCourse command, EventAppender appender) {
+ appender.append(new CourseCreated(command.courseId(), command.name(), command.capacity()));
+}
+----
+
+== See Also
+
+* xref:../tenant-management.adoc[Tenant Management] - Managing tenant lifecycle
+* xref:dynamic-tenants.adoc[Dynamic Tenants] - Adding/removing tenants at runtime
+* xref:../message-routing.adoc[Message Routing] - How messages flow through the system
diff --git a/docs/reference/modules/ROOT/pages/disable.adoc b/docs/reference/modules/ROOT/pages/disable.adoc
deleted file mode 100644
index c55cc62..0000000
--- a/docs/reference/modules/ROOT/pages/disable.adoc
+++ /dev/null
@@ -1,9 +0,0 @@
-:navtitle: Disable Extension
-= Disable Extension
-
-By default, extension is automatically enabled. If you wish to disable extension without removing extension use following property.
-
-[source,properties]
-----
-axon.multi-tenancy.enabled=false
-----
diff --git a/docs/reference/modules/ROOT/pages/index.adoc b/docs/reference/modules/ROOT/pages/index.adoc
index f27e747..b89f095 100644
--- a/docs/reference/modules/ROOT/pages/index.adoc
+++ b/docs/reference/modules/ROOT/pages/index.adoc
@@ -1,15 +1,193 @@
:navtitle: Introduction
-= Introduction
+= Multitenancy Extension
-Axon Framework Multitenancy Extension provides your application ability to serve multiple tenants (event-stores) at once. Multi-tenancy is important in cloud computing and this extension will provide ability to connect to tenants dynamically, physical separate tenants data, scale tenants independently...
+Multitenancy is an architectural pattern where a single application instance serves multiple tenants -- such as universities, departments, or organizations -- while keeping their data and operations logically isolated.
+This extension enables Axon Framework applications to support multiple tenants with complete isolation of commands, events, queries, and event processors.
-== Requirements
-Currently, following requirements needs to be meet for extension to work:
+== How It Works
-- Use *Spring Framework* together with *Axon Framework 4.6+*
-- Use *Axon Server EE 4.6+* or Axon Cloud as event store
-- This is not hard requirement but if you wish to enable multitenancy on projection side, only SQL databases are supported out-of-the box
+The Multitenancy Extension transforms your single-tenant Axon application into a multi-tenant system by introducing a routing layer.
+Instead of a single command bus, event store, query bus, and event processor, the extension manages multiple _tenant segments_ -- one set of infrastructure components per tenant.
-== Restrictions
+=== Single-Tenant Architecture
-Only components that interact with Axon Server and or database via JPA are supported out of the box. If you wish to use other components, you need to implement multi-tenancy support for them.
+In a standard Axon application, all messages flow through shared infrastructure:
+
+[source]
+----
+ +------------------+
+ Command -------->| |
+ | Command Bus |-------> Command Handler
+ Query ---------->| |
+ | Query Bus |-------> Query Handler
+ | |
+ Event <----------| Event Store |<------- Command Handler
+ | |
+ | Event Processor |-------> Event Handler
+ +------------------+
+----
+
+=== Multi-Tenant Architecture
+
+With the Multitenancy Extension, each tenant gets its own isolated segment:
+
+[source]
+----
+ +---------------------------+
+ | Multi-Tenant Command |
+ Command + tenantId --->| Bus |
+ +---------------------------+
+ |
+ +-----------------------+-----------------------+
+ | | |
+ v v v
+ +------------------+ +------------------+ +------------------+
+ | MIT Segment | | Stanford Segment | | Oxford Segment |
+ | | | | | |
+ | Command Bus | | Command Bus | | Command Bus |
+ | Event Store | | Event Store | | Event Store |
+ | Query Bus | | Query Bus | | Query Bus |
+ | Event Processor | | Event Processor | | Event Processor |
+ +------------------+ +------------------+ +------------------+
+----
+
+When a message arrives, the extension:
+
+1. Extracts the tenant identifier from the message (typically from metadata)
+2. Routes the message to the appropriate tenant segment
+3. Processes the message in complete isolation from other tenants
+
+== Key Concepts
+
+=== TenantDescriptor
+
+A `TenantDescriptor` identifies a tenant in the system.
+At its simplest, it contains a tenant identifier string, but it can also carry additional properties for tenant-specific configuration.
+
+[source,java]
+----
+// Simple tenant with just an ID
+TenantDescriptor mit = TenantDescriptor.tenantWithId("mit-university");
+
+// Tenant with additional properties
+TenantDescriptor stanford = new TenantDescriptor("stanford-university",
+ Map.of("region", "us-west", "tier", "premium"));
+----
+
+=== TenantProvider
+
+The `TenantProvider` manages the set of known tenants and notifies multi-tenant components when tenants are added or removed.
+It enables both static configurations (tenants defined at startup) and dynamic configurations (tenants added at runtime).
+
+[source,java]
+----
+// Static configuration: define tenants at startup
+SimpleTenantProvider provider = new SimpleTenantProvider(List.of(
+ TenantDescriptor.tenantWithId("mit-university"),
+ TenantDescriptor.tenantWithId("stanford-university"),
+ TenantDescriptor.tenantWithId("oxford-university")
+));
+
+// Dynamic configuration: add tenants at runtime
+provider.addTenant(TenantDescriptor.tenantWithId("cambridge-university"));
+provider.removeTenant(TenantDescriptor.tenantWithId("oxford-university"));
+----
+
+=== TargetTenantResolver
+
+The `TargetTenantResolver` determines which tenant a message belongs to.
+The most common implementation extracts the tenant identifier from message metadata.
+
+[source,java]
+----
+// Resolves tenant from message metadata using key "tenantId"
+TargetTenantResolver resolver = new MetadataBasedTenantResolver();
+
+// Custom metadata key
+TargetTenantResolver resolver = new MetadataBasedTenantResolver("universityId");
+----
+
+When sending commands or queries, include the tenant identifier in the metadata:
+
+[source,java]
+----
+// The tenant ID flows through the entire message chain
+commandGateway.send(
+ new EnrollStudentCommand(courseId, studentId),
+ Metadata.with("tenantId", "mit-university")
+);
+----
+
+=== Segment Factories
+
+Segment factories create tenant-specific infrastructure components.
+Each factory receives a `TenantDescriptor` and returns the appropriate component for that tenant:
+
+[cols="1,2"]
+|===
+|Factory |Purpose
+
+|`TenantCommandSegmentFactory`
+|Creates a command bus for each tenant
+
+|`TenantQuerySegmentFactory`
+|Creates a query bus for each tenant
+
+|`TenantEventSegmentFactory`
+|Creates an event store for each tenant
+
+|`TenantEventProcessorSegmentFactory`
+|Creates event processors for each tenant
+|===
+
+These factories give you complete control over tenant isolation strategies:
+
+* **Separate databases**: Each tenant's events stored in a different database
+* **Separate schemas**: Shared database with tenant-specific schemas
+* **Separate connections**: Tenant-specific connection pools or Axon Server contexts
+
+== When to Use This Extension
+
+The Multitenancy Extension is ideal when you need:
+
+Data isolation::
+Each tenant's commands, events, and queries must be completely separate.
+A university's course enrollments and student records should never be visible to another university.
+
+Tenant-specific scaling::
+Different tenants may require different processing capacities.
+A large university with thousands of courses needs more event processor threads than a small college.
+
+Dynamic tenant onboarding::
+New tenants can be added without application restarts.
+When a new university signs up, their infrastructure is provisioned automatically.
+
+Compliance requirements::
+Regulatory requirements demand strict data separation between customers.
+Each university's data must be provably isolated for audit purposes.
+
+Consider alternatives if:
+
+* You only need logical separation (filtering by tenant ID in queries)
+* All tenants share the same data model and can coexist in a single event store
+* You have a small, fixed number of tenants that rarely changes
+
+== Setup Guides
+
+Choose the setup guide that matches your infrastructure:
+
+xref:setup/getting-started.adoc[Getting Started]::
+Standard setup with any event store backend.
+Use this guide if you are using a custom event store implementation or want to understand the core concepts.
+
+xref:setup/axon-server.adoc[Axon Server Integration]::
+Setup for applications using Axon Server as the event store.
+Leverages Axon Server contexts for tenant isolation.
+
+xref:setup/spring-boot.adoc[Spring Boot Integration]::
+Setup for Spring Boot applications with any event store backend.
+Provides auto-configuration and simplified setup.
+
+xref:setup/spring-boot-axon-server.adoc[Spring Boot with Axon Server]::
+The most streamlined setup for Spring Boot applications using Axon Server.
+Combines Spring Boot auto-configuration with Axon Server context-based isolation.
diff --git a/docs/reference/modules/ROOT/pages/infrastructure.adoc b/docs/reference/modules/ROOT/pages/infrastructure.adoc
new file mode 100644
index 0000000..9f41156
--- /dev/null
+++ b/docs/reference/modules/ROOT/pages/infrastructure.adoc
@@ -0,0 +1,271 @@
+:navtitle: Infrastructure
+= Multi-Tenant Infrastructure
+
+The extension provides multi-tenant versions of Axon Framework's core infrastructure components. These components route messages to tenant-specific segments while maintaining the same API as their single-tenant counterparts.
+
+== Architecture Overview
+
+Each multi-tenant component follows the same pattern:
+
+1. Wraps the standard component as a decorator
+2. Uses `TargetTenantResolver` to determine the tenant
+3. Delegates to tenant-specific segments
+4. Creates segments on-demand via factory
+
+[source]
+----
+MultiTenantCommandBus
+ ├── tenantSegments: Map
+ ├── segmentFactory: TenantCommandSegmentFactory
+ └── targetTenantResolver: TargetTenantResolver
+----
+
+== MultiTenantCommandBus
+
+Routes commands to tenant-specific command buses.
+
+=== How It Differs from Standard CommandBus
+
+|===
+|Standard CommandBus |MultiTenantCommandBus
+
+|Single instance handles all commands
+|Routes to per-tenant command bus instances
+
+|Handlers registered once
+|Handlers registered per tenant segment
+
+|Direct dispatch
+|Resolves tenant, then dispatches to segment
+|===
+
+=== With Axon Server
+
+When using Axon Server, the `MultiTenantAxonServerCommandBusConnector` handles routing at the connector level. Commands are sent to the correct Axon Server context without needing per-tenant bus instances locally.
+
+=== Embedded Mode
+
+Without Axon Server, each tenant gets a `SimpleCommandBus`. The default factory creates these automatically:
+
+[source,java]
+----
+// Default behavior - creates SimpleCommandBus per tenant
+// You get this for free, no configuration needed
+----
+
+To customize:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.messaging.commandhandling.TenantCommandSegmentFactory;
+import org.axonframework.messaging.commandhandling.CommandBus;
+
+@Bean
+public TenantCommandSegmentFactory commandBusFactory() {
+ return tenant -> {
+ // Custom command bus configuration per tenant
+ return SimpleCommandBus.builder()
+ // ... custom configuration
+ .build();
+ };
+}
+----
+
+== MultiTenantQueryBus
+
+Routes queries to tenant-specific query buses.
+
+=== Query Handlers
+
+Query handlers must use **method parameter injection** for tenant-scoped data access:
+
+[source,java]
+----
+@QueryHandler
+public List handle(FindOrdersQuery query, OrderRepository orderRepository) {
+ // Repository is resolved per-invocation based on query's tenant metadata
+ return orderRepository.findAll();
+}
+
+// Or with EntityManager
+@QueryHandler
+public List handle(FindOrdersQuery query, EntityManager entityManager) {
+ return entityManager.createQuery("SELECT o FROM Order o", Order.class)
+ .getResultList();
+}
+----
+
+IMPORTANT: Do NOT use field-injected repositories or EntityManagers. Field injection creates a single instance shared across all tenants, causing data leakage. Always use method parameter injection for tenant-scoped data access.
+
+=== Subscription Queries
+
+Subscription queries work per-tenant. Updates emitted on one tenant's bus don't reach subscribers on other tenants:
+
+[source,java]
+----
+// Subscription on tenant-1
+queryGateway.subscriptionQuery(
+ new FindOrdersQuery()
+ .andMetaData(Map.of("tenantId", "tenant-1")),
+ ...
+);
+
+// Updates emitted on tenant-1's QueryUpdateEmitter
+// Only reach tenant-1 subscribers
+----
+
+== MultiTenantEventStore
+
+Routes event operations to tenant-specific event stores.
+
+=== How It Differs from Standard EventStore
+
+|===
+|Operation |Standard EventStore |MultiTenantEventStore
+
+|`publish(events)`
+|Stores in single event store
+|Resolves tenant from event metadata, stores in tenant's event store
+
+|`source(condition)`
+|Sources from single store
+|Resolves tenant from entity ID's context, sources from tenant's store
+
+|`open(condition)` for streaming
+|Opens single stream
+|Not supported directly - use `tenantSegments().get(tenant).open()`
+|===
+
+=== Event Sourcing
+
+When sourcing an entity, the event store uses the entity identifier's context to determine the tenant:
+
+[source,java]
+----
+// Command with tenant metadata
+CreateOrderCommand cmd = new CreateOrderCommand(orderId, productId)
+ .andMetaData(Map.of("tenantId", "tenant-1"));
+
+// Events sourced from tenant-1's event store
+// Events publish to tenant-1's event store
+----
+
+=== Accessing Tenant Segments
+
+For operations that need direct access to a tenant's event store (like event processors):
+
+[source,java]
+----
+MultiTenantEventStore multiTenantStore = ...;
+
+// Get all tenant segments
+Map segments = multiTenantStore.tenantSegments();
+
+// Access specific tenant's store
+EventStore tenant1Store = segments.get(TenantDescriptor.tenantWithId("tenant-1"));
+----
+
+== Segment Factories
+
+Segment factories create tenant-specific infrastructure:
+
+|===
+|Factory |Creates |Default Implementation
+
+|`TenantCommandSegmentFactory`
+|`CommandBus`
+|`SimpleCommandBus`
+
+|`TenantQuerySegmentFactory`
+|`QueryBus`
+|`SimpleQueryBus`
+
+|`TenantEventSegmentFactory`
+|`EventStore`
+|In-memory `StorageEngineBackedEventStore`
+|===
+
+=== Custom Segment Factory Example
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.eventsourcing.eventstore.TenantEventSegmentFactory;
+import org.axonframework.eventsourcing.eventstore.EventStore;
+import org.axonframework.eventsourcing.eventstore.jpa.JpaEventStorageEngine;
+
+@Bean
+public TenantEventSegmentFactory eventStoreFactory(EntityManagerFactory emf) {
+ return tenant -> {
+ // Create JPA-based event store per tenant
+ JpaEventStorageEngine engine = JpaEventStorageEngine.builder()
+ .entityManagerProvider(() -> createEntityManagerForTenant(tenant))
+ .build();
+
+ return new StorageEngineBackedEventStore(
+ engine,
+ new SimpleEventBus(),
+ new AnnotationBasedTagResolver()
+ );
+ };
+}
+----
+
+== Configuration
+
+=== Axon Framework
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.configuration.MultiTenancyConfigurer;
+
+MultiTenancyConfigurer.enhance(messagingConfigurer)
+ .registerTenantProvider(config -> tenantProvider)
+ .registerTargetTenantResolver(config -> resolver)
+ // Custom factories (optional - defaults provided)
+ .registerCommandBusSegmentFactory(config -> tenant -> createCommandBus(tenant))
+ .registerQueryBusSegmentFactory(config -> tenant -> createQueryBus(tenant))
+ .registerEventStoreSegmentFactory(config -> tenant -> createEventStore(tenant))
+ .build();
+----
+
+=== Spring Boot
+
+With Spring Boot, just define beans for custom factories:
+
+[source,java]
+----
+@Configuration
+public class MultiTenantInfrastructureConfig {
+
+ @Bean
+ public TenantEventSegmentFactory eventStoreFactory() {
+ return tenant -> {
+ // Custom event store per tenant
+ };
+ }
+}
+----
+
+The default factories are used if you don't provide custom ones.
+
+== Axon Server Connector Module
+
+When using the `multitenancy-axon-server-connector` module, you get:
+
+|===
+|Component |Behavior
+
+|`AxonServerTenantProvider`
+|Discovers tenants from Axon Server contexts
+
+|`MultiTenantAxonServerCommandBusConnector`
+|Routes commands to correct Axon Server context
+
+|`MultiTenantAxonServerQueryBusConnector`
+|Routes queries to correct Axon Server context
+
+|`AxonServerTenantEventSegmentFactory`
+|Creates `AxonServerEventStore` per tenant
+|===
+
+These are registered via SPI and override the embedded defaults. No configuration needed - just add the dependency.
diff --git a/docs/reference/modules/ROOT/pages/message-routing.adoc b/docs/reference/modules/ROOT/pages/message-routing.adoc
new file mode 100644
index 0000000..c5a75a5
--- /dev/null
+++ b/docs/reference/modules/ROOT/pages/message-routing.adoc
@@ -0,0 +1,219 @@
+:navtitle: Message Routing
+= Message Routing
+
+Every message in a multi-tenant application must be routed to the correct tenant. This page explains how tenant resolution works and how to configure it.
+
+== How Routing Works
+
+When you send a command, query, or event:
+
+1. The multi-tenant bus receives the message
+2. `TargetTenantResolver` extracts the tenant from the message
+3. The message is forwarded to that tenant's infrastructure
+4. The response (if any) returns through the same path
+
+[source]
+----
+Command with metadata {tenantId: "tenant-1"}
+ │
+ ▼
+MultiTenantCommandBus
+ │
+ ├── TargetTenantResolver extracts "tenant-1"
+ │
+ ▼
+tenant-1's CommandBus
+ │
+ ▼
+Command Handler
+----
+
+== TargetTenantResolver
+
+The `TargetTenantResolver` interface determines which tenant a message belongs to:
+
+[source,java]
+----
+public interface TargetTenantResolver {
+ TenantDescriptor resolveTenant(M message, List tenants);
+}
+----
+
+=== MetadataBasedTenantResolver (Default)
+
+The default implementation reads tenant ID from message metadata:
+
+[source,java]
+----
+// Configured with key "tenantId"
+MetadataBasedTenantResolver resolver = new MetadataBasedTenantResolver("tenantId");
+
+// Message with metadata: {"tenantId": "customer-acme"}
+// Resolves to: TenantDescriptor.tenantWithId("customer-acme")
+----
+
+=== Custom Resolver
+
+Implement your own resolver for different routing strategies:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.TargetTenantResolver;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.messaging.core.Message;
+
+public class PayloadBasedTenantResolver implements TargetTenantResolver {
+
+ @Override
+ public TenantDescriptor resolveTenant(Message message, List tenants) {
+ Object payload = message.payload();
+
+ if (payload instanceof TenantAwareCommand cmd) {
+ return TenantDescriptor.tenantWithId(cmd.getTenantId());
+ }
+
+ throw new NoTenantInMessageException("Cannot determine tenant from message");
+ }
+}
+----
+
+== Adding Tenant to Messages
+
+=== Entry Point
+
+At your application's entry point (REST controller, message listener, etc.), add the tenant ID to the first message:
+
+[source,java]
+----
+import org.axonframework.messaging.commandhandling.gateway.CommandGateway;
+import org.axonframework.messaging.core.Metadata;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class OrderController {
+
+ private final CommandGateway commandGateway;
+
+ @PostMapping("/orders")
+ public CompletableFuture createOrder(
+ @RequestHeader("X-Tenant-Id") String tenantId,
+ @RequestBody CreateOrderRequest request) {
+
+ return commandGateway.send(
+ new CreateOrderCommand(request.getProductId(), request.getQuantity()),
+ Metadata.with("tenantId", tenantId)
+ ).getResultMessage()
+ .thenApply(result -> result.payload().toString());
+ }
+}
+----
+
+=== Automatic Propagation
+
+Once a message has tenant metadata, the `TenantCorrelationProvider` automatically propagates it to all subsequent messages:
+
+[source]
+----
+CreateOrderCommand {tenantId: "tenant-1"}
+ │
+ ▼
+Command Handler
+ │
+ ├── OrderCreatedEvent {tenantId: "tenant-1"} ← auto-propagated
+ │
+ ▼
+Event Handler
+ │
+ ├── ConfirmInventoryCommand {tenantId: "tenant-1"} ← auto-propagated
+----
+
+You only need to add the tenant ID once at the entry point.
+
+== Configuration
+
+=== Axon Framework
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.MetadataBasedTenantResolver;
+import org.axonframework.extension.multitenancy.core.configuration.MultiTenancyConfigurer;
+import org.axonframework.messaging.core.correlation.CorrelationDataProviderRegistry;
+
+MultiTenancyConfigurer.enhance(messagingConfigurer)
+ .registerTenantProvider(config -> tenantProvider)
+ .registerTargetTenantResolver(config -> new MetadataBasedTenantResolver("tenantId"))
+ .build();
+
+// Also register the correlation provider for propagation
+messagingConfigurer.componentRegistry(registry ->
+ registry.getComponent(CorrelationDataProviderRegistry.class)
+ .registerProvider(config -> new TenantCorrelationProvider("tenantId"))
+);
+----
+
+=== Spring Boot
+
+The Spring Boot autoconfiguration provides both components automatically:
+
+[source,yaml]
+----
+# application.yml
+axon:
+ multi-tenancy:
+ tenant-key: tenantId # Used by both resolver and correlation provider
+----
+
+To customize:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.TargetTenantResolver;
+import org.axonframework.messaging.core.Message;
+import org.springframework.context.annotation.Bean;
+
+@Bean
+public TargetTenantResolver customTenantResolver() {
+ return new PayloadBasedTenantResolver();
+}
+----
+
+== Error Handling
+
+=== NoTenantInMessageException
+
+Thrown when the resolver cannot determine the tenant:
+
+[source,java]
+----
+// Message without tenantId metadata
+commandGateway.send(new CreateOrderCommand(...));
+// Throws: NoTenantInMessageException
+----
+
+Always ensure entry point messages have tenant metadata.
+
+=== NoSuchTenantException
+
+Thrown when the resolved tenant doesn't exist:
+
+[source,java]
+----
+// Tenant "unknown" is not registered
+commandGateway.send(
+ new CreateOrderCommand(...),
+ Metadata.with("tenantId", "unknown")
+);
+// Throws: NoSuchTenantException
+----
+
+== Best Practices
+
+1. **Single entry point for tenant**: Extract tenant ID from authentication token, HTTP header, or request path at your API layer.
+
+2. **Don't hardcode tenants in business logic**: Let the infrastructure handle routing.
+
+3. **Validate tenant early**: Check that the tenant exists before processing requests.
+
+4. **Use consistent key names**: Stick with one metadata key (like `"tenantId"`) throughout your application.
diff --git a/docs/reference/modules/ROOT/pages/multi-tenant-components.adoc b/docs/reference/modules/ROOT/pages/multi-tenant-components.adoc
deleted file mode 100644
index 40f07c2..0000000
--- a/docs/reference/modules/ROOT/pages/multi-tenant-components.adoc
+++ /dev/null
@@ -1,17 +0,0 @@
-:navtitle: Supported Multi-Tenant Components
-= Supported Multi-Tenant Components
-
-Currently, supported multi-tenants components are as follows:
-
-- `MultiTenantCommandBus`
-- `MultiTenantEventProcessor`
-- `MultiTenantEventStore`
-- `MultiTenantQueryBus`
-- `MultiTenantQueryUpdateEmitter`
-- `MultiTenantEventProcessorControlService`
-- `MultiTenantDataSourceManager`
-- `MultiTenantEventScheduler`
-
-Not supported components are:
-
-- Deadline Manager
\ No newline at end of file
diff --git a/docs/reference/modules/ROOT/pages/projections.adoc b/docs/reference/modules/ROOT/pages/projections.adoc
new file mode 100644
index 0000000..4277444
--- /dev/null
+++ b/docs/reference/modules/ROOT/pages/projections.adoc
@@ -0,0 +1,309 @@
+:navtitle: Projections
+= Multi-Tenant Projections
+
+Projections (read models) in multi-tenant applications typically require tenant-specific database access. The extension provides two approaches for JPA-based projections:
+
+1. **EntityManager injection** - Direct JPA access for the current tenant
+2. **Spring Data Repository injection** - Higher-level repository abstraction (Spring Boot only)
+
+== Architecture
+
+Each tenant can have:
+
+* Separate database
+* Separate schema in shared database
+* Separate table prefix (not recommended)
+
+The extension manages `EntityManagerFactory` instances per tenant:
+
+[source]
+----
+TenantEntityManagerRegistry
+ ├── tenant-1: EntityManagerFactory → database-tenant-1
+ ├── tenant-2: EntityManagerFactory → database-tenant-2
+ └── tenant-3: EntityManagerFactory → database-tenant-3
+----
+
+When an event handler runs, it receives an `EntityManager` bound to the event's tenant.
+
+== EntityManager Injection
+
+=== Configuration
+
+First, provide a factory that creates `EntityManagerFactory` per tenant:
+
+==== Axon Framework
+
+[source,java]
+----
+import jakarta.persistence.EntityManagerFactory;
+import jakarta.persistence.Persistence;
+import org.axonframework.extension.multitenancy.core.configuration.MultiTenancyConfigurer;
+import org.axonframework.extension.multitenancy.projection.jpa.TenantEntityManagerFactoryProvider;
+
+MultiTenancyConfigurer.enhance(messagingConfigurer)
+ .registerTenantEntityManagerFactoryProvider(config -> tenant -> {
+ Map props = new HashMap<>();
+ props.put("hibernate.connection.url",
+ "jdbc:postgresql://localhost:5432/" + tenant.tenantId());
+ props.put("hibernate.connection.username", "postgres");
+ props.put("hibernate.connection.password", "postgres");
+
+ return Persistence.createEntityManagerFactory("tenant-pu", props);
+ })
+ .build();
+----
+
+==== Spring Boot
+
+[source,java]
+----
+import jakarta.persistence.EntityManagerFactory;
+import org.axonframework.extension.multitenancy.projection.jpa.TenantEntityManagerFactoryProvider;
+import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
+import org.springframework.context.annotation.Bean;
+
+@Configuration
+public class JpaMultiTenancyConfig {
+
+ @Bean
+ public TenantEntityManagerFactoryProvider tenantEntityManagerFactoryProvider(
+ DataSource... tenantDataSources) { // Your tenant data sources
+
+ return tenant -> {
+ LocalContainerEntityManagerFactoryBean factory =
+ new LocalContainerEntityManagerFactoryBean();
+ factory.setDataSource(getDataSourceForTenant(tenant));
+ factory.setPackagesToScan("com.example.projections");
+ factory.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
+ factory.afterPropertiesSet();
+ return factory.getObject();
+ };
+ }
+}
+----
+
+=== Usage in Event Handlers
+
+[source,java]
+----
+import jakarta.persistence.EntityManager;
+import org.axonframework.eventsourcing.eventhandling.EventHandler;
+import org.axonframework.messaging.eventhandling.processing.ProcessingGroup;
+
+@ProcessingGroup("orders")
+public class OrderProjection {
+
+ @EventHandler
+ public void on(OrderCreatedEvent event, EntityManager entityManager) {
+ // EntityManager is automatically bound to the event's tenant
+ OrderEntity order = new OrderEntity(
+ event.getOrderId(),
+ event.getProductId(),
+ event.getQuantity()
+ );
+ entityManager.persist(order);
+ }
+
+ @EventHandler
+ public void on(OrderShippedEvent event, EntityManager entityManager) {
+ OrderEntity order = entityManager.find(OrderEntity.class, event.getOrderId());
+ if (order != null) {
+ order.setStatus("SHIPPED");
+ order.setShippedAt(event.getShippedAt());
+ }
+ }
+}
+----
+
+The `EntityManager` parameter is resolved by `TenantEntityManagerResolverFactory`, which:
+
+1. Extracts the tenant from the event's metadata
+2. Gets the `EntityManagerFactory` for that tenant
+3. Creates and provides an `EntityManager`
+
+== Spring Data Repository Injection
+
+For Spring Boot applications, you can inject tenant-scoped Spring Data repositories directly.
+
+=== Configuration
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.autoconfig.jpa.TenantRepositoryRegistry;
+import org.axonframework.extension.multitenancy.autoconfig.jpa.DefaultTenantRepositoryRegistry;
+import org.axonframework.extension.multitenancy.projection.jpa.TenantEntityManagerRegistry;
+import org.springframework.context.annotation.Bean;
+
+@Configuration
+public class RepositoryConfig {
+
+ @Bean
+ public TenantRepositoryRegistry tenantRepositoryRegistry(
+ TenantEntityManagerRegistry entityManagerRegistry) {
+
+ return new DefaultTenantRepositoryRegistry(entityManagerRegistry);
+ }
+}
+----
+
+=== Usage in Event Handlers
+
+[source,java]
+----
+import org.axonframework.eventsourcing.eventhandling.EventHandler;
+import org.axonframework.messaging.eventhandling.processing.ProcessingGroup;
+
+@ProcessingGroup("orders")
+public class OrderProjection {
+
+ @EventHandler
+ public void on(OrderCreatedEvent event, OrderRepository orderRepository) {
+ // Repository is automatically bound to the event's tenant
+ orderRepository.save(new OrderEntity(
+ event.getOrderId(),
+ event.getProductId(),
+ event.getQuantity()
+ ));
+ }
+
+ @EventHandler
+ public void on(OrderShippedEvent event, OrderRepository orderRepository) {
+ orderRepository.findById(event.getOrderId())
+ .ifPresent(order -> {
+ order.setStatus("SHIPPED");
+ order.setShippedAt(event.getShippedAt());
+ orderRepository.save(order);
+ });
+ }
+}
+----
+
+The `OrderRepository` is a standard Spring Data interface:
+
+[source,java]
+----
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface OrderRepository extends JpaRepository {
+ List findByStatus(String status);
+}
+----
+
+=== How It Works
+
+The `TenantScopedJpaRepositoryResolverFactory`:
+
+1. Detects Spring Data `Repository` interface parameters
+2. Extracts the tenant from the message context
+3. Creates a repository bound to that tenant's `EntityManager`
+
+This happens per-handler-invocation, so each event gets a repository for its tenant.
+
+== Database Schema Management
+
+The extension does not manage database schemas. You're responsible for:
+
+1. Creating tenant databases/schemas
+2. Running migrations (Flyway, Liquibase) per tenant
+3. Ensuring schema compatibility
+
+=== Schema-per-Tenant Strategy
+
+[source,java]
+----
+// Each tenant gets its own schema in a shared database
+TenantEntityManagerFactoryProvider provider = tenant -> {
+ Map props = new HashMap<>();
+ props.put("hibernate.connection.url", "jdbc:postgresql://localhost:5432/app");
+ props.put("hibernate.default_schema", tenant.tenantId());
+ return Persistence.createEntityManagerFactory("tenant-pu", props);
+};
+----
+
+=== Database-per-Tenant Strategy
+
+[source,java]
+----
+// Each tenant gets its own database
+TenantEntityManagerFactoryProvider provider = tenant -> {
+ Map props = new HashMap<>();
+ props.put("hibernate.connection.url",
+ "jdbc:postgresql://localhost:5432/" + tenant.tenantId());
+ return Persistence.createEntityManagerFactory("tenant-pu", props);
+};
+----
+
+== Query Handlers
+
+Query handlers can also receive tenant-scoped `EntityManager` or repositories:
+
+[source,java]
+----
+@QueryHandler
+public List handle(FindOrdersQuery query, EntityManager entityManager) {
+ return entityManager.createQuery(
+ "SELECT new OrderSummary(o.id, o.status) FROM OrderEntity o",
+ OrderSummary.class
+ ).getResultList();
+}
+
+// Or with repository
+@QueryHandler
+public List handle(FindOrdersQuery query, OrderRepository repository) {
+ return repository.findAll().stream()
+ .map(o -> new OrderSummary(o.getId(), o.getStatus()))
+ .toList();
+}
+----
+
+The tenant is resolved from the query message's metadata.
+
+== Critical: Method Parameter Injection
+
+In multi-tenant applications, you **must** use method parameter injection for all tenant-scoped resources:
+
+[source,java]
+----
+// WRONG - field injection is NOT tenant-aware
+@Component
+public class OrderProjection {
+ @Autowired
+ private OrderRepository orderRepository; // Single instance for ALL tenants!
+
+ @EventHandler
+ public void on(OrderCreatedEvent event) {
+ orderRepository.save(...); // Data goes to wrong tenant!
+ }
+}
+
+// CORRECT - method parameter injection
+@Component
+public class OrderProjection {
+
+ @EventHandler
+ public void on(OrderCreatedEvent event, OrderRepository orderRepository) {
+ orderRepository.save(...); // Correct tenant's repository
+ }
+}
+----
+
+This applies to:
+
+* `EntityManager`
+* Spring Data repositories (`JpaRepository`, etc.)
+* Any tenant-scoped resource
+
+The parameter resolver extracts the tenant from the message's metadata and provides the correctly-scoped instance.
+
+== Best Practices
+
+1. **Use database-per-tenant for strong isolation**: Separate databases provide the strongest tenant isolation.
+
+2. **Cache EntityManagerFactory instances**: The `TenantEntityManagerRegistry` caches factories - don't create them on every request.
+
+3. **Handle missing tenants gracefully**: If a tenant's database doesn't exist, your factory should fail clearly.
+
+4. **Run migrations before tenant activation**: Ensure database schema is ready before adding a tenant to the provider.
+
+5. **Consider connection pooling**: Each tenant's `EntityManagerFactory` may have its own connection pool. Monitor total connections.
diff --git a/docs/reference/modules/ROOT/pages/setup/axon-server.adoc b/docs/reference/modules/ROOT/pages/setup/axon-server.adoc
new file mode 100644
index 0000000..6dffa37
--- /dev/null
+++ b/docs/reference/modules/ROOT/pages/setup/axon-server.adoc
@@ -0,0 +1,577 @@
+:navtitle: Using Axon Server
+= Using Axon Server
+
+This guide covers setting up multitenancy with Axon Server using the standard Axon Framework configuration (non-Spring). When using Axon Server, each tenant maps to an Axon Server context, providing natural isolation and automatic tenant discovery.
+
+== Why Axon Server for Multitenancy
+
+Axon Server Enterprise Edition provides native multi-context support that maps perfectly to multitenancy:
+
+* **Context-per-tenant isolation** - Each tenant gets its own Axon Server context with separate event stores, command routing, and query handling
+* **Automatic tenant discovery** - The extension discovers available contexts from Axon Server and registers them as tenants
+* **Dynamic tenant management** - Create or delete contexts in Axon Server, and the application automatically adapts
+* **Distributed command and query routing** - Commands and queries route to the correct context automatically
+* **No local infrastructure management** - Axon Server handles event storage and message distribution
+
+== Prerequisites
+
+Before starting, ensure you have:
+
+* **Axon Server Enterprise Edition** running with multiple contexts configured
+* Each context represents a tenant (e.g., `university-oxford`, `university-cambridge`)
+* Network connectivity from your application to Axon Server
+
+TIP: Without an Axon Server license, only the `default` context is available. This is sufficient for development and testing single-tenant scenarios.
+
+== Dependencies
+
+Add both the multitenancy core and Axon Server connector modules:
+
+[source,xml]
+----
+
+
+
+ org.axonframework.extensions.multitenancy
+ axon-multitenancy
+ ${multitenancy.version}
+
+
+
+
+ org.axonframework.extensions.multitenancy
+ axon-multitenancy-axon-server-connector
+ ${multitenancy.version}
+
+
+
+
+ org.axonframework
+ axon-server-connector
+ ${axon.version}
+
+
+
+
+ org.axonframework
+ axon-eventsourcing
+ ${axon.version}
+
+
+----
+
+== Before: Single-Tenant Application
+
+Here is a typical single-tenant Axon Framework application connected to Axon Server:
+
+[source,java]
+----
+import org.axonframework.axonserver.connector.AxonServerConfiguration;
+import org.axonframework.common.configuration.AxonConfiguration;
+import org.axonframework.eventsourcing.configuration.EventSourcingConfigurer;
+
+public class SingleTenantUniversityApp {
+
+ public static void main(String[] args) {
+ // Configure Axon Server connection
+ AxonServerConfiguration axonServerConfig = new AxonServerConfiguration();
+ axonServerConfig.setServers("localhost:8124");
+
+ // Create the configurer
+ EventSourcingConfigurer configurer = EventSourcingConfigurer.create();
+
+ // Register Axon Server configuration
+ configurer.componentRegistry(cr -> cr.registerComponent(
+ AxonServerConfiguration.class, c -> axonServerConfig));
+
+ // Register domain components (entities, projections, etc.)
+ configurer = CourseConfiguration.configure(configurer);
+ configurer = StudentConfiguration.configure(configurer);
+
+ // Start the application
+ AxonConfiguration configuration = configurer.start();
+
+ // Use the command and query gateways
+ CommandGateway commandGateway = configuration.getComponent(CommandGateway.class);
+ QueryGateway queryGateway = configuration.getComponent(QueryGateway.class);
+
+ // All operations go to the single "default" context
+ commandGateway.sendAndWait(new CreateCourse(CourseId.random(), "Introduction to Multitenancy", 30));
+ }
+}
+----
+
+In this setup:
+
+* All commands route to a single Axon Server context
+* Events store in one context's event store
+* All queries execute against one context
+
+== After: Multi-Tenant Application
+
+Here is the same application transformed to support multiple tenants using Axon Server contexts:
+
+[source,java]
+----
+import org.axonframework.axonserver.connector.AxonServerConfiguration;
+import org.axonframework.common.configuration.AxonConfiguration;
+import org.axonframework.eventsourcing.configuration.EventSourcingConfigurer;
+import org.axonframework.extension.multitenancy.core.MetadataBasedTenantResolver;
+import org.axonframework.extension.multitenancy.core.TenantConnectPredicate;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.extension.multitenancy.core.configuration.MultiTenancyConfigurer;
+import org.axonframework.messaging.core.Metadata;
+
+public class MultiTenantUniversityApp {
+
+ public static void main(String[] args) {
+ // Configure Axon Server connection
+ AxonServerConfiguration axonServerConfig = new AxonServerConfiguration();
+ axonServerConfig.setServers("localhost:8124");
+
+ // Create the base configurer
+ EventSourcingConfigurer configurer = EventSourcingConfigurer.create();
+
+ // Register Axon Server configuration
+ configurer.componentRegistry(cr -> cr.registerComponent(
+ AxonServerConfiguration.class, c -> axonServerConfig));
+
+ // Register tenant filter - only connect to university contexts
+ configurer.componentRegistry(cr -> cr.registerComponent(
+ TenantConnectPredicate.class,
+ c -> tenant -> tenant.tenantId().startsWith("university-")));
+
+ // Enable multitenancy with tenant resolver
+ MultiTenancyConfigurer multiTenancyConfigurer = MultiTenancyConfigurer.enhance(configurer)
+ .registerTargetTenantResolver(config -> new MetadataBasedTenantResolver());
+
+ // Register domain components (same as before)
+ configurer = CourseConfiguration.configure(configurer);
+ configurer = StudentConfiguration.configure(configurer);
+
+ // Register tenant-scoped components (e.g., per-tenant repositories)
+ multiTenancyConfigurer.tenantComponent(
+ CourseRepository.class,
+ tenant -> new InMemoryCourseRepository()
+ );
+
+ // Ensure tenant ID propagates to events and subsequent messages
+ configurer.messaging(mc -> mc.registerCorrelationDataProvider(config -> message -> {
+ Map result = new HashMap<>();
+ if (message.metadata().containsKey("tenantId")) {
+ result.put("tenantId", message.metadata().get("tenantId"));
+ }
+ return result;
+ }));
+
+ // Start the application
+ AxonConfiguration configuration = configurer.start();
+
+ // Use the gateways with tenant metadata
+ CommandGateway commandGateway = configuration.getComponent(CommandGateway.class);
+ QueryGateway queryGateway = configuration.getComponent(QueryGateway.class);
+
+ // Create course for Oxford University
+ commandGateway.send(
+ new CreateCourse(CourseId.random(), "Medieval History", 25),
+ Metadata.with("tenantId", "university-oxford")
+ );
+
+ // Create course for Cambridge University
+ commandGateway.send(
+ new CreateCourse(CourseId.random(), "Quantum Physics", 20),
+ Metadata.with("tenantId", "university-cambridge")
+ );
+ }
+}
+----
+
+=== What Changed
+
+The transformation involves these key additions:
+
+==== 1. AxonServerConfiguration Setup
+
+[source,java]
+----
+AxonServerConfiguration axonServerConfig = new AxonServerConfiguration();
+axonServerConfig.setServers("localhost:8124");
+
+configurer.componentRegistry(cr -> cr.registerComponent(
+ AxonServerConfiguration.class, c -> axonServerConfig));
+----
+
+The `AxonServerConfiguration` tells the extension how to connect to Axon Server. The multitenancy connector uses this to discover available contexts and create per-tenant connections.
+
+==== 2. TenantConnectPredicate for Filtering Contexts
+
+[source,java]
+----
+configurer.componentRegistry(cr -> cr.registerComponent(
+ TenantConnectPredicate.class,
+ c -> tenant -> tenant.tenantId().startsWith("university-")));
+----
+
+The `TenantConnectPredicate` filters which Axon Server contexts become tenants. This is essential because:
+
+* Axon Server may have system contexts (like `_admin`) you do not want as tenants
+* You may want to connect only to specific contexts based on naming conventions
+* In production, you might filter based on context metadata
+
+Common patterns:
+
+[source,java]
+----
+// Accept all non-system contexts
+tenant -> !tenant.tenantId().startsWith("_")
+
+// Accept only contexts with specific prefix
+tenant -> tenant.tenantId().startsWith("tenant-")
+
+// Accept contexts based on metadata
+tenant -> "production".equals(tenant.properties().get("environment"))
+
+// Accept specific named contexts
+Set allowed = Set.of("university-oxford", "university-cambridge");
+tenant -> allowed.contains(tenant.tenantId())
+----
+
+==== 3. Automatic Tenant Discovery
+
+When using the `axon-multitenancy-axon-server-connector` module, the `AxonServerTenantProvider` is automatically registered via SPI. It:
+
+* Queries Axon Server's Admin API to discover available contexts
+* Filters contexts using your `TenantConnectPredicate`
+* Registers each matching context as a tenant
+* Subscribes to context updates for dynamic tenant management
+
+You do not need to manually register tenants - they are discovered automatically.
+
+==== 4. Commands and Queries Route to Correct Context
+
+[source,java]
+----
+// Command routes to university-oxford context in Axon Server
+commandGateway.send(
+ new CreateCourse(CourseId.random(), "Medieval History", 25),
+ Metadata.with("tenantId", "university-oxford")
+);
+----
+
+The `MultiTenantAxonServerCommandBusConnector` (registered automatically) uses the `TargetTenantResolver` to determine which tenant a command belongs to, then routes it to that tenant's Axon Server context.
+
+=== Complete Working Example
+
+Here is a complete example with domain classes:
+
+[source,java]
+----
+// Command
+public record CreateCourse(
+ @TargetEntityId CourseId courseId,
+ String name,
+ int capacity
+) {}
+
+// Event
+@Event(name = "CourseCreated")
+public record CourseCreated(
+ @EventTag(key = "courseId") CourseId courseId,
+ String name,
+ int capacity
+) {}
+
+// State entity - only reconstructs state from events
+@EventSourcedEntity(tagKey = "courseId")
+public class CourseState {
+
+ private CourseId id;
+ private String name;
+ private int capacity;
+ private boolean created = false;
+
+ @EntityCreator
+ public CourseState() {}
+
+ @EventSourcingHandler
+ public void on(CourseCreated event) {
+ this.id = event.courseId();
+ this.name = event.name();
+ this.capacity = event.capacity();
+ this.created = true;
+ }
+
+ public boolean isCreated() { return created; }
+}
+
+// Command handler - separate from state entity
+public class CreateCourseHandler {
+
+ @CommandHandler
+ public void handle(CreateCourse command, CourseState state, EventAppender appender) {
+ appender.append(new CourseCreated(
+ command.courseId(),
+ command.name(),
+ command.capacity()
+ ));
+ }
+}
+----
+
+[source,java]
+----
+// Query
+public record FindAllCourses() {
+ public record Result(List courses) {}
+}
+
+// Query Handler - uses parameter injection for tenant-scoped repository
+public class FindAllCoursesQueryHandler {
+
+ @QueryHandler
+ public FindAllCourses.Result handle(FindAllCourses query, CourseRepository repository) {
+ return new FindAllCourses.Result(repository.findAll());
+ }
+}
+----
+
+[source,java]
+----
+// Configuration module for the Course entity
+public class CourseConfiguration {
+
+ public static EventSourcingConfigurer configure(EventSourcingConfigurer configurer) {
+ var entityModule = EventSourcedEntityModule
+ .autodetected(CourseId.class, Course.class);
+
+ return configurer.registerEntity(entityModule);
+ }
+}
+
+// Configuration module for the Course projection
+public class CourseStatsConfiguration {
+
+ public static EventSourcingConfigurer configure(EventSourcingConfigurer configurer) {
+ var processor = MultiTenantPooledStreamingEventProcessorModule
+ .create("CourseStatsProjection")
+ .eventHandlingComponents(c -> c
+ .autodetected(cfg -> new CourseStatsProjector())
+ )
+ .notCustomized();
+
+ QueryHandlingModule queryModule = QueryHandlingModule.named("CourseStats-Handler")
+ .queryHandlers()
+ .annotatedQueryHandlingComponent(cfg -> new FindAllCoursesQueryHandler())
+ .build();
+
+ return configurer
+ .componentRegistry(cr -> cr.registerModule(processor.build()))
+ .registerQueryHandlingModule(queryModule);
+ }
+}
+----
+
+[source,java]
+----
+// Main application
+public class MultiTenantUniversityApp {
+
+ public static void main(String[] args) {
+ AxonServerConfiguration axonServerConfig = new AxonServerConfiguration();
+ axonServerConfig.setServers("localhost:8124");
+
+ EventSourcingConfigurer configurer = EventSourcingConfigurer.create();
+
+ configurer.componentRegistry(cr -> cr.registerComponent(
+ AxonServerConfiguration.class, c -> axonServerConfig));
+
+ configurer.componentRegistry(cr -> cr.registerComponent(
+ TenantConnectPredicate.class,
+ c -> tenant -> tenant.tenantId().startsWith("university-")));
+
+ MultiTenancyConfigurer multiTenancyConfigurer = MultiTenancyConfigurer.enhance(configurer)
+ .registerTargetTenantResolver(config -> new MetadataBasedTenantResolver());
+
+ configurer = CourseConfiguration.configure(configurer);
+ configurer = CourseStatsConfiguration.configure(configurer);
+
+ multiTenancyConfigurer.tenantComponent(
+ CourseRepository.class,
+ tenant -> new InMemoryCourseRepository()
+ );
+
+ configurer.messaging(mc -> mc.registerCorrelationDataProvider(config -> message -> {
+ Map result = new HashMap<>();
+ if (message.metadata().containsKey("tenantId")) {
+ result.put("tenantId", message.metadata().get("tenantId"));
+ }
+ return result;
+ }));
+
+ AxonConfiguration configuration = configurer.start();
+
+ // Wait for tenant discovery
+ TenantProvider provider = configuration.getComponent(TenantProvider.class);
+ // In production, use proper synchronization
+
+ CommandGateway commandGateway = configuration.getComponent(CommandGateway.class);
+ QueryGateway queryGateway = configuration.getComponent(QueryGateway.class);
+
+ // Operations now route to correct Axon Server context based on tenant metadata
+ }
+}
+----
+
+== Key Differences from Non-Axon-Server Setup
+
+When using Axon Server, several things work differently compared to embedded mode:
+
+|===
+|Aspect |Embedded Mode |With Axon Server
+
+|Tenant Discovery
+|Manual registration via `SimpleTenantProvider`
+|Automatic discovery from Axon Server contexts
+
+|Event Storage
+|In-memory or custom `StorageEngine` per tenant
+|Axon Server context per tenant (events stored in Axon Server)
+
+|Command Routing
+|Local `SimpleCommandBus` per tenant
+|Distributed via `MultiTenantAxonServerCommandBusConnector`
+
+|Query Routing
+|Local `SimpleQueryBus` per tenant
+|Distributed via `MultiTenantAxonServerQueryBusConnector`
+
+|Dynamic Tenants
+|Call `tenantProvider.addTenant()`
+|Create new context in Axon Server (auto-discovered)
+
+|Infrastructure Management
+|Application manages all infrastructure
+|Axon Server manages event stores and message routing
+|===
+
+=== Automatically Registered Components
+
+When `axon-multitenancy-axon-server-connector` is on the classpath, these components are registered via SPI:
+
+|===
+|Component |Role
+
+|`AxonServerTenantProvider`
+|Discovers tenants from Axon Server contexts
+
+|`MultiTenantAxonServerCommandBusConnector`
+|Routes commands to tenant-specific Axon Server contexts
+
+|`MultiTenantAxonServerQueryBusConnector`
+|Routes queries to tenant-specific Axon Server contexts
+
+|`AxonServerTenantEventSegmentFactory`
+|Creates `EventStore` instances backed by Axon Server per tenant
+|===
+
+These override the embedded defaults without any additional configuration.
+
+== Production Considerations
+
+=== Axon Server Cluster Configuration
+
+For production deployments:
+
+[source,java]
+----
+AxonServerConfiguration axonServerConfig = new AxonServerConfiguration();
+axonServerConfig.setServers("axonserver-1:8124,axonserver-2:8124,axonserver-3:8124");
+axonServerConfig.setClientId("university-service-" + instanceId);
+axonServerConfig.setComponentName("university-service");
+----
+
+=== Error Handling for Missing Tenants
+
+Commands sent to non-existent tenants will fail with `NoSuchTenantException`:
+
+[source,java]
+----
+try {
+ // Note: .getResultMessage().join() blocks - necessary here to catch the exception
+ commandGateway.send(
+ new CreateCourse(courseId, "Test Course", 10),
+ Metadata.with("tenantId", "non-existent-tenant")
+ ).getResultMessage().join();
+} catch (CompletionException e) {
+ if (e.getCause() instanceof NoSuchTenantException) {
+ // Handle missing tenant
+ log.error("Tenant not found: {}", e.getCause().getMessage());
+ }
+}
+----
+
+=== Waiting for Tenant Registration
+
+After startup, tenants are discovered asynchronously. In production, wait for tenants to be available:
+
+[source,java]
+----
+TenantProvider provider = configuration.getComponent(TenantProvider.class);
+TenantDescriptor expectedTenant = TenantDescriptor.tenantWithId("university-oxford");
+
+// Wait for tenant to be registered
+Awaitility.await()
+ .atMost(Duration.ofSeconds(30))
+ .until(() -> provider.getTenants().contains(expectedTenant));
+----
+
+=== Health Checks
+
+Monitor tenant connectivity:
+
+[source,java]
+----
+TenantProvider provider = configuration.getComponent(TenantProvider.class);
+List tenants = provider.getTenants();
+
+for (TenantDescriptor tenant : tenants) {
+ // Verify connectivity per tenant
+ log.info("Connected to tenant: {}", tenant.tenantId());
+}
+----
+
+=== Context Naming Conventions
+
+Establish clear naming conventions for contexts:
+
+* `tenant-` - Customer-specific contexts
+* `region-` - Regional contexts
+* `env-` - Environment-specific contexts (dev, staging, prod)
+
+Then filter with `TenantConnectPredicate`:
+
+[source,java]
+----
+// Only connect to production tenant contexts
+TenantConnectPredicate predicate = tenant ->
+ tenant.tenantId().startsWith("tenant-") &&
+ !"_admin".equals(tenant.tenantId());
+----
+
+=== Graceful Shutdown
+
+The extension handles shutdown automatically, but ensure proper cleanup:
+
+[source,java]
+----
+Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ log.info("Shutting down multi-tenant application...");
+ configuration.shutdown();
+}));
+----
+
+The shutdown process:
+
+1. Disconnects from each tenant's Axon Server context
+2. Stops event processors for each tenant
+3. Cleans up tenant-specific resources
diff --git a/docs/reference/modules/ROOT/pages/setup/getting-started.adoc b/docs/reference/modules/ROOT/pages/setup/getting-started.adoc
new file mode 100644
index 0000000..90e7786
--- /dev/null
+++ b/docs/reference/modules/ROOT/pages/setup/getting-started.adoc
@@ -0,0 +1,443 @@
+:navtitle: Getting Started
+= Getting Started
+
+This guide walks you through adding multi-tenancy to an existing Axon Framework application. We will transform a simple single-tenant Course management application into a multi-tenant system where each tenant has isolated data.
+
+== Prerequisites
+
+Before you begin, ensure you have:
+
+* **Axon Framework 5.0 or later** - This extension requires Axon Framework 5
+* **Java 17 or later** - Required by Axon Framework 5
+
+== Add the Dependency
+
+Add the Multitenancy Extension to your Maven project:
+
+[source,xml]
+----
+
+ org.axonframework.extensions.multitenancy
+ axon-multitenancy
+ ${multitenancy.version}
+
+----
+
+NOTE: This guide covers the standard (non-Spring) setup with an embedded in-memory event store. For production deployments, see xref:setup/axon-server.adoc[Setting Up with Axon Server].
+
+== Before: Single-Tenant Application
+
+Let's start with a simple Course management application. This is a standard Axon Framework application without multi-tenancy.
+
+=== Domain Model
+
+First, the command and event:
+
+[source,java]
+----
+// Command to create a course
+public record CreateCourse(
+ @TargetEntityId
+ CourseId courseId,
+ String name,
+ int capacity
+) {}
+
+// Event published when a course is created
+@Event(name = "CourseCreated")
+public record CourseCreated(
+ @EventTag(key = "courseId")
+ CourseId courseId,
+ String name,
+ int capacity
+) {}
+----
+
+The `CourseId` value object:
+
+[source,java]
+----
+public record CourseId(String raw) {
+
+ public static CourseId random() {
+ return new CourseId(UUID.randomUUID().toString());
+ }
+
+ @Override
+ public String toString() {
+ return raw;
+ }
+}
+----
+
+=== State Entity
+
+The state entity reconstructs state from events. It only contains `@EventSourcingHandler` methods:
+
+[source,java]
+----
+@EventSourcedEntity(tagKey = "courseId")
+public class CourseState {
+
+ private CourseId id;
+ private boolean created = false;
+ private int capacity;
+
+ @EntityCreator
+ public CourseState() {}
+
+ @EventSourcingHandler
+ public void on(CourseCreated event) {
+ this.id = event.courseId();
+ this.created = true;
+ this.capacity = event.capacity();
+ }
+
+ public boolean isCreated() { return created; }
+ public int getCapacity() { return capacity; }
+}
+----
+
+=== Command Handler
+
+Command handlers are separate from state entities. They receive the state as a parameter for validation:
+
+[source,java]
+----
+public class CreateCourseHandler {
+
+ @CommandHandler
+ public void handle(CreateCourse command, CourseState state, EventAppender appender) {
+ // Validate against current state if needed
+ // For creation, state will be empty/null for new entities
+
+ appender.append(
+ new CourseCreated(
+ command.courseId(),
+ command.name(),
+ command.capacity()
+ )
+ );
+ }
+}
+----
+
+=== Projection
+
+A simple projection to track course statistics:
+
+[source,java]
+----
+// Read model
+public record CourseStats(CourseId courseId, String name, int capacity) {}
+
+// Repository interface
+public interface CourseStatsRepository {
+ CourseStats save(CourseStats stats);
+ List findAll();
+}
+
+// In-memory implementation
+public class InMemoryCourseStatsRepository implements CourseStatsRepository {
+ private final ConcurrentHashMap stats = new ConcurrentHashMap<>();
+
+ @Override
+ public CourseStats save(CourseStats stats) {
+ this.stats.put(stats.courseId(), stats);
+ return stats;
+ }
+
+ @Override
+ public List findAll() {
+ return stats.values().stream().toList();
+ }
+}
+
+// Event handler
+public class CourseStatsProjector {
+
+ @EventHandler
+ void handle(CourseCreated event, CourseStatsRepository repository) {
+ repository.save(new CourseStats(
+ event.courseId(),
+ event.name(),
+ event.capacity()
+ ));
+ }
+}
+
+// Query and handler
+public record FindAllCourses() {
+ public record Result(List courses) {}
+}
+
+public class FindAllCoursesQueryHandler {
+
+ @QueryHandler
+ FindAllCourses.Result handle(FindAllCourses query, CourseStatsRepository repository) {
+ return new FindAllCourses.Result(repository.findAll());
+ }
+}
+----
+
+=== Single-Tenant Configuration
+
+The standard configuration for a single-tenant application:
+
+[source,java]
+----
+public class SingleTenantApplication {
+
+ public static void main(String[] args) {
+ // Create base configurer
+ EventSourcingConfigurer configurer = EventSourcingConfigurer.create();
+
+ // Register the entity
+ var courseEntity = EventSourcedEntityModule
+ .autodetected(CourseId.class, CourseCreation.class);
+ configurer.registerEntity(courseEntity);
+
+ // Register event processor with projector
+ var processor = PooledStreamingEventProcessorModule
+ .create("CourseStats")
+ .eventHandlingComponents(c -> c
+ .autodetected(cfg -> new CourseStatsProjector())
+ )
+ .notCustomized();
+ configurer.componentRegistry(cr -> cr.registerModule(processor.build()));
+
+ // Register query handler
+ QueryHandlingModule queryModule = QueryHandlingModule.named("CourseQueries")
+ .queryHandlers()
+ .annotatedQueryHandlingComponent(cfg -> new FindAllCoursesQueryHandler())
+ .build();
+ configurer.registerQueryHandlingModule(queryModule);
+
+ // Register repository as a component
+ InMemoryCourseStatsRepository repository = new InMemoryCourseStatsRepository();
+ configurer.componentRegistry(cr ->
+ cr.register(CourseStatsRepository.class, c -> repository)
+ );
+
+ // Start the application
+ AxonConfiguration configuration = configurer.start();
+ CommandGateway commandGateway = configuration.getComponent(CommandGateway.class);
+
+ // Send a command
+ commandGateway.sendAndWait(
+ new CreateCourse(CourseId.random(), "Introduction to Axon", 30)
+ );
+ }
+}
+----
+
+== After: Multi-Tenant Application
+
+Now let's transform this into a multi-tenant application. The key changes are:
+
+1. Enhance the configurer with `MultiTenancyConfigurer`
+2. Register a `TenantProvider` to define available tenants
+3. Register a `TargetTenantResolver` to determine which tenant a message belongs to
+4. Register a correlation data provider to propagate tenant ID from commands to events
+5. Register tenant-scoped components (like the repository)
+6. Send commands with tenant metadata
+
+=== Multi-Tenant Configuration
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.MetadataBasedTenantResolver;
+import org.axonframework.extension.multitenancy.core.SimpleTenantProvider;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.extension.multitenancy.core.configuration.MultiTenancyConfigurer;
+import org.axonframework.extension.multitenancy.messaging.eventhandling.processing.MultiTenantPooledStreamingEventProcessorModule;
+
+public class MultiTenantApplication {
+
+ // Define tenants
+ private static final TenantDescriptor TENANT_A = TenantDescriptor.tenantWithId("tenant-a");
+ private static final TenantDescriptor TENANT_B = TenantDescriptor.tenantWithId("tenant-b");
+
+ public static void main(String[] args) {
+ // Create tenant provider with initial tenants
+ SimpleTenantProvider tenantProvider = new SimpleTenantProvider(
+ List.of(TENANT_A, TENANT_B)
+ );
+
+ // Create base configurer
+ EventSourcingConfigurer configurer = EventSourcingConfigurer.create();
+
+ // 1. Enhance with multi-tenancy
+ MultiTenancyConfigurer multiTenancyConfigurer = MultiTenancyConfigurer.enhance(configurer)
+ .registerTenantProvider(config -> tenantProvider)
+ .registerTargetTenantResolver(config -> new MetadataBasedTenantResolver());
+
+ // 2. Register entity (same as before)
+ var courseEntity = EventSourcedEntityModule
+ .autodetected(CourseId.class, CourseCreation.class);
+ configurer.registerEntity(courseEntity);
+
+ // 3. Use MultiTenantPooledStreamingEventProcessorModule for projections
+ // Register tenant-scoped repository with the processor - each tenant gets its own instance
+ var processor = MultiTenantPooledStreamingEventProcessorModule
+ .create("CourseStats")
+ .eventHandlingComponents(c -> c
+ .autodetected(cfg -> new CourseStatsProjector())
+ )
+ .tenantComponent(CourseStatsRepository.class, tenant -> new InMemoryCourseStatsRepository())
+ .notCustomized();
+ configurer.componentRegistry(cr -> cr.registerModule(processor.build()));
+
+ // 4. Register query handler (same as before)
+ QueryHandlingModule queryModule = QueryHandlingModule.named("CourseQueries")
+ .queryHandlers()
+ .annotatedQueryHandlingComponent(cfg -> new FindAllCoursesQueryHandler())
+ .build();
+ configurer.registerQueryHandlingModule(queryModule);
+
+ // 5. Register correlation data provider for tenant propagation
+ // This ensures tenantId flows from commands to events
+ configurer.messaging(mc -> mc.registerCorrelationDataProvider(config -> message -> {
+ Map result = new HashMap<>();
+ if (message.metadata().containsKey("tenantId")) {
+ result.put("tenantId", message.metadata().get("tenantId"));
+ }
+ return result;
+ }));
+
+ // Start the application
+ AxonConfiguration configuration = configurer.start();
+ CommandGateway commandGateway = configuration.getComponent(CommandGateway.class);
+
+ // Send command WITH tenant metadata
+ // Note: .getResultMessage().join() blocks - necessary here to prevent main() from exiting
+ commandGateway.send(
+ new CreateCourse(CourseId.random(), "Introduction to Multi-Tenancy", 30),
+ Metadata.with("tenantId", "tenant-a") // <1>
+ ).getResultMessage().join();
+ }
+}
+----
+<1> The `tenantId` metadata is required. Without it, the command will fail with `NoSuchTenantException`.
+
+=== What Changed
+
+[cols="1,1"]
+|===
+|Before (Single-Tenant) |After (Multi-Tenant)
+
+|`EventSourcingConfigurer.create()`
+|`MultiTenancyConfigurer.enhance(configurer)`
+
+|Single repository instance
+|`tenantComponent()` creates one per tenant
+
+|`PooledStreamingEventProcessorModule`
+|`MultiTenantPooledStreamingEventProcessorModule`
+
+|No tenant metadata needed
+|`Metadata.with("tenantId", "tenant-a")`
+|===
+
+=== Sending Commands with Tenant Metadata
+
+When sending commands, include the tenant ID in metadata:
+
+[source,java]
+----
+// Using CommandGateway with metadata
+// Note: .getResultMessage().join() blocks until completion - only needed in main() or tests.
+// In REST controllers, return CompletableFuture instead.
+commandGateway.send(
+ new CreateCourse(courseId, "Course Name", 25),
+ Metadata.with("tenantId", "tenant-a")
+).getResultMessage().join();
+----
+
+=== Querying with Tenant Metadata
+
+Queries also require tenant metadata:
+
+[source,java]
+----
+QueryMessage query = new GenericQueryMessage(
+ new MessageType(FindAllCourses.class),
+ new FindAllCourses()
+).andMetadata(Metadata.with("tenantId", "tenant-a"));
+
+List courses = queryGateway.query(query, FindAllCourses.Result.class)
+ .thenApply(FindAllCourses.Result::courses)
+ .join();
+----
+
+== Key Points
+
+=== Tenant Isolation
+
+Each tenant has completely isolated infrastructure:
+
+* **Separate Event Stores** - Events from tenant A are never visible to tenant B
+* **Separate Command Buses** - Commands are routed to the correct tenant's command handlers
+* **Separate Query Buses** - Queries only return data from the specified tenant
+* **Separate Event Processors** - Each tenant has its own event processor instances
+
+=== Same Entity ID in Different Tenants
+
+The same entity ID can exist in multiple tenants without conflict:
+
+[source,java]
+----
+CourseId sharedId = CourseId.random();
+
+// Create course with this ID in tenant A
+commandGateway.send(
+ new CreateCourse(sharedId, "Course in Tenant A", 30),
+ Metadata.with("tenantId", "tenant-a")
+);
+
+// Create course with SAME ID in tenant B - no conflict!
+commandGateway.send(
+ new CreateCourse(sharedId, "Course in Tenant B", 25),
+ Metadata.with("tenantId", "tenant-b")
+);
+----
+
+=== Dynamic Tenant Registration
+
+You can add tenants at runtime:
+
+[source,java]
+----
+SimpleTenantProvider tenantProvider = new SimpleTenantProvider(List.of(TENANT_A));
+
+// Later, add a new tenant dynamically
+TenantDescriptor newTenant = TenantDescriptor.tenantWithId("tenant-c");
+tenantProvider.addTenant(newTenant);
+
+// Now commands to tenant-c will work
+----
+
+=== Missing Tenant Metadata
+
+If you send a command without tenant metadata, you will get a `NoSuchTenantException`:
+
+[source,java]
+----
+// This will throw NoSuchTenantException
+commandGateway.sendAndWait(new CreateCourse(courseId, "No Tenant", 10));
+----
+
+Always include tenant information in your message metadata.
+
+== Next Steps
+
+This guide demonstrated multi-tenancy with an embedded in-memory event store, suitable for development and testing. For production deployments:
+
+* xref:setup/axon-server.adoc[Setting Up with Axon Server] - Recommended for production. Provides persistent storage, clustering, and automatic tenant discovery from Axon Server contexts.
+
+For more details on specific features:
+
+* xref:tenant-management.adoc[Tenant Management] - Dynamic tenant registration and lifecycle
+* xref:message-routing.adoc[Message Routing] - How messages are routed to tenants
+* xref:projections.adoc[Projections] - Multi-tenant event handling and projections
+* xref:infrastructure.adoc[Infrastructure] - Multi-tenant infrastructure components
diff --git a/docs/reference/modules/ROOT/pages/setup/spring-boot-axon-server.adoc b/docs/reference/modules/ROOT/pages/setup/spring-boot-axon-server.adoc
new file mode 100644
index 0000000..12d674f
--- /dev/null
+++ b/docs/reference/modules/ROOT/pages/setup/spring-boot-axon-server.adoc
@@ -0,0 +1,509 @@
+:navtitle: Spring Boot with Axon Server
+= Spring Boot with Axon Server
+
+This guide covers the recommended production setup: Spring Boot with Axon Server Enterprise Edition. This combination provides the simplest path to multi-tenancy with automatic tenant discovery and zero infrastructure code changes.
+
+== Introduction
+
+Spring Boot with Axon Server is the "golden path" for Axon Framework multi-tenancy. This setup offers:
+
+* **Automatic tenant discovery** - Tenants map directly to Axon Server contexts
+* **Zero infrastructure code** - No custom factories or wiring required
+* **Production-ready isolation** - Each tenant's events, commands, and queries are completely isolated
+* **Dynamic tenant management** - Add or remove tenants at runtime through Axon Server
+
+With just a dependency and minimal configuration, your existing single-tenant application becomes multi-tenant.
+
+== Prerequisites
+
+Before starting, ensure you have:
+
+* **Spring Boot 3.x** - The extension requires Spring Boot 3.0 or later
+* **Axon Server Enterprise Edition** - Multi-context support requires EE (or Developer Edition for testing)
+* **Axon Server contexts created** - One context per tenant (e.g., `university-stanford`, `university-mit`)
+* **Java 17+** - Required by Spring Boot 3.x
+
+TIP: For local development, use Axon Server Developer Edition which includes multi-context support. In the free Standard Edition, only the `default` context is available.
+
+== Dependencies
+
+Add the Spring Boot starter to your `pom.xml`:
+
+[source,xml]
+----
+
+ org.axonframework.extension.multitenancy
+ multitenancy-spring-boot-starter
+ ${axon-multitenancy.version}
+
+----
+
+This starter transitively includes:
+
+* `multitenancy` - Core multi-tenancy abstractions
+* `multitenancy-axon-server-connector` - Axon Server integration
+* `multitenancy-spring` - Spring integration
+* `multitenancy-spring-boot-autoconfigure` - Auto-configuration
+
+== Before: Single-Tenant Application
+
+Consider a typical university course management application:
+
+=== Domain Model
+
+[source,java]
+----
+// Command
+public record CreateCourse(
+ @TargetEntityId CourseId courseId,
+ String name,
+ int capacity
+) {}
+
+// Event
+@Event(name = "CourseCreated")
+public record CourseCreated(
+ @EventTag(key = "courseId") CourseId courseId,
+ String name,
+ int capacity
+) {}
+
+// State entity - only reconstructs state from events
+@EventSourced(tagKey = "courseId", idType = CourseId.class)
+public class CourseState {
+
+ private boolean created = false;
+
+ @EventSourcingHandler
+ public void on(CourseCreated event) {
+ this.created = true;
+ }
+
+ public boolean isCreated() { return created; }
+}
+
+// Command handler - separate from state entity
+@Component
+public class CreateCourseHandler {
+
+ @CommandHandler
+ public void handle(CreateCourse command, CourseState state, EventAppender appender) {
+ appender.append(new CourseCreated(
+ command.courseId(),
+ command.name(),
+ command.capacity()
+ ));
+ }
+}
+----
+
+=== Projection
+
+[source,java]
+----
+@Entity
+@Table(name = "course_stats")
+public class CourseStatsReadModel {
+ @Id
+ private String courseId;
+ private String name;
+ private int capacity;
+ // getters, setters...
+}
+
+public interface CourseStatsRepository
+ extends JpaRepository {
+}
+
+@Component
+public class CourseStatsProjector {
+
+ @Autowired
+ private CourseStatsRepository repository; // Single-tenant: field injection works
+
+ @EventHandler
+ public void on(CourseCreated event) {
+ repository.save(new CourseStatsReadModel(
+ event.courseId().raw(),
+ event.name(),
+ event.capacity()
+ ));
+ }
+}
+----
+
+=== Configuration
+
+[source,yaml]
+----
+# application.yml - Single tenant
+axon:
+ axonserver:
+ servers: localhost:8124
+----
+
+This application serves one university. All courses, students, and data exist in a single Axon Server context.
+
+== After: Multi-Tenant Transformation
+
+Transform this into a multi-tenant application serving multiple universities:
+
+=== Step 1: Add the Dependency
+
+[source,xml]
+----
+
+ org.axonframework.extension.multitenancy
+ multitenancy-spring-boot-starter
+ ${axon-multitenancy.version}
+
+----
+
+=== Step 2: Configure Tenants
+
+[source,yaml]
+----
+# application.yml - Multi-tenant
+axon:
+ axonserver:
+ servers: localhost:8124
+ multi-tenancy:
+ tenant-key: tenantId
+ axon-server:
+ contexts: stanford,mit,berkeley
+----
+
+That is the complete infrastructure configuration. The extension automatically:
+
+* Creates connections to each Axon Server context
+* Routes commands and queries to the correct context based on message metadata
+* Stores and retrieves events from tenant-specific event stores
+* Creates per-tenant event processors
+
+=== Step 3: Update Projections for Tenant Isolation
+
+The only code change required is switching from field injection to method parameter injection for data access:
+
+[source,java]
+----
+@Component
+public class CourseStatsProjector {
+
+ // REMOVED: @Autowired private CourseStatsRepository repository;
+
+ @EventHandler
+ public void on(CourseCreated event, CourseStatsRepository repository) {
+ // Repository is automatically scoped to the event's tenant
+ repository.save(new CourseStatsReadModel(
+ event.courseId().raw(),
+ event.name(),
+ event.capacity()
+ ));
+ }
+}
+----
+
+The `CourseStatsRepository` parameter is resolved per-invocation based on the event's tenant metadata. Each tenant's data is stored in its own database.
+
+=== Step 4: Send Messages with Tenant Context
+
+Include tenant information in your commands and queries:
+
+[source,java]
+----
+// From a REST controller
+@PostMapping("/courses")
+public CompletableFuture> createCourse(@RequestBody CreateCourseRequest request,
+ @RequestHeader("X-Tenant-ID") String tenantId) {
+ return commandGateway.send(
+ new CreateCourse(CourseId.random(), request.name(), request.capacity()),
+ Metadata.with("tenantId", tenantId)
+ ).getResultMessage();
+}
+
+// Query with tenant context
+@GetMapping("/courses")
+public CompletableFuture> listCourses(@RequestHeader("X-Tenant-ID") String tenantId) {
+ QueryMessage query = new GenericQueryMessage(
+ new MessageType(FindAllCourses.class),
+ new FindAllCourses()
+ ).andMetadata(Metadata.with("tenantId", tenantId));
+
+ return queryGateway.query(query, FindAllCourses.Result.class)
+ .thenApply(FindAllCourses.Result::courses);
+}
+----
+
+== Automatic Tenant Discovery
+
+Instead of listing contexts explicitly, let the extension discover them from Axon Server:
+
+[source,yaml]
+----
+axon:
+ multi-tenancy:
+ tenant-key: tenantId
+ axon-server:
+ filter-admin-contexts: true # Excludes contexts starting with "_"
+ # contexts: (omit to enable automatic discovery)
+----
+
+With automatic discovery:
+
+* The extension queries Axon Server for available contexts at startup
+* New contexts added to Axon Server are automatically registered as tenants
+* Contexts removed from Axon Server are automatically unregistered
+
+NOTE: Automatic discovery requires Axon Server's Admin API to be accessible.
+
+== Advanced Configuration
+
+=== TenantConnectPredicate for Context Filtering
+
+Control which Axon Server contexts become tenants:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.TenantConnectPredicate;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+
+@Configuration
+public class MultiTenancyConfig {
+
+ @Bean
+ public TenantConnectPredicate tenantConnectPredicate() {
+ // Only connect to contexts starting with "university-"
+ return tenant -> tenant.tenantId().startsWith("university-");
+ }
+}
+----
+
+Use cases:
+
+* Filter by naming convention (e.g., `prod-*` vs `dev-*`)
+* Exclude specific contexts (e.g., shared infrastructure contexts)
+* Implement tenant allowlists
+
+=== Custom Tenant Resolver
+
+Override the default metadata-based resolver:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.TargetTenantResolver;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.messaging.core.Message;
+
+@Configuration
+public class MultiTenancyConfig {
+
+ @Bean
+ public TargetTenantResolver tenantResolver() {
+ return (message, tenants) -> {
+ // Custom logic to determine tenant
+ // Example: extract from a custom header or message payload
+ String tenantId = message.metadata().get("organizationId");
+ if (tenantId == null) {
+ tenantId = extractFromPayload(message.payload());
+ }
+ return TenantDescriptor.tenantWithId(tenantId);
+ };
+ }
+}
+----
+
+=== Per-Tenant JPA Projections
+
+For tenant-isolated projections with separate databases:
+
+==== Enable Tenant Repositories
+
+[source,yaml]
+----
+axon:
+ multi-tenancy:
+ jpa:
+ tenant-repositories: true
+----
+
+==== Provide a TenantDataSourceProvider
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.extension.multitenancy.spring.data.jpa.TenantDataSourceProvider;
+import org.axonframework.extension.multitenancy.spring.data.jpa.TenantEntityManagerFactoryBuilder;
+import org.springframework.boot.jdbc.DataSourceBuilder;
+
+@Configuration
+public class TenantDatabaseConfig {
+
+ @Bean
+ public TenantDataSourceProvider tenantDataSourceProvider() {
+ Map cache = new ConcurrentHashMap<>();
+
+ return tenant -> cache.computeIfAbsent(tenant.tenantId(), id ->
+ DataSourceBuilder.create()
+ .url("jdbc:postgresql://localhost:5432/" + id)
+ .username("app_user")
+ .password("secret")
+ .build()
+ );
+ }
+
+ @Bean
+ public TenantEntityManagerFactoryBuilder tenantEntityManagerFactoryBuilder(
+ TenantDataSourceProvider dataSourceProvider) {
+ return TenantEntityManagerFactoryBuilder
+ .forDataSourceProvider(dataSourceProvider)
+ .packagesToScan("com.example.university.projections")
+ .jpaProperty("hibernate.hbm2ddl.auto", "validate")
+ .jpaProperty("hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect")
+ .build();
+ }
+}
+----
+
+==== Use Method Parameter Injection
+
+[source,java]
+----
+@Component
+public class CourseStatsProjector {
+
+ @EventHandler
+ public void on(CourseCreated event, CourseStatsRepository repository) {
+ // Repository connects to the tenant's specific database
+ repository.save(new CourseStatsReadModel(
+ event.courseId().raw(),
+ event.name(),
+ event.capacity()
+ ));
+ }
+}
+
+@Component
+public class CourseQueryHandler {
+
+ @QueryHandler
+ public List handle(FindAllCourses query,
+ CourseStatsRepository repository) {
+ // Query runs against the tenant's database
+ return repository.findAll().stream()
+ .map(entity -> new CourseStats(entity.getCourseId(), entity.getName()))
+ .toList();
+ }
+}
+----
+
+IMPORTANT: Always use method parameter injection for repositories and EntityManagers in multi-tenant applications. Field injection creates a single instance shared across all tenants, causing data leakage.
+
+== Production Checklist
+
+Before deploying to production, verify:
+
+=== Infrastructure
+
+* [ ] Axon Server Enterprise Edition is deployed and licensed
+* [ ] One Axon Server context exists per tenant
+* [ ] Contexts are properly named (consider a naming convention like `prod-tenant-{name}`)
+* [ ] Network connectivity from application to all Axon Server nodes
+
+=== Configuration
+
+* [ ] `axon.multi-tenancy.tenant-key` matches your metadata convention
+* [ ] Either explicit contexts are listed or automatic discovery is configured
+* [ ] `TenantConnectPredicate` filters out non-tenant contexts if using discovery
+* [ ] Admin contexts (`_*`) are filtered out (`filter-admin-contexts: true`)
+
+=== Data Isolation
+
+* [ ] All repositories and EntityManagers use method parameter injection
+* [ ] No field-injected data access components in handlers
+* [ ] Per-tenant databases are configured (if using JPA projections)
+* [ ] Database connections are properly pooled per tenant
+
+=== Operations
+
+* [ ] Monitoring configured for per-tenant metrics
+* [ ] Logging includes tenant context for debugging
+* [ ] Database migrations run per tenant before adding to Axon Server
+* [ ] Backup strategy covers all tenant databases
+
+=== Security
+
+* [ ] Tenant ID validated before adding to message metadata
+* [ ] No cross-tenant data access possible through API
+* [ ] Axon Server context access properly secured
+
+== Complete Example Configuration
+
+[source,yaml]
+----
+# application.yml
+axon:
+ axonserver:
+ servers: axonserver.example.com:8124
+
+ multi-tenancy:
+ enabled: true
+ tenant-key: tenantId
+
+ axon-server:
+ # Option 1: Explicit tenant list
+ contexts: stanford,mit,berkeley
+
+ # Option 2: Automatic discovery (comment out 'contexts' above)
+ # filter-admin-contexts: true
+
+ jpa:
+ tenant-repositories: true
+
+spring:
+ jpa:
+ # Disable default JPA when using tenant repositories
+ # (handled automatically by the extension)
+
+logging:
+ level:
+ org.axonframework.extension.multitenancy: DEBUG
+----
+
+[source,java]
+----
+@Configuration
+public class MultiTenancyConfig {
+
+ @Bean
+ public TenantConnectPredicate tenantConnectPredicate() {
+ Set allowedTenants = Set.of("stanford", "mit", "berkeley");
+ return tenant -> allowedTenants.contains(tenant.tenantId());
+ }
+
+ @Bean
+ public TenantDataSourceProvider tenantDataSourceProvider() {
+ return tenant -> DataSourceBuilder.create()
+ .url("jdbc:postgresql://db.example.com:5432/" + tenant.tenantId())
+ .username(System.getenv("DB_USERNAME"))
+ .password(System.getenv("DB_PASSWORD"))
+ .build();
+ }
+
+ @Bean
+ public TenantEntityManagerFactoryBuilder tenantEntityManagerFactoryBuilder(
+ TenantDataSourceProvider provider) {
+ return TenantEntityManagerFactoryBuilder
+ .forDataSourceProvider(provider)
+ .packagesToScan("com.example.university.projections")
+ .jpaProperty("hibernate.hbm2ddl.auto", "validate")
+ .build();
+ }
+}
+----
+
+== Next Steps
+
+* xref:../tenant-management.adoc[Tenant Management] - Dynamic tenant registration and lifecycle
+* xref:../message-routing.adoc[Message Routing] - How messages are routed to tenants
+* xref:../projections.adoc[Projections] - Detailed guide on multi-tenant read models
+* xref:../event-processors.adoc[Event Processors] - Per-tenant event processing
diff --git a/docs/reference/modules/ROOT/pages/setup/spring-boot.adoc b/docs/reference/modules/ROOT/pages/setup/spring-boot.adoc
new file mode 100644
index 0000000..df0ee3d
--- /dev/null
+++ b/docs/reference/modules/ROOT/pages/setup/spring-boot.adoc
@@ -0,0 +1,655 @@
+:navtitle: Spring Boot
+= Spring Boot Integration
+
+This guide covers setting up the Multitenancy Extension with Spring Boot using embedded storage (JPA). For Axon Server deployments, see xref:setup/axon-server.adoc[Axon Server Integration].
+
+== Benefits of Spring Boot Autoconfiguration
+
+Spring Boot autoconfiguration provides:
+
+* **Zero-configuration startup**: Multi-tenant infrastructure is created automatically
+* **Handler auto-discovery**: `@CommandHandler`, `@EventHandler`, and `@QueryHandler` methods are discovered via component scanning
+* **Tenant-scoped injection**: Spring Data repositories and `TenantComponent` implementations are automatically resolved per-tenant
+* **Configuration properties**: Customize behavior via `application.yml`
+
+== Prerequisites
+
+* Spring Boot 3.x
+* Axon Framework 5.x
+* JPA provider (Hibernate recommended)
+
+== Dependencies
+
+Add the Spring Boot starter to your `pom.xml`:
+
+[source,xml]
+----
+
+ org.axonframework.extension.multitenancy
+ multitenancy-spring-boot-starter
+ ${multitenancy.version}
+
+----
+
+This starter includes:
+
+* Core multitenancy components
+* Spring Boot autoconfiguration
+* Spring Data JPA integration
+
+For JPA, also include:
+
+[source,xml]
+----
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ com.h2database
+ h2
+ runtime
+
+----
+
+== Before: Single-Tenant Application
+
+Here is a typical single-tenant Spring Boot application managing university courses:
+
+=== Application Class
+
+[source,java]
+----
+@SpringBootApplication
+public class UniversityApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(UniversityApplication.class, args);
+ }
+}
+----
+
+=== Domain Model
+
+[source,java]
+----
+// Command
+public record CreateCourse(
+ @TargetEntityId CourseId courseId,
+ String name,
+ int capacity
+) {}
+
+// Event
+public record CourseCreated(
+ CourseId courseId,
+ String name,
+ int capacity
+) {}
+----
+
+=== JPA Entity
+
+[source,java]
+----
+@Entity
+@Table(name = "course_stats")
+public class CourseStatsReadModel {
+
+ @Id
+ private String courseId;
+ private String name;
+ private int capacity;
+
+ public CourseStatsReadModel() {}
+
+ public CourseStatsReadModel(String courseId, String name, int capacity) {
+ this.courseId = courseId;
+ this.name = name;
+ this.capacity = capacity;
+ }
+
+ // Getters and setters
+ public String getCourseId() { return courseId; }
+ public void setCourseId(String courseId) { this.courseId = courseId; }
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+ public int getCapacity() { return capacity; }
+ public void setCapacity(int capacity) { this.capacity = capacity; }
+}
+----
+
+=== Spring Data Repository
+
+[source,java]
+----
+public interface CourseStatsJpaRepository extends JpaRepository {
+}
+----
+
+=== Event Handler (Single-Tenant)
+
+In a single-tenant application, the repository is typically injected via constructor:
+
+[source,java]
+----
+@Component
+public class CourseStatsProjector {
+
+ private final CourseStatsJpaRepository repository;
+
+ public CourseStatsProjector(CourseStatsJpaRepository repository) {
+ this.repository = repository; // Single shared instance
+ }
+
+ @EventHandler
+ public void on(CourseCreated event) {
+ repository.save(new CourseStatsReadModel(
+ event.courseId().raw(),
+ event.name(),
+ event.capacity()
+ ));
+ }
+}
+----
+
+This approach fails in multi-tenant scenarios because all tenants share the same repository instance, causing data to mix between tenants.
+
+== After: Multi-Tenant Transformation
+
+=== Application Configuration
+
+Configure multi-tenancy in `application.yml`:
+
+[source,yaml]
+----
+axon:
+ axonserver:
+ enabled: false # Using embedded storage, not Axon Server
+ multi-tenancy:
+ tenant-key: tenantId # Metadata key for tenant identification (default)
+ jpa:
+ tenant-repositories: true # Enable per-tenant JPA repositories
+----
+
+=== Tenant Provider
+
+Provide a `TenantProvider` bean that defines available tenants:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.extension.multitenancy.core.TenantProvider;
+import org.axonframework.extension.multitenancy.core.SimpleTenantProvider;
+
+@Configuration
+public class MultiTenancyConfiguration {
+
+ public static final TenantDescriptor UNIVERSITY_A =
+ TenantDescriptor.tenantWithId("university-a");
+ public static final TenantDescriptor UNIVERSITY_B =
+ TenantDescriptor.tenantWithId("university-b");
+
+ @Bean
+ public TenantProvider tenantProvider() {
+ return new SimpleTenantProvider(List.of(UNIVERSITY_A, UNIVERSITY_B));
+ }
+}
+----
+
+=== Per-Tenant DataSource Provider
+
+When using `axon.multi-tenancy.jpa.tenant-repositories=true`, you must provide a `TenantDataSourceProvider` that supplies a `DataSource` for each tenant:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.spring.data.jpa.TenantDataSourceProvider;
+import org.springframework.boot.jdbc.DataSourceBuilder;
+import javax.sql.DataSource;
+
+@Configuration
+public class MultiTenancyConfiguration {
+
+ @Bean
+ public TenantDataSourceProvider tenantDataSourceProvider() {
+ Map cache = new ConcurrentHashMap<>();
+ return tenant -> cache.computeIfAbsent(tenant.tenantId(), id ->
+ DataSourceBuilder.create()
+ .url("jdbc:h2:mem:" + id + ";DB_CLOSE_DELAY=-1")
+ .driverClassName("org.h2.Driver")
+ .username("sa")
+ .password("")
+ .build()
+ );
+ }
+}
+----
+
+For production, connect to separate databases per tenant:
+
+[source,java]
+----
+@Bean
+public TenantDataSourceProvider tenantDataSourceProvider(
+ @Value("${db.username}") String username,
+ @Value("${db.password}") String password) {
+
+ Map cache = new ConcurrentHashMap<>();
+ return tenant -> cache.computeIfAbsent(tenant.tenantId(), id ->
+ DataSourceBuilder.create()
+ .url("jdbc:postgresql://localhost:5432/" + id)
+ .driverClassName("org.postgresql.Driver")
+ .username(username)
+ .password(password)
+ .build()
+ );
+}
+----
+
+=== EntityManagerFactory Configuration
+
+Optionally customize the `TenantEntityManagerFactoryBuilder` for Hibernate settings:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.spring.data.jpa.TenantEntityManagerFactoryBuilder;
+
+@Bean
+public TenantEntityManagerFactoryBuilder tenantEntityManagerFactoryBuilder(
+ TenantDataSourceProvider dataSourceProvider) {
+ return TenantEntityManagerFactoryBuilder
+ .forDataSourceProvider(dataSourceProvider)
+ .packagesToScan("com.example.university.domain.read")
+ .jpaProperty("hibernate.hbm2ddl.auto", "create-drop")
+ .jpaProperty("hibernate.show_sql", "true")
+ .build();
+}
+----
+
+=== Repository Interface (No Changes Required)
+
+Your Spring Data repository interface stays exactly the same:
+
+[source,java]
+----
+public interface CourseStatsJpaRepository extends JpaRepository {
+}
+----
+
+When `axon.multi-tenancy.jpa.tenant-repositories=true`, the extension automatically:
+
+1. Discovers all `Repository` interfaces via classpath scanning
+2. Registers them as tenant components
+3. Creates tenant-scoped instances on demand
+
+=== Event Handler (Multi-Tenant)
+
+The key change is using **method parameter injection** instead of constructor injection:
+
+[source,java]
+----
+@Component
+public class CourseStatsProjector {
+
+ private static final Logger logger = LoggerFactory.getLogger(CourseStatsProjector.class);
+
+ @EventHandler
+ public void on(CourseCreated event, CourseStatsJpaRepository repository) {
+ // repository is automatically scoped to the event's tenant
+ logger.info("Saving course {} for current tenant", event.courseId());
+
+ repository.save(new CourseStatsReadModel(
+ event.courseId().raw(),
+ event.name(),
+ event.capacity()
+ ));
+ }
+}
+----
+
+The `repository` parameter is resolved by `TenantRepositoryParameterResolverFactory`, which:
+
+1. Extracts the tenant from the event's metadata
+2. Gets or creates the `EntityManagerFactory` for that tenant
+3. Creates a Spring Data repository bound to that tenant's `EntityManager`
+
+=== Query Handler
+
+Query handlers also receive tenant-scoped repositories:
+
+[source,java]
+----
+public record FindAllCourses() {
+ public record Result(List courses) {}
+}
+
+@Component
+public class FindAllCoursesQueryHandler {
+
+ @QueryHandler
+ public FindAllCourses.Result handle(FindAllCourses query,
+ CourseStatsJpaRepository repository) {
+ // repository is scoped to the query's tenant
+ return new FindAllCourses.Result(repository.findAll());
+ }
+}
+----
+
+== TenantComponent for Tenant-Scoped Services
+
+Beyond repositories, you can create custom tenant-scoped services using the `TenantComponent` interface.
+
+=== Defining a TenantComponent
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.extension.multitenancy.spring.TenantComponent;
+
+// Note: Do NOT add @Component annotation!
+public class TenantAuditService implements TenantComponent {
+
+ private final Clock clock; // Spring dependency
+ private final String tenantId; // Tenant context
+
+ // Constructor for factory instance - Spring injects Clock
+ public TenantAuditService(Clock clock) {
+ this.clock = clock;
+ this.tenantId = null;
+ }
+
+ // Private constructor for tenant-specific instances
+ private TenantAuditService(Clock clock, String tenantId) {
+ this.clock = clock;
+ this.tenantId = tenantId;
+ }
+
+ @Override
+ public TenantAuditService createForTenant(TenantDescriptor tenant) {
+ return new TenantAuditService(clock, tenant.tenantId());
+ }
+
+ public void recordAudit(String action) {
+ Instant timestamp = clock.instant();
+ // Record audit for this tenant
+ System.out.println(tenantId + ": " + action + " at " + timestamp);
+ }
+
+ public String getTenantId() {
+ return tenantId;
+ }
+}
+----
+
+Key points:
+
+* Do NOT annotate with `@Component` - the autoconfiguration discovers and manages these
+* Spring dependencies are injected into the factory constructor
+* The `createForTenant` method creates tenant-specific instances
+* Instances are cached per-tenant
+
+=== Using TenantComponent in Handlers
+
+[source,java]
+----
+@Component
+public class CourseStatsProjector {
+
+ @EventHandler
+ public void on(CourseCreated event,
+ CourseStatsJpaRepository repository,
+ TenantAuditService auditService) {
+
+ // Both are automatically scoped to the event's tenant
+ auditService.recordAudit("course_created:" + event.courseId().raw());
+
+ repository.save(new CourseStatsReadModel(
+ event.courseId().raw(),
+ event.name(),
+ event.capacity()
+ ));
+ }
+}
+----
+
+== TenantAwareProcessingContext
+
+For advanced scenarios, inject `ProcessingContext` to access tenant-scoped components programmatically:
+
+[source,java]
+----
+import org.axonframework.messaging.core.unitofwork.ProcessingContext;
+
+@EventHandler
+public void on(CourseCreated event, ProcessingContext context) {
+ // Get tenant-scoped repository from context
+ CourseStatsJpaRepository repository =
+ context.component(CourseStatsJpaRepository.class);
+
+ // Get tenant-scoped service from context
+ TenantAuditService auditService =
+ context.component(TenantAuditService.class);
+
+ auditService.recordAudit("course_created:" + event.courseId().raw());
+ repository.save(new CourseStatsReadModel(
+ event.courseId().raw(),
+ event.name(),
+ event.capacity()
+ ));
+}
+----
+
+When multi-tenancy is enabled, `ProcessingContext` is wrapped in `TenantAwareProcessingContext`, which intercepts `component()` calls for registered tenant-scoped types.
+
+== Sending Messages with Tenant Context
+
+When sending commands or queries, include the tenant ID in metadata:
+
+[source,java]
+----
+import org.axonframework.messaging.core.Metadata;
+
+// Sending a command
+// Note: .getResultMessage().join() blocks until completion - only needed in main() or tests.
+// In REST controllers, return CompletableFuture instead.
+commandGateway.send(
+ new CreateCourse(courseId, "Introduction to Java", 30),
+ Metadata.with("tenantId", "university-a")
+).getResultMessage().join();
+
+// Sending a query
+QueryMessage query = new GenericQueryMessage(
+ new MessageType(FindAllCourses.class),
+ new FindAllCourses()
+).andMetadata(Metadata.with("tenantId", "university-a"));
+
+List courses = queryGateway
+ .query(query, FindAllCourses.Result.class)
+ .thenApply(FindAllCourses.Result::courses)
+ .join();
+----
+
+Messages without tenant metadata will fail with `NoSuchTenantException`.
+
+== Key Spring Boot Features
+
+=== Auto-Discovered Handlers
+
+Handler classes annotated with `@Component` are automatically discovered:
+
+[source,java]
+----
+@Component // Spring discovers this
+public class CourseStatsProjector {
+
+ @EventHandler // Axon registers this handler
+ public void on(CourseCreated event, CourseStatsJpaRepository repository) {
+ // ...
+ }
+}
+----
+
+=== Tenant-Scoped Repository Injection
+
+When `axon.multi-tenancy.jpa.tenant-repositories=true`:
+
+* Spring Data repositories are NOT registered as Spring beans
+* Instead, they are created per-tenant on demand
+* Inject via handler method parameters, not constructor injection
+
+=== Configuration Properties
+
+[cols="2,1,3"]
+|===
+|Property |Default |Description
+
+|`axon.multi-tenancy.enabled`
+|`true`
+|Enable/disable multi-tenancy
+
+|`axon.multi-tenancy.tenant-key`
+|`tenantId`
+|Metadata key for tenant identification
+
+|`axon.multi-tenancy.jpa.tenant-repositories`
+|`false`
+|Enable per-tenant Spring Data repositories
+
+|`axon.multi-tenancy.tenant-components.enabled`
+|`true`
+|Enable TenantComponent auto-discovery
+|===
+
+== Complete Example
+
+=== Configuration
+
+[source,java]
+----
+@Configuration
+public class UniversityMultiTenancyConfig {
+
+ public static final TenantDescriptor UNIVERSITY_A =
+ TenantDescriptor.tenantWithId("university-a");
+ public static final TenantDescriptor UNIVERSITY_B =
+ TenantDescriptor.tenantWithId("university-b");
+
+ @Bean
+ public TenantProvider tenantProvider() {
+ return new SimpleTenantProvider(List.of(UNIVERSITY_A, UNIVERSITY_B));
+ }
+
+ @Bean
+ public TenantDataSourceProvider tenantDataSourceProvider() {
+ Map cache = new ConcurrentHashMap<>();
+ return tenant -> cache.computeIfAbsent(tenant.tenantId(), id ->
+ DataSourceBuilder.create()
+ .url("jdbc:h2:mem:" + id + ";DB_CLOSE_DELAY=-1")
+ .driverClassName("org.h2.Driver")
+ .username("sa")
+ .password("")
+ .build()
+ );
+ }
+
+ @Bean
+ public TenantEntityManagerFactoryBuilder tenantEntityManagerFactoryBuilder(
+ TenantDataSourceProvider dataSourceProvider) {
+ return TenantEntityManagerFactoryBuilder
+ .forDataSourceProvider(dataSourceProvider)
+ .packagesToScan("com.example.university.domain.read")
+ .jpaProperty("hibernate.hbm2ddl.auto", "create-drop")
+ .build();
+ }
+
+ @Bean
+ public Clock clock() {
+ return Clock.systemUTC();
+ }
+}
+----
+
+=== application.yml
+
+[source,yaml]
+----
+axon:
+ axonserver:
+ enabled: false
+ multi-tenancy:
+ jpa:
+ tenant-repositories: true
+
+logging:
+ level:
+ org.axonframework: INFO
+ org.axonframework.extension.multitenancy: DEBUG
+----
+
+=== Repository
+
+[source,java]
+----
+public interface CourseStatsJpaRepository
+ extends JpaRepository {
+}
+----
+
+=== Projector
+
+[source,java]
+----
+@Component
+public class CourseStatsProjector {
+
+ @EventHandler
+ public void on(CourseCreated event,
+ CourseStatsJpaRepository repository,
+ TenantAuditService auditService) {
+
+ auditService.recordAudit("course_created:" + event.courseId().raw());
+
+ repository.save(new CourseStatsReadModel(
+ event.courseId().raw(),
+ event.name(),
+ event.capacity()
+ ));
+ }
+}
+----
+
+=== Query Handler
+
+[source,java]
+----
+@Component
+public class FindAllCoursesQueryHandler {
+
+ @QueryHandler
+ public FindAllCourses.Result handle(FindAllCourses query,
+ CourseStatsJpaRepository repository) {
+ return new FindAllCourses.Result(repository.findAll());
+ }
+}
+----
+
+== What Happens Automatically
+
+When you add the multitenancy starter with the configuration above:
+
+1. **Multi-tenant infrastructure**: `MultiTenantCommandBus`, `MultiTenantQueryBus`, and `MultiTenantEventStore` replace the standard implementations
+
+2. **Handler registration**: Handlers are registered with all tenants via `MultiTenantMessageHandlerLookup`
+
+3. **Repository discovery**: All `JpaRepository` interfaces are discovered and registered as tenant components
+
+4. **TenantComponent discovery**: All `TenantComponent` implementations are discovered and registered
+
+5. **Parameter resolution**: Handler parameters are resolved per-tenant based on message metadata
+
+6. **Tenant correlation**: Tenant ID automatically propagates from commands to events via `TenantCorrelationProvider`
+
+== Next Steps
+
+* xref:tenant-management.adoc[Tenant Management] - Dynamic tenant registration
+* xref:projections.adoc[Projections] - Advanced projection strategies
+* xref:event-processors.adoc[Event Processors] - Multi-tenant event processing
diff --git a/docs/reference/modules/ROOT/pages/tenant-management.adoc b/docs/reference/modules/ROOT/pages/tenant-management.adoc
new file mode 100644
index 0000000..a02a3c3
--- /dev/null
+++ b/docs/reference/modules/ROOT/pages/tenant-management.adoc
@@ -0,0 +1,173 @@
+:navtitle: Tenant Management
+= Tenant Management
+
+Tenant management is handled by the `TenantProvider` interface. This component tracks which tenants exist and notifies other components when tenants are added or removed.
+
+== TenantProvider Interface
+
+[source,java]
+----
+public interface TenantProvider {
+ Registration subscribe(MultiTenantAwareComponent component);
+ List getTenants();
+}
+----
+
+The provider serves two purposes:
+
+1. **Query**: Returns the current list of tenants via `getTenants()`
+2. **Subscribe**: Notifies components when tenants change via `subscribe()`
+
+Multi-tenant infrastructure components (like `MultiTenantCommandBus`) subscribe to the provider and automatically create/destroy tenant segments as tenants come and go.
+
+== Choosing a TenantProvider
+
+=== With Axon Server (Recommended)
+
+When using the `multitenancy-axon-server-connector` module, `AxonServerTenantProvider` is auto-registered via SPI. It discovers tenants from Axon Server contexts.
+
+[source,yaml]
+----
+# application.yml
+axon:
+ multi-tenancy:
+ axon-server:
+ # Option 1: Explicit list
+ contexts: tenant-1,tenant-2,tenant-3
+
+ # Option 2: Auto-discovery with filtering
+ filter-admin-contexts: true # Excludes contexts starting with "_"
+----
+
+With auto-discovery, the provider monitors Axon Server and automatically adds/removes tenants as contexts are created/deleted.
+
+=== SimpleTenantProvider
+
+For deployments without Axon Server, use `SimpleTenantProvider`:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.SimpleTenantProvider;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+
+// Static tenants at startup
+SimpleTenantProvider provider = new SimpleTenantProvider();
+provider.addTenant(TenantDescriptor.tenantWithId("tenant-1"));
+provider.addTenant(TenantDescriptor.tenantWithId("tenant-2"));
+----
+
+Or with initial tenants in the constructor:
+
+[source,java]
+----
+SimpleTenantProvider provider = new SimpleTenantProvider(List.of(
+ TenantDescriptor.tenantWithId("tenant-1"),
+ TenantDescriptor.tenantWithId("tenant-2")
+));
+----
+
+=== Dynamic Tenant Management
+
+`SimpleTenantProvider` supports runtime tenant changes:
+
+[source,java]
+----
+// Add a tenant at runtime
+provider.addTenant(TenantDescriptor.tenantWithId("new-tenant"));
+
+// Remove a tenant
+provider.removeTenant(TenantDescriptor.tenantWithId("old-tenant"));
+
+// Check if tenant exists
+boolean exists = provider.hasTenant("tenant-1");
+----
+
+When you add a tenant, all subscribed components (command bus, query bus, event store, event processors) automatically create infrastructure for that tenant.
+
+== Configuration
+
+=== Axon Framework
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.SimpleTenantProvider;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.extension.multitenancy.core.configuration.MultiTenancyConfigurer;
+import org.axonframework.messaging.core.configuration.MessagingConfigurer;
+
+SimpleTenantProvider tenantProvider = new SimpleTenantProvider(List.of(
+ TenantDescriptor.tenantWithId("tenant-1"),
+ TenantDescriptor.tenantWithId("tenant-2")
+));
+
+MessagingConfigurer messagingConfigurer = MessagingConfigurer.create();
+// ... configure handlers, entities, etc.
+
+MultiTenancyConfigurer.enhance(messagingConfigurer)
+ .registerTenantProvider(config -> tenantProvider)
+ .registerTargetTenantResolver(config -> new MetadataBasedTenantResolver("tenantId"))
+ .build()
+ .start();
+----
+
+=== Spring Boot
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.SimpleTenantProvider;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.extension.multitenancy.core.TenantProvider;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class TenantConfiguration {
+
+ @Bean
+ public TenantProvider tenantProvider() {
+ return new SimpleTenantProvider(List.of(
+ TenantDescriptor.tenantWithId("tenant-1"),
+ TenantDescriptor.tenantWithId("tenant-2")
+ ));
+ }
+}
+----
+
+NOTE: With Axon Server and Spring Boot, you typically don't need to define a `TenantProvider` bean - the `AxonServerTenantProvider` is auto-registered via SPI.
+
+== Filtering Tenants
+
+Use `TenantConnectPredicate` to filter which tenants your application connects to:
+
+[source,java]
+----
+import org.axonframework.extension.multitenancy.core.TenantConnectPredicate;
+import org.springframework.context.annotation.Bean;
+
+@Bean
+public TenantConnectPredicate tenantFilter() {
+ return tenant -> tenant.tenantId().startsWith("customer-");
+}
+----
+
+This is useful when:
+
+* Multiple applications share an Axon Server cluster but handle different tenant subsets
+* You want to exclude administrative or system contexts
+* You're doing a rolling deployment and want to gradually migrate tenants
+
+== TenantDescriptor
+
+`TenantDescriptor` is a simple record that identifies a tenant:
+
+[source,java]
+----
+TenantDescriptor tenant = TenantDescriptor.tenantWithId("my-tenant");
+String id = tenant.tenantId(); // "my-tenant"
+----
+
+The tenant ID is typically used:
+
+* As the Axon Server context name
+* In database connection strings for tenant-specific schemas
+* As a routing key in message metadata
diff --git a/docs/reference/modules/nav.adoc b/docs/reference/modules/nav.adoc
index af74732..139290c 100644
--- a/docs/reference/modules/nav.adoc
+++ b/docs/reference/modules/nav.adoc
@@ -1,4 +1,21 @@
-* xref::configuration.adoc[]
-* xref::multi-tenant-components.adoc[]
-* xref::disable.adoc[]
-* xref::release-notes.adoc[]
\ No newline at end of file
+* xref:ROOT:index.adoc[Introduction]
+
+* Setup Guides
+** xref:ROOT:setup/getting-started.adoc[Getting Started]
+** xref:ROOT:setup/axon-server.adoc[Axon Server Integration]
+** xref:ROOT:setup/spring-boot.adoc[Spring Boot Integration]
+** xref:ROOT:setup/spring-boot-axon-server.adoc[Spring Boot with Axon Server]
+
+* Concepts
+** xref:ROOT:tenant-management.adoc[Tenant Management]
+** xref:ROOT:message-routing.adoc[Message Routing]
+** xref:ROOT:infrastructure.adoc[Infrastructure]
+** xref:ROOT:projections.adoc[Projections]
+
+* Configuration Reference
+** xref:ROOT:configuration/tenant-resolution.adoc[Tenant Resolution]
+** xref:ROOT:configuration/event-processors.adoc[Event Processors]
+** xref:ROOT:configuration/tenant-components.adoc[Tenant Components]
+** xref:ROOT:configuration/dynamic-tenants.adoc[Dynamic Tenants]
+
+* xref:ROOT:release-notes.adoc[Release Notes]
diff --git a/multitenancy-axon-server-connector/pom.xml b/multitenancy-axon-server-connector/pom.xml
new file mode 100644
index 0000000..cfb4493
--- /dev/null
+++ b/multitenancy-axon-server-connector/pom.xml
@@ -0,0 +1,86 @@
+
+
+
+ 4.0.0
+
+ axon-multitenancy-parent
+ org.axonframework.extensions.multitenancy
+ 5.1.0-SNAPSHOT
+
+
+ axon-multitenancy-axon-server-connector
+
+ Axon Framework Multi-Tenancy Extension - Axon Server Connector
+
+ Module providing Axon Server integration for multi-tenancy. Contains AxonServerTenantProvider
+ which discovers tenants from Axon Server contexts. This module is Spring-agnostic and can be
+ used with any framework (raw Java, Quarkus, etc.) that uses Axon Server.
+
+
+ jar
+
+
+
+
+ org.axonframework.extensions.multitenancy
+ axon-multitenancy
+ ${project.version}
+
+
+
+ org.axonframework
+ axon-server-connector
+ provided
+
+
+
+ org.axonframework
+ axon-common
+ provided
+
+
+
+ org.axonframework
+ axon-messaging
+ provided
+
+
+
+ org.axonframework
+ axon-eventsourcing
+ provided
+
+
+
+ jakarta.annotation
+ jakarta.annotation-api
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ ${junit.jupiter.version}
+ test
+
+
+ org.mockito
+ mockito-core
+ ${mockito.version}
+ test
+
+
+
diff --git a/multitenancy-axon-server-connector/src/main/java/org/axonframework/extension/multitenancy/axonserver/AxonServerTenantEventSegmentFactory.java b/multitenancy-axon-server-connector/src/main/java/org/axonframework/extension/multitenancy/axonserver/AxonServerTenantEventSegmentFactory.java
new file mode 100644
index 0000000..d8c5fb0
--- /dev/null
+++ b/multitenancy-axon-server-connector/src/main/java/org/axonframework/extension/multitenancy/axonserver/AxonServerTenantEventSegmentFactory.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.axonserver;
+
+import jakarta.annotation.Nonnull;
+import org.axonframework.axonserver.connector.AxonServerConnectionManager;
+import org.axonframework.axonserver.connector.event.AxonServerEventStorageEngine;
+import org.axonframework.axonserver.connector.event.AxonServerEventStorageEngineFactory;
+import org.axonframework.common.configuration.Configuration;
+import org.axonframework.eventsourcing.eventstore.AnnotationBasedTagResolver;
+import org.axonframework.eventsourcing.eventstore.EventStore;
+import org.axonframework.eventsourcing.eventstore.StorageEngineBackedEventStore;
+import org.axonframework.eventsourcing.eventstore.TagResolver;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.extension.multitenancy.eventsourcing.eventstore.TenantEventSegmentFactory;
+import org.axonframework.messaging.eventhandling.SimpleEventBus;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Objects;
+
+/**
+ * A {@link TenantEventSegmentFactory} that creates {@link EventStore} instances
+ * per tenant, connecting each to its own Axon Server context.
+ *
+ * For each tenant, this factory:
+ *
+ *
Creates an {@link AxonServerEventStorageEngine} for the tenant's context
+ *
Wraps it in a {@link StorageEngineBackedEventStore}
+ *
+ *
+ * @author Theo Emanuelsson
+ * @since 5.0.0
+ * @see TenantEventSegmentFactory
+ * @see AxonServerEventStorageEngineFactory
+ */
+public class AxonServerTenantEventSegmentFactory implements TenantEventSegmentFactory {
+
+ private static final Logger logger = LoggerFactory.getLogger(AxonServerTenantEventSegmentFactory.class);
+
+ private final Configuration configuration;
+ private final TagResolver tagResolver;
+
+ /**
+ * Constructs an {@link AxonServerTenantEventSegmentFactory} using the provided configuration.
+ *
+ * @param configuration The Axon {@link Configuration} used to create tenant-specific event stores.
+ * @param tagResolver The {@link TagResolver} used for tagging events.
+ */
+ public AxonServerTenantEventSegmentFactory(
+ @Nonnull Configuration configuration,
+ @Nonnull TagResolver tagResolver
+ ) {
+ this.configuration = Objects.requireNonNull(configuration, "Configuration must not be null");
+ this.tagResolver = Objects.requireNonNull(tagResolver, "TagResolver must not be null");
+ }
+
+ /**
+ * Creates an {@link AxonServerTenantEventSegmentFactory} from the given {@link Configuration}.
+ *
+ * @param config The Axon {@link Configuration} to obtain components from.
+ * @return A new factory instance, or {@code null} if required components are not available.
+ */
+ public static AxonServerTenantEventSegmentFactory createFrom(@Nonnull Configuration config) {
+ // Only create if AxonServerConnectionManager is available
+ if (!config.hasComponent(AxonServerConnectionManager.class)) {
+ return null;
+ }
+
+ TagResolver tagResolver = config.getComponent(
+ TagResolver.class,
+ () -> new AnnotationBasedTagResolver()
+ );
+
+ return new AxonServerTenantEventSegmentFactory(config, tagResolver);
+ }
+
+ @Override
+ public EventStore apply(TenantDescriptor tenant) {
+ logger.debug("Creating EventStore segment for tenant [{}]", tenant.tenantId());
+
+ // Create tenant-specific storage engine using the factory
+ AxonServerEventStorageEngine storageEngine = AxonServerEventStorageEngineFactory.constructForContext(
+ tenant.tenantId(),
+ configuration
+ );
+
+ // Create event store with tenant-specific storage engine
+ StorageEngineBackedEventStore eventStore = new StorageEngineBackedEventStore(
+ storageEngine,
+ new SimpleEventBus(),
+ tagResolver
+ );
+
+ logger.debug("Created EventStore segment for tenant [{}]", tenant.tenantId());
+ return eventStore;
+ }
+}
diff --git a/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extensions/multitenancy/autoconfig/AxonServerTenantProvider.java b/multitenancy-axon-server-connector/src/main/java/org/axonframework/extension/multitenancy/axonserver/AxonServerTenantProvider.java
similarity index 61%
rename from multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extensions/multitenancy/autoconfig/AxonServerTenantProvider.java
rename to multitenancy-axon-server-connector/src/main/java/org/axonframework/extension/multitenancy/axonserver/AxonServerTenantProvider.java
index 3570406..91fbd0e 100644
--- a/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extensions/multitenancy/autoconfig/AxonServerTenantProvider.java
+++ b/multitenancy-axon-server-connector/src/main/java/org/axonframework/extension/multitenancy/axonserver/AxonServerTenantProvider.java
@@ -1,11 +1,11 @@
/*
- * Copyright (c) 2010-2023. Axon Framework
+ * Copyright (c) 2010-2025. Axon Framework
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
- * http://www.apache.org/licenses/LICENSE-2.0
+ * http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.axonframework.extensions.multitenancy.autoconfig;
+package org.axonframework.extension.multitenancy.axonserver;
import io.axoniq.axonserver.connector.ResultStream;
import io.axoniq.axonserver.grpc.admin.ContextOverview;
@@ -21,12 +21,10 @@
import org.axonframework.axonserver.connector.AxonServerConnectionManager;
import org.axonframework.common.Registration;
import org.axonframework.common.StringUtils;
-import org.axonframework.extensions.multitenancy.components.MultiTenantAwareComponent;
-import org.axonframework.extensions.multitenancy.components.TenantConnectPredicate;
-import org.axonframework.extensions.multitenancy.components.TenantDescriptor;
-import org.axonframework.extensions.multitenancy.components.TenantProvider;
-import org.axonframework.lifecycle.Lifecycle;
-import org.axonframework.lifecycle.Phase;
+import org.axonframework.extension.multitenancy.core.MultiTenantAwareComponent;
+import org.axonframework.extension.multitenancy.core.TenantConnectPredicate;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.extension.multitenancy.core.TenantProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -38,58 +36,80 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;
-import javax.annotation.Nonnull;
/**
* Axon Server implementation of the {@link TenantProvider}. Uses Axon Server's multi-context support to construct
* tenant-specific segments of all {@link MultiTenantAwareComponent MultiTenantAwareComponents}.
+ *
+ * This provider can:
+ *
+ *
Discover tenants from predefined context names
+ *
Dynamically discover tenants via Axon Server's Admin API
+ *
Subscribe to context updates to add/remove tenants at runtime
+ *
+ *
+ * This class is Spring-agnostic and can be used with any framework that uses Axon Server.
+ * Lifecycle management (start/shutdown) is handled through the configuration API via
+ * {@link DistributedMultiTenancyConfigurationDefaults}.
*
* @author Stefan Dragisic
- * @since 4.6.0
+ * @author Theo Emanuelsson
+ * @since 5.0.0
+ * @see TenantProvider
+ * @see TenantConnectPredicate
+ * @see DistributedMultiTenancyConfigurationDefaults
*/
-public class AxonServerTenantProvider implements TenantProvider, Lifecycle {
+public class AxonServerTenantProvider implements TenantProvider {
private static final Logger logger = LoggerFactory.getLogger(AxonServerTenantProvider.class);
+ private static final String ADMIN_CTX = "_admin";
private final List tenantAwareComponents = new CopyOnWriteArrayList<>();
-
private final Set tenantDescriptors = new HashSet<>();
private final String preDefinedContexts;
private final TenantConnectPredicate tenantConnectPredicate;
private final AxonServerConnectionManager axonServerConnectionManager;
- private final String ADMIN_CTX = "_admin";
+
private ConcurrentHashMap> registrationMap = new ConcurrentHashMap<>();
/**
- * Construct a {@link AxonServerTenantProvider}.
+ * Construct a {@link AxonServerTenantProvider} using a builder pattern.
*
- * @param preDefinedContexts A comma-separated list of all the base contexts used for the tenants.
- * @param tenantConnectPredicate A {@link java.util.function.Predicate} used to filter out newly registered
- * contexts as tenants.
- * @param axonServerConnectionManager The {@link AxonServerConnectionManager} used to retrieve all context-specific
- * changes through to make adjustments in the tenant-specific segments.
+ * @param builder The builder containing the configuration.
*/
- public AxonServerTenantProvider(String preDefinedContexts,
- TenantConnectPredicate tenantConnectPredicate,
- AxonServerConnectionManager axonServerConnectionManager) {
- this.preDefinedContexts = preDefinedContexts;
- this.tenantConnectPredicate = tenantConnectPredicate;
- this.axonServerConnectionManager = axonServerConnectionManager;
+ protected AxonServerTenantProvider(Builder builder) {
+ this.preDefinedContexts = builder.preDefinedContexts;
+ this.tenantConnectPredicate = builder.tenantConnectPredicate;
+ this.axonServerConnectionManager = builder.axonServerConnectionManager;
}
/**
- * Start this {@link TenantProvider}, by added all tenants and subscribing to the
+ * Creates a new {@link Builder} to construct an {@link AxonServerTenantProvider}.
+ *
+ * @return A new Builder instance.
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Start this {@link TenantProvider}, by adding all tenants and subscribing to the
* {@link AxonServerConnectionManager} for context updates.
+ *
+ * @return A {@link CompletableFuture} that completes when the provider has started.
*/
- public void start() {
- tenantDescriptors.addAll(getInitialTenants());
- tenantDescriptors.forEach(this::addTenant);
- if (preDefinedContexts == null || preDefinedContexts.isEmpty()) {
- subscribeToUpdates();
- }
+ public CompletableFuture start() {
+ return CompletableFuture.runAsync(() -> {
+ tenantDescriptors.addAll(getInitialTenants());
+ tenantDescriptors.forEach(this::addTenant);
+ if (preDefinedContexts == null || preDefinedContexts.isEmpty()) {
+ subscribeToUpdates();
+ }
+ });
}
private List getInitialTenants() {
@@ -125,6 +145,10 @@ private void subscribeToUpdates() {
break;
case DELETED:
removeTenant(TenantDescriptor.tenantWithId(contextUpdate.getContext()));
+ break;
+ default:
+ // Ignore other update types
+ break;
}
}
} catch (Exception e) {
@@ -176,7 +200,7 @@ private TenantDescriptor toTenantDescriptor(ContextOverview context) {
/**
* Adds a new tenant to the system.
- *
+ *
* This method adds the provided {@link TenantDescriptor} to the set of known tenants.
* Once added all {@link MultiTenantAwareComponent MultiTenantAwareComponents} are registered and started for the
* new tenant.
@@ -186,14 +210,14 @@ private TenantDescriptor toTenantDescriptor(ContextOverview context) {
public void addTenant(TenantDescriptor tenantDescriptor) {
tenantDescriptors.add(tenantDescriptor);
tenantAwareComponents
- .forEach(bus -> registrationMap
+ .forEach(component -> registrationMap
.computeIfAbsent(tenantDescriptor, t -> new CopyOnWriteArrayList<>())
- .add(bus.registerAndStartTenant(tenantDescriptor)));
+ .add(component.registerAndStartTenant(tenantDescriptor)));
}
/**
* Removes a tenant from the system.
- *
+ *
* This method checks if the provided {@link TenantDescriptor} is in the set of known tenants.
* If it is, the method removes the tenant and cancels all its registrations.
* {@link MultiTenantAwareComponent MultiTenantAwareComponents} are then unregistered in reverse order of their
@@ -213,18 +237,18 @@ public void removeTenant(TenantDescriptor tenantDescriptor) {
}
@Override
- public Registration subscribe(MultiTenantAwareComponent bus) {
- tenantAwareComponents.add(bus);
+ public Registration subscribe(MultiTenantAwareComponent component) {
+ tenantAwareComponents.add(component);
tenantDescriptors
.forEach(tenantDescriptor -> registrationMap
.computeIfAbsent(tenantDescriptor, t -> new CopyOnWriteArrayList<>())
- .add(bus.registerTenant(tenantDescriptor)));
+ .add(component.registerTenant(tenantDescriptor)));
return () -> {
+ tenantAwareComponents.remove(component);
registrationMap.forEach((tenant, registrationList) -> {
registrationList.forEach(Registration::cancel);
- tenantAwareComponents.removeIf(t -> true);
axonServerConnectionManager.disconnect(tenant.tenantId());
});
registrationMap = new ConcurrentHashMap<>();
@@ -232,16 +256,8 @@ public Registration subscribe(MultiTenantAwareComponent bus) {
};
}
- @Override
- public void registerLifecycleHandlers(@Nonnull LifecycleRegistry lifecycle) {
- lifecycle.onStart(Phase.INSTRUCTION_COMPONENTS + 10, this::start);
- lifecycle.onShutdown(Phase.INSTRUCTION_COMPONENTS + 10, this::shutdown);
- }
-
/**
* Shuts down the AxonServerTenantProvider by deregistering all subscribed components.
- * This method is designed to be invoked by a lifecycle handler (e.g., a shutdown hook)
- * to ensure proper cleanup when the application is shutting down.
*
* The shutdown process involves the following steps:
*
@@ -255,12 +271,74 @@ public void registerLifecycleHandlers(@Nonnull LifecycleRegistry lifecycle) {
* This method ensures that all resources associated with tenant management are properly
* released and that components are given the opportunity to perform any necessary cleanup
* in the reverse order of their registration.
+ *
+ * @return A {@link CompletableFuture} that completes when the provider has shut down.
*/
- public void shutdown() {
- registrationMap.values().forEach(registrationList -> {
- ArrayList reversed = new ArrayList<>(registrationList);
- Collections.reverse(reversed);
- reversed.forEach(Registration::cancel);
+ public CompletableFuture shutdown() {
+ return CompletableFuture.runAsync(() -> {
+ registrationMap.values().forEach(registrationList -> {
+ ArrayList reversed = new ArrayList<>(registrationList);
+ Collections.reverse(reversed);
+ reversed.forEach(Registration::cancel);
+ });
});
}
+
+ /**
+ * Builder class for constructing an {@link AxonServerTenantProvider}.
+ */
+ public static class Builder {
+
+ private String preDefinedContexts;
+ private TenantConnectPredicate tenantConnectPredicate = tenant -> true;
+ private AxonServerConnectionManager axonServerConnectionManager;
+
+ /**
+ * Sets a comma-separated list of predefined context names to use as tenants.
+ * If set, the provider will not discover tenants from Axon Server's Admin API.
+ *
+ * @param preDefinedContexts A comma-separated list of context names.
+ * @return This builder for chaining.
+ */
+ public Builder preDefinedContexts(String preDefinedContexts) {
+ this.preDefinedContexts = preDefinedContexts;
+ return this;
+ }
+
+ /**
+ * Sets the predicate used to filter which contexts should be registered as tenants.
+ * Defaults to accepting all contexts.
+ *
+ * @param tenantConnectPredicate The predicate to filter tenants.
+ * @return This builder for chaining.
+ */
+ public Builder tenantConnectPredicate(TenantConnectPredicate tenantConnectPredicate) {
+ this.tenantConnectPredicate = tenantConnectPredicate;
+ return this;
+ }
+
+ /**
+ * Sets the {@link AxonServerConnectionManager} used to connect to Axon Server
+ * and retrieve context information.
+ *
+ * @param axonServerConnectionManager The connection manager.
+ * @return This builder for chaining.
+ */
+ public Builder axonServerConnectionManager(AxonServerConnectionManager axonServerConnectionManager) {
+ this.axonServerConnectionManager = axonServerConnectionManager;
+ return this;
+ }
+
+ /**
+ * Builds the {@link AxonServerTenantProvider} with the configured settings.
+ *
+ * @return A new AxonServerTenantProvider instance.
+ */
+ public AxonServerTenantProvider build() {
+ if (axonServerConnectionManager == null) {
+ throw new IllegalStateException("AxonServerConnectionManager is required");
+ }
+ return new AxonServerTenantProvider(this);
+ }
+ }
}
diff --git a/multitenancy-axon-server-connector/src/main/java/org/axonframework/extension/multitenancy/axonserver/DistributedMultiTenancyConfigurationDefaults.java b/multitenancy-axon-server-connector/src/main/java/org/axonframework/extension/multitenancy/axonserver/DistributedMultiTenancyConfigurationDefaults.java
new file mode 100644
index 0000000..de9c691
--- /dev/null
+++ b/multitenancy-axon-server-connector/src/main/java/org/axonframework/extension/multitenancy/axonserver/DistributedMultiTenancyConfigurationDefaults.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.axonserver;
+
+import jakarta.annotation.Nonnull;
+import org.axonframework.axonserver.connector.AxonServerConnectionManager;
+import org.axonframework.common.configuration.ComponentDefinition;
+import org.axonframework.common.configuration.ComponentRegistry;
+import org.axonframework.common.configuration.Configuration;
+import org.axonframework.common.configuration.ConfigurationEnhancer;
+import org.axonframework.common.configuration.SearchScope;
+import org.axonframework.common.lifecycle.Phase;
+import org.axonframework.extension.multitenancy.core.MultiTenantAwareComponent;
+import org.axonframework.extension.multitenancy.core.TenantConnectPredicate;
+import org.axonframework.extension.multitenancy.core.TenantProvider;
+import org.axonframework.extension.multitenancy.core.configuration.MultiTenancyConfigurationDefaults;
+import org.axonframework.extension.multitenancy.eventsourcing.eventstore.TenantEventSegmentFactory;
+import org.axonframework.messaging.commandhandling.distributed.CommandBusConnector;
+import org.axonframework.messaging.queryhandling.distributed.QueryBusConnector;
+
+/**
+ * A {@link ConfigurationEnhancer} that provides default multi-tenancy components for distributed
+ * deployments using Axon Server. This enhancer is auto-discovered via SPI when the module is on
+ * the classpath.
+ *
+ * This enhancer implements the wrapping pattern: rather than creating per-tenant
+ * {@code DistributedCommandBus} and {@code DistributedQueryBus} instances, it provides multi-tenant
+ * connectors that wrap the standard framework infrastructure. This ensures all framework decorators
+ * (such as {@code PayloadConvertingCommandBusConnector}) are automatically applied.
+ *
+ * When activated, this enhancer registers:
+ *
+ *
{@link AxonServerTenantProvider} as the {@link TenantProvider} - discovers tenants from Axon Server contexts
+ *
{@link MultiTenantAxonServerCommandBusConnector} as the {@link CommandBusConnector} - routes commands to tenant-specific connectors
+ *
{@link MultiTenantAxonServerQueryBusConnector} as the {@link QueryBusConnector} - routes queries to tenant-specific connectors
+ *
{@link AxonServerTenantEventSegmentFactory} as the {@link TenantEventSegmentFactory} - creates per-tenant event stores
+ *
+ *
+ * The multi-tenant connectors are subscribed to the {@link TenantProvider} during the
+ * {@link Phase#INSTRUCTION_COMPONENTS} phase, enabling them to receive tenant lifecycle events
+ * and create/destroy tenant-specific connectors dynamically.
+ *
+ * The enhancer only registers components if:
+ *
+ *
An {@link AxonServerConnectionManager} is available in the configuration
+ *
A {@link org.axonframework.extension.multitenancy.core.TargetTenantResolver TargetTenantResolver} is available
+ *
No other implementation has been explicitly registered for that component type
+ *
+ *
+ * @author Theo Emanuelsson
+ * @since 5.0.0
+ * @see AxonServerTenantProvider
+ * @see MultiTenantAxonServerCommandBusConnector
+ * @see MultiTenantAxonServerQueryBusConnector
+ * @see AxonServerTenantEventSegmentFactory
+ */
+public class DistributedMultiTenancyConfigurationDefaults implements ConfigurationEnhancer {
+
+ /**
+ * The order of this enhancer. Runs just before {@code MultiTenancyConfigurationDefaults}
+ * to register Axon Server-specific components that override the embedded defaults.
+ *
+ * Both enhancers use {@code registerIfNotPresent()} - whichever runs first wins.
+ * This ensures Axon Server implementations take precedence when on the classpath.
+ */
+ public static final int ENHANCER_ORDER = MultiTenancyConfigurationDefaults.ENHANCER_ORDER - 5;
+
+ @Override
+ public void enhance(@Nonnull ComponentRegistry registry) {
+ registry.registerIfNotPresent(tenantProviderDefinition(), SearchScope.ALL);
+ registry.registerIfNotPresent(commandBusConnectorDefinition(), SearchScope.ALL);
+ registry.registerIfNotPresent(queryBusConnectorDefinition(), SearchScope.ALL);
+ registry.registerIfNotPresent(eventSegmentFactoryDefinition(), SearchScope.ALL);
+ }
+
+ private static ComponentDefinition tenantProviderDefinition() {
+ return ComponentDefinition.ofType(TenantProvider.class)
+ .withBuilder(config -> {
+ // Only build if AxonServerConnectionManager is available
+ return config.getOptionalComponent(AxonServerConnectionManager.class)
+ .map(connectionManager -> AxonServerTenantProvider.builder()
+ .axonServerConnectionManager(connectionManager)
+ .tenantConnectPredicate(
+ config.getComponent(
+ TenantConnectPredicate.class,
+ () -> tenant -> true
+ )
+ )
+ .build())
+ .orElse(null);
+ })
+ .onStart(Phase.INSTRUCTION_COMPONENTS + 10,
+ provider -> ((AxonServerTenantProvider) provider).start())
+ .onShutdown(Phase.INSTRUCTION_COMPONENTS + 10,
+ provider -> ((AxonServerTenantProvider) provider).shutdown());
+ }
+
+ private static ComponentDefinition commandBusConnectorDefinition() {
+ return ComponentDefinition.ofType(CommandBusConnector.class)
+ .withBuilder(MultiTenantAxonServerCommandBusConnector::createFrom)
+ .onStart(Phase.INSTRUCTION_COMPONENTS,
+ (Configuration config, CommandBusConnector connector) ->
+ subscribeToTenantProvider(config, connector))
+ .onShutdown(Phase.OUTBOUND_COMMAND_CONNECTORS,
+ connector -> ((MultiTenantAxonServerCommandBusConnector) connector).shutdown());
+ }
+
+ private static ComponentDefinition queryBusConnectorDefinition() {
+ return ComponentDefinition.ofType(QueryBusConnector.class)
+ .withBuilder(MultiTenantAxonServerQueryBusConnector::createFrom)
+ .onStart(Phase.INSTRUCTION_COMPONENTS,
+ (Configuration config, QueryBusConnector connector) ->
+ subscribeToTenantProvider(config, connector))
+ .onShutdown(Phase.OUTBOUND_QUERY_CONNECTORS,
+ connector -> ((MultiTenantAxonServerQueryBusConnector) connector).shutdown());
+ }
+
+ private static void subscribeToTenantProvider(Configuration config, Object connector) {
+ if (connector instanceof MultiTenantAwareComponent multiTenantConnector) {
+ config.getOptionalComponent(TenantProvider.class)
+ .ifPresent(provider -> provider.subscribe(multiTenantConnector));
+ }
+ }
+
+ private static ComponentDefinition eventSegmentFactoryDefinition() {
+ return ComponentDefinition.ofType(TenantEventSegmentFactory.class)
+ .withBuilder(AxonServerTenantEventSegmentFactory::createFrom);
+ }
+
+ @Override
+ public int order() {
+ return ENHANCER_ORDER;
+ }
+}
diff --git a/multitenancy-axon-server-connector/src/main/java/org/axonframework/extension/multitenancy/axonserver/MultiTenantAxonServerCommandBusConnector.java b/multitenancy-axon-server-connector/src/main/java/org/axonframework/extension/multitenancy/axonserver/MultiTenantAxonServerCommandBusConnector.java
new file mode 100644
index 0000000..a1ad10f
--- /dev/null
+++ b/multitenancy-axon-server-connector/src/main/java/org/axonframework/extension/multitenancy/axonserver/MultiTenantAxonServerCommandBusConnector.java
@@ -0,0 +1,332 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.axonserver;
+
+import io.axoniq.axonserver.connector.AxonServerConnection;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.axonframework.axonserver.connector.AxonServerConfiguration;
+import org.axonframework.axonserver.connector.AxonServerConnectionManager;
+import org.axonframework.axonserver.connector.command.AxonServerCommandBusConnector;
+import org.axonframework.common.AxonConfigurationException;
+import org.axonframework.common.Registration;
+import org.axonframework.common.configuration.Configuration;
+import org.axonframework.common.infra.ComponentDescriptor;
+import org.axonframework.extension.multitenancy.core.MultiTenantAwareComponent;
+import org.axonframework.extension.multitenancy.core.NoSuchTenantException;
+import org.axonframework.extension.multitenancy.core.TargetTenantResolver;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.messaging.commandhandling.CommandMessage;
+import org.axonframework.messaging.commandhandling.CommandResultMessage;
+import org.axonframework.messaging.commandhandling.distributed.CommandBusConnector;
+import org.axonframework.messaging.core.Message;
+import org.axonframework.messaging.core.QualifiedName;
+import org.axonframework.messaging.core.unitofwork.ProcessingContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+
+import static org.axonframework.common.BuilderUtils.assertNonNull;
+
+/**
+ * A multi-tenant implementation of {@link CommandBusConnector} that routes commands to tenant-specific
+ * {@link AxonServerCommandBusConnector} instances.
+ *
+ * This connector implements the wrapping pattern: rather than creating per-tenant {@code DistributedCommandBus}
+ * instances, it wraps the standard framework infrastructure by providing a single connector that internally
+ * routes to tenant-specific Axon Server connections. This ensures all framework decorators (such as
+ * {@code PayloadConvertingCommandBusConnector}) are applied automatically.
+ *
+ * The connector manages tenant-specific connectors in response to tenant lifecycle events via the
+ * {@link MultiTenantAwareComponent} interface. When a tenant is registered, a new
+ * {@link AxonServerCommandBusConnector} is created for that tenant's context. Existing command subscriptions
+ * are automatically replayed to the new connector.
+ *
+ *
+ * @author Theo Emanuelsson
+ * @since 5.0.0
+ * @see CommandBusConnector
+ * @see MultiTenantAwareComponent
+ * @see AxonServerCommandBusConnector
+ */
+public class MultiTenantAxonServerCommandBusConnector implements CommandBusConnector, MultiTenantAwareComponent {
+
+ private static final Logger logger = LoggerFactory.getLogger(MultiTenantAxonServerCommandBusConnector.class);
+
+ private final AxonServerConnectionManager connectionManager;
+ private final AxonServerConfiguration axonServerConfiguration;
+ private final TargetTenantResolver targetTenantResolver;
+
+ private final Map connectors = new ConcurrentHashMap<>();
+ private final Map subscriptions = new ConcurrentHashMap<>();
+
+ private volatile Handler incomingHandler;
+
+ /**
+ * Instantiate a {@link MultiTenantAxonServerCommandBusConnector} based on the given {@link Builder builder}.
+ *
+ * @param builder The {@link Builder} used to instantiate a {@link MultiTenantAxonServerCommandBusConnector}.
+ */
+ protected MultiTenantAxonServerCommandBusConnector(Builder builder) {
+ builder.validate();
+ this.connectionManager = builder.connectionManager;
+ this.axonServerConfiguration = builder.axonServerConfiguration;
+ this.targetTenantResolver = builder.targetTenantResolver;
+ }
+
+ /**
+ * Creates a {@link MultiTenantAxonServerCommandBusConnector} from the given {@link Configuration}.
+ *
+ * @param config The Axon {@link Configuration} to obtain components from.
+ * @return A new connector instance, or {@code null} if required components are not available.
+ */
+ @Nullable
+ public static MultiTenantAxonServerCommandBusConnector createFrom(@Nonnull Configuration config) {
+ return config.getOptionalComponent(AxonServerConnectionManager.class)
+ .flatMap(connectionManager -> config.getOptionalComponent(TargetTenantResolver.class)
+ .map(resolver -> MultiTenantAxonServerCommandBusConnector.builder()
+ .connectionManager(connectionManager)
+ .axonServerConfiguration(config.getComponent(AxonServerConfiguration.class))
+ .targetTenantResolver(resolver)
+ .build()))
+ .orElse(null);
+ }
+
+ /**
+ * Instantiate a builder to construct a {@link MultiTenantAxonServerCommandBusConnector}.
+ *
+ * @return A Builder to create a {@link MultiTenantAxonServerCommandBusConnector}.
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ @Nonnull
+ @Override
+ public CompletableFuture dispatch(@Nonnull CommandMessage command,
+ @Nullable ProcessingContext processingContext) {
+ TenantDescriptor tenant = targetTenantResolver.resolveTenant(command, connectors.keySet());
+ AxonServerCommandBusConnector connector = connectors.get(tenant);
+ if (connector == null) {
+ return CompletableFuture.failedFuture(NoSuchTenantException.forTenantId(tenant.tenantId()));
+ }
+ return connector.dispatch(command, processingContext);
+ }
+
+ @Override
+ public CompletableFuture subscribe(@Nonnull QualifiedName commandName, int loadFactor) {
+ logger.debug("Subscribing to command [{}] with load factor [{}] across all tenants", commandName, loadFactor);
+ subscriptions.put(commandName, loadFactor);
+
+ if (connectors.isEmpty()) {
+ // No tenants registered yet; subscription will be replayed when tenants join
+ return CompletableFuture.completedFuture(null);
+ }
+
+ return CompletableFuture.allOf(
+ connectors.values().stream()
+ .map(connector -> connector.subscribe(commandName, loadFactor))
+ .toArray(CompletableFuture[]::new)
+ );
+ }
+
+ @Override
+ public boolean unsubscribe(@Nonnull QualifiedName commandName) {
+ subscriptions.remove(commandName);
+
+ boolean allUnsubscribed = true;
+ for (AxonServerCommandBusConnector connector : connectors.values()) {
+ if (!connector.unsubscribe(commandName)) {
+ allUnsubscribed = false;
+ }
+ }
+ return allUnsubscribed;
+ }
+
+ @Override
+ public void onIncomingCommand(@Nonnull Handler handler) {
+ this.incomingHandler = handler;
+ connectors.values().forEach(connector -> connector.onIncomingCommand(handler));
+ }
+
+ @Override
+ public Registration registerTenant(TenantDescriptor tenantDescriptor) {
+ logger.debug("Registering tenant [{}] for command routing", tenantDescriptor.tenantId());
+
+ AxonServerConnection connection = connectionManager.getConnection(tenantDescriptor.tenantId());
+ AxonServerCommandBusConnector connector = new AxonServerCommandBusConnector(
+ connection,
+ axonServerConfiguration
+ );
+ connectors.put(tenantDescriptor, connector);
+
+ // Set incoming handler if already configured
+ if (incomingHandler != null) {
+ connector.onIncomingCommand(incomingHandler);
+ }
+
+ connector.start();
+
+ return () -> {
+ logger.debug("Unregistering tenant [{}] from command routing", tenantDescriptor.tenantId());
+ AxonServerCommandBusConnector removed = connectors.remove(tenantDescriptor);
+ if (removed != null) {
+ removed.disconnect()
+ .thenCompose(v -> removed.shutdownDispatching())
+ .join();
+ }
+ return removed != null;
+ };
+ }
+
+ @Override
+ public Registration registerAndStartTenant(TenantDescriptor tenantDescriptor) {
+ Registration registration = registerTenant(tenantDescriptor);
+
+ // Replay existing subscriptions to the new tenant connector
+ AxonServerCommandBusConnector connector = connectors.get(tenantDescriptor);
+ if (connector != null) {
+ subscriptions.forEach((commandName, loadFactor) -> {
+ logger.debug("Replaying subscription [{}] to tenant [{}]",
+ commandName, tenantDescriptor.tenantId());
+ connector.subscribe(commandName, loadFactor);
+ });
+ }
+
+ return registration;
+ }
+
+ /**
+ * Shuts down all tenant connectors.
+ *
+ * @return A {@link CompletableFuture} that completes when all connectors have been shut down.
+ */
+ public CompletableFuture shutdown() {
+ logger.debug("Shutting down {} command bus connectors", connectors.size());
+ return CompletableFuture.allOf(
+ connectors.values().stream()
+ .map(connector -> connector.disconnect()
+ .thenCompose(v -> connector.shutdownDispatching()))
+ .toArray(CompletableFuture[]::new)
+ );
+ }
+
+ /**
+ * Returns the tenant-specific connectors managed by this multi-tenant connector.
+ * Primarily for testing purposes.
+ *
+ * @return An unmodifiable view of the tenant-to-connector mapping.
+ */
+ Map connectors() {
+ return Collections.unmodifiableMap(connectors);
+ }
+
+ @Override
+ public void describeTo(@Nonnull ComponentDescriptor descriptor) {
+ descriptor.describeProperty("connectionManager", connectionManager);
+ descriptor.describeProperty("tenants", connectors.keySet());
+ descriptor.describeProperty("subscriptions", subscriptions.keySet());
+ }
+
+ /**
+ * Builder class to instantiate a {@link MultiTenantAxonServerCommandBusConnector}.
+ *
+ * The {@link AxonServerConnectionManager}, {@link AxonServerConfiguration}, and
+ * {@link TargetTenantResolver} are hard requirements and must be provided.
+ */
+ public static class Builder {
+
+ private AxonServerConnectionManager connectionManager;
+ private AxonServerConfiguration axonServerConfiguration;
+ private TargetTenantResolver targetTenantResolver;
+
+ /**
+ * Sets the {@link AxonServerConnectionManager} used to obtain tenant-specific connections.
+ *
+ * @param connectionManager The connection manager providing Axon Server connections per tenant context.
+ * @return The current builder instance, for fluent interfacing.
+ */
+ public Builder connectionManager(@Nonnull AxonServerConnectionManager connectionManager) {
+ assertNonNull(connectionManager, "AxonServerConnectionManager may not be null");
+ this.connectionManager = connectionManager;
+ return this;
+ }
+
+ /**
+ * Sets the {@link AxonServerConfiguration} for Axon Server settings.
+ *
+ * @param axonServerConfiguration The Axon Server configuration.
+ * @return The current builder instance, for fluent interfacing.
+ */
+ public Builder axonServerConfiguration(@Nonnull AxonServerConfiguration axonServerConfiguration) {
+ assertNonNull(axonServerConfiguration, "AxonServerConfiguration may not be null");
+ this.axonServerConfiguration = axonServerConfiguration;
+ return this;
+ }
+
+ /**
+ * Sets the {@link TargetTenantResolver} used to resolve which tenant a command belongs to.
+ *
+ * @param targetTenantResolver The resolver that determines the target tenant for each command.
+ * @return The current builder instance, for fluent interfacing.
+ */
+ @SuppressWarnings("unchecked")
+ public Builder targetTenantResolver(@Nonnull TargetTenantResolver extends Message> targetTenantResolver) {
+ assertNonNull(targetTenantResolver, "TargetTenantResolver may not be null");
+ this.targetTenantResolver = (TargetTenantResolver) targetTenantResolver;
+ return this;
+ }
+
+ /**
+ * Initializes a {@link MultiTenantAxonServerCommandBusConnector} as specified through this Builder.
+ *
+ * @return A {@link MultiTenantAxonServerCommandBusConnector} as specified through this Builder.
+ */
+ public MultiTenantAxonServerCommandBusConnector build() {
+ return new MultiTenantAxonServerCommandBusConnector(this);
+ }
+
+ /**
+ * Validate whether the fields contained in this Builder are set accordingly.
+ *
+ * @throws AxonConfigurationException If one field is asserted to be incorrect according to the Builder's
+ * specifications.
+ */
+ protected void validate() {
+ assertNonNull(connectionManager,
+ "The AxonServerConnectionManager is a hard requirement and should be provided");
+ assertNonNull(axonServerConfiguration,
+ "The AxonServerConfiguration is a hard requirement and should be provided");
+ assertNonNull(targetTenantResolver,
+ "The TargetTenantResolver is a hard requirement and should be provided");
+ }
+ }
+}
diff --git a/multitenancy-axon-server-connector/src/main/java/org/axonframework/extension/multitenancy/axonserver/MultiTenantAxonServerQueryBusConnector.java b/multitenancy-axon-server-connector/src/main/java/org/axonframework/extension/multitenancy/axonserver/MultiTenantAxonServerQueryBusConnector.java
new file mode 100644
index 0000000..bc86c1a
--- /dev/null
+++ b/multitenancy-axon-server-connector/src/main/java/org/axonframework/extension/multitenancy/axonserver/MultiTenantAxonServerQueryBusConnector.java
@@ -0,0 +1,347 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.axonserver;
+
+import io.axoniq.axonserver.connector.AxonServerConnection;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.axonframework.axonserver.connector.AxonServerConfiguration;
+import org.axonframework.axonserver.connector.AxonServerConnectionManager;
+import org.axonframework.axonserver.connector.query.AxonServerQueryBusConnector;
+import org.axonframework.common.AxonConfigurationException;
+import org.axonframework.common.Registration;
+import org.axonframework.common.configuration.Configuration;
+import org.axonframework.common.infra.ComponentDescriptor;
+import org.axonframework.extension.multitenancy.core.MultiTenantAwareComponent;
+import org.axonframework.extension.multitenancy.core.NoSuchTenantException;
+import org.axonframework.extension.multitenancy.core.TargetTenantResolver;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.messaging.core.Message;
+import org.axonframework.messaging.core.MessageStream;
+import org.axonframework.messaging.core.QualifiedName;
+import org.axonframework.messaging.core.unitofwork.ProcessingContext;
+import org.axonframework.messaging.queryhandling.QueryMessage;
+import org.axonframework.messaging.queryhandling.QueryResponseMessage;
+import org.axonframework.messaging.queryhandling.distributed.QueryBusConnector;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+
+import static org.axonframework.common.BuilderUtils.assertNonNull;
+
+/**
+ * A multi-tenant implementation of {@link QueryBusConnector} that routes queries to tenant-specific
+ * {@link AxonServerQueryBusConnector} instances.
+ *
+ * This connector implements the wrapping pattern: rather than creating per-tenant {@code DistributedQueryBus}
+ * instances, it wraps the standard framework infrastructure by providing a single connector that internally
+ * routes to tenant-specific Axon Server connections. This ensures all framework decorators (such as
+ * {@code PayloadConvertingQueryBusConnector}) are applied automatically.
+ *
+ * The connector manages tenant-specific connectors in response to tenant lifecycle events via the
+ * {@link MultiTenantAwareComponent} interface. When a tenant is registered, a new
+ * {@link AxonServerQueryBusConnector} is created for that tenant's context. Existing query subscriptions
+ * are automatically replayed to the new connector.
+ *
+ *
+ * @author Theo Emanuelsson
+ * @since 5.0.0
+ * @see QueryBusConnector
+ * @see MultiTenantAwareComponent
+ * @see AxonServerQueryBusConnector
+ */
+public class MultiTenantAxonServerQueryBusConnector implements QueryBusConnector, MultiTenantAwareComponent {
+
+ private static final Logger logger = LoggerFactory.getLogger(MultiTenantAxonServerQueryBusConnector.class);
+
+ private final AxonServerConnectionManager connectionManager;
+ private final AxonServerConfiguration axonServerConfiguration;
+ private final TargetTenantResolver targetTenantResolver;
+
+ private final Map connectors = new ConcurrentHashMap<>();
+ private final Set subscriptions = ConcurrentHashMap.newKeySet();
+
+ private volatile Handler incomingHandler;
+
+ /**
+ * Instantiate a {@link MultiTenantAxonServerQueryBusConnector} based on the given {@link Builder builder}.
+ *
+ * @param builder The {@link Builder} used to instantiate a {@link MultiTenantAxonServerQueryBusConnector}.
+ */
+ protected MultiTenantAxonServerQueryBusConnector(Builder builder) {
+ builder.validate();
+ this.connectionManager = builder.connectionManager;
+ this.axonServerConfiguration = builder.axonServerConfiguration;
+ this.targetTenantResolver = builder.targetTenantResolver;
+ }
+
+ /**
+ * Creates a {@link MultiTenantAxonServerQueryBusConnector} from the given {@link Configuration}.
+ *
+ * @param config The Axon {@link Configuration} to obtain components from.
+ * @return A new connector instance, or {@code null} if required components are not available.
+ */
+ @Nullable
+ public static MultiTenantAxonServerQueryBusConnector createFrom(@Nonnull Configuration config) {
+ return config.getOptionalComponent(AxonServerConnectionManager.class)
+ .flatMap(connectionManager -> config.getOptionalComponent(TargetTenantResolver.class)
+ .map(resolver -> MultiTenantAxonServerQueryBusConnector.builder()
+ .connectionManager(connectionManager)
+ .axonServerConfiguration(config.getComponent(AxonServerConfiguration.class))
+ .targetTenantResolver(resolver)
+ .build()))
+ .orElse(null);
+ }
+
+ /**
+ * Instantiate a builder to construct a {@link MultiTenantAxonServerQueryBusConnector}.
+ *
+ * @return A Builder to create a {@link MultiTenantAxonServerQueryBusConnector}.
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ @Nonnull
+ @Override
+ public MessageStream query(@Nonnull QueryMessage query,
+ @Nullable ProcessingContext context) {
+ TenantDescriptor tenant = targetTenantResolver.resolveTenant(query, connectors.keySet());
+ AxonServerQueryBusConnector connector = connectors.get(tenant);
+ if (connector == null) {
+ return MessageStream.failed(NoSuchTenantException.forTenantId(tenant.tenantId()));
+ }
+ return connector.query(query, context);
+ }
+
+ @Nonnull
+ @Override
+ public MessageStream subscriptionQuery(@Nonnull QueryMessage query,
+ @Nullable ProcessingContext context,
+ int updateBufferSize) {
+ TenantDescriptor tenant = targetTenantResolver.resolveTenant(query, connectors.keySet());
+ AxonServerQueryBusConnector connector = connectors.get(tenant);
+ if (connector == null) {
+ return MessageStream.failed(NoSuchTenantException.forTenantId(tenant.tenantId()));
+ }
+ return connector.subscriptionQuery(query, context, updateBufferSize);
+ }
+
+ @Override
+ public CompletableFuture subscribe(@Nonnull QualifiedName name) {
+ logger.debug("Subscribing to query [{}] across all tenants", name);
+ subscriptions.add(name);
+
+ if (connectors.isEmpty()) {
+ // No tenants registered yet; subscription will be replayed when tenants join
+ return CompletableFuture.completedFuture(null);
+ }
+
+ return CompletableFuture.allOf(
+ connectors.values().stream()
+ .map(connector -> connector.subscribe(name))
+ .toArray(CompletableFuture[]::new)
+ );
+ }
+
+ @Override
+ public boolean unsubscribe(@Nonnull QualifiedName name) {
+ subscriptions.remove(name);
+
+ boolean allUnsubscribed = true;
+ for (AxonServerQueryBusConnector connector : connectors.values()) {
+ if (!connector.unsubscribe(name)) {
+ allUnsubscribed = false;
+ }
+ }
+ return allUnsubscribed;
+ }
+
+ @Override
+ public void onIncomingQuery(@Nonnull Handler handler) {
+ this.incomingHandler = handler;
+ connectors.values().forEach(connector -> connector.onIncomingQuery(handler));
+ }
+
+ @Override
+ public Registration registerTenant(TenantDescriptor tenantDescriptor) {
+ logger.debug("Registering tenant [{}] for query routing", tenantDescriptor.tenantId());
+
+ AxonServerConnection connection = connectionManager.getConnection(tenantDescriptor.tenantId());
+ AxonServerQueryBusConnector connector = new AxonServerQueryBusConnector(
+ connection,
+ axonServerConfiguration
+ );
+ connectors.put(tenantDescriptor, connector);
+
+ // Set incoming handler if already configured
+ if (incomingHandler != null) {
+ connector.onIncomingQuery(incomingHandler);
+ }
+
+ connector.start();
+
+ return () -> {
+ logger.debug("Unregistering tenant [{}] from query routing", tenantDescriptor.tenantId());
+ AxonServerQueryBusConnector removed = connectors.remove(tenantDescriptor);
+ if (removed != null) {
+ removed.disconnect()
+ .thenCompose(v -> removed.shutdownDispatching())
+ .join();
+ }
+ return removed != null;
+ };
+ }
+
+ @Override
+ public Registration registerAndStartTenant(TenantDescriptor tenantDescriptor) {
+ Registration registration = registerTenant(tenantDescriptor);
+
+ // Replay existing subscriptions to the new tenant connector
+ AxonServerQueryBusConnector connector = connectors.get(tenantDescriptor);
+ if (connector != null) {
+ subscriptions.forEach(queryName -> {
+ logger.debug("Replaying subscription [{}] to tenant [{}]",
+ queryName, tenantDescriptor.tenantId());
+ connector.subscribe(queryName);
+ });
+ }
+
+ return registration;
+ }
+
+ /**
+ * Shuts down all tenant connectors.
+ *
+ * @return A {@link CompletableFuture} that completes when all connectors have been shut down.
+ */
+ public CompletableFuture shutdown() {
+ logger.debug("Shutting down {} query bus connectors", connectors.size());
+ return CompletableFuture.allOf(
+ connectors.values().stream()
+ .map(connector -> connector.disconnect()
+ .thenCompose(v -> connector.shutdownDispatching()))
+ .toArray(CompletableFuture[]::new)
+ );
+ }
+
+ /**
+ * Returns the tenant-specific connectors managed by this multi-tenant connector.
+ * Primarily for testing purposes.
+ *
+ * @return An unmodifiable view of the tenant-to-connector mapping.
+ */
+ Map connectors() {
+ return Collections.unmodifiableMap(connectors);
+ }
+
+ @Override
+ public void describeTo(@Nonnull ComponentDescriptor descriptor) {
+ descriptor.describeProperty("connectionManager", connectionManager);
+ descriptor.describeProperty("tenants", connectors.keySet());
+ descriptor.describeProperty("subscriptions", subscriptions);
+ }
+
+ /**
+ * Builder class to instantiate a {@link MultiTenantAxonServerQueryBusConnector}.
+ *
+ * The {@link AxonServerConnectionManager}, {@link AxonServerConfiguration}, and
+ * {@link TargetTenantResolver} are hard requirements and must be provided.
+ */
+ public static class Builder {
+
+ private AxonServerConnectionManager connectionManager;
+ private AxonServerConfiguration axonServerConfiguration;
+ private TargetTenantResolver targetTenantResolver;
+
+ /**
+ * Sets the {@link AxonServerConnectionManager} used to obtain tenant-specific connections.
+ *
+ * @param connectionManager The connection manager providing Axon Server connections per tenant context.
+ * @return The current builder instance, for fluent interfacing.
+ */
+ public Builder connectionManager(@Nonnull AxonServerConnectionManager connectionManager) {
+ assertNonNull(connectionManager, "AxonServerConnectionManager may not be null");
+ this.connectionManager = connectionManager;
+ return this;
+ }
+
+ /**
+ * Sets the {@link AxonServerConfiguration} for Axon Server settings.
+ *
+ * @param axonServerConfiguration The Axon Server configuration.
+ * @return The current builder instance, for fluent interfacing.
+ */
+ public Builder axonServerConfiguration(@Nonnull AxonServerConfiguration axonServerConfiguration) {
+ assertNonNull(axonServerConfiguration, "AxonServerConfiguration may not be null");
+ this.axonServerConfiguration = axonServerConfiguration;
+ return this;
+ }
+
+ /**
+ * Sets the {@link TargetTenantResolver} used to resolve which tenant a query belongs to.
+ *
+ * @param targetTenantResolver The resolver that determines the target tenant for each query.
+ * @return The current builder instance, for fluent interfacing.
+ */
+ @SuppressWarnings("unchecked")
+ public Builder targetTenantResolver(@Nonnull TargetTenantResolver extends Message> targetTenantResolver) {
+ assertNonNull(targetTenantResolver, "TargetTenantResolver may not be null");
+ this.targetTenantResolver = (TargetTenantResolver) targetTenantResolver;
+ return this;
+ }
+
+ /**
+ * Initializes a {@link MultiTenantAxonServerQueryBusConnector} as specified through this Builder.
+ *
+ * @return A {@link MultiTenantAxonServerQueryBusConnector} as specified through this Builder.
+ */
+ public MultiTenantAxonServerQueryBusConnector build() {
+ return new MultiTenantAxonServerQueryBusConnector(this);
+ }
+
+ /**
+ * Validate whether the fields contained in this Builder are set accordingly.
+ *
+ * @throws AxonConfigurationException If one field is asserted to be incorrect according to the Builder's
+ * specifications.
+ */
+ protected void validate() {
+ assertNonNull(connectionManager,
+ "The AxonServerConnectionManager is a hard requirement and should be provided");
+ assertNonNull(axonServerConfiguration,
+ "The AxonServerConfiguration is a hard requirement and should be provided");
+ assertNonNull(targetTenantResolver,
+ "The TargetTenantResolver is a hard requirement and should be provided");
+ }
+ }
+}
diff --git a/multitenancy-axon-server-connector/src/main/resources/META-INF/services/org.axonframework.common.configuration.ConfigurationEnhancer b/multitenancy-axon-server-connector/src/main/resources/META-INF/services/org.axonframework.common.configuration.ConfigurationEnhancer
new file mode 100644
index 0000000..1007688
--- /dev/null
+++ b/multitenancy-axon-server-connector/src/main/resources/META-INF/services/org.axonframework.common.configuration.ConfigurationEnhancer
@@ -0,0 +1 @@
+org.axonframework.extension.multitenancy.axonserver.DistributedMultiTenancyConfigurationDefaults
diff --git a/multitenancy-axon-server-connector/src/test/java/org/axonframework/extension/multitenancy/axonserver/AxonServerTenantEventSegmentFactoryTest.java b/multitenancy-axon-server-connector/src/test/java/org/axonframework/extension/multitenancy/axonserver/AxonServerTenantEventSegmentFactoryTest.java
new file mode 100644
index 0000000..0a7e415
--- /dev/null
+++ b/multitenancy-axon-server-connector/src/test/java/org/axonframework/extension/multitenancy/axonserver/AxonServerTenantEventSegmentFactoryTest.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.axonserver;
+
+import org.axonframework.axonserver.connector.AxonServerConnectionManager;
+import org.axonframework.common.configuration.Configuration;
+import org.axonframework.eventsourcing.eventstore.AnnotationBasedTagResolver;
+import org.axonframework.eventsourcing.eventstore.TagResolver;
+import org.junit.jupiter.api.*;
+
+import java.util.function.Supplier;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Test class validating the {@link AxonServerTenantEventSegmentFactory}.
+ *
+ * @author Stefan Dragisic
+ */
+class AxonServerTenantEventSegmentFactoryTest {
+
+ private Configuration configuration;
+ private TagResolver tagResolver;
+
+ @BeforeEach
+ void setUp() {
+ configuration = mock(Configuration.class);
+ tagResolver = new AnnotationBasedTagResolver();
+ }
+
+ @Test
+ void createFromReturnsFactoryWhenConnectionManagerAvailable() {
+ when(configuration.hasComponent(AxonServerConnectionManager.class)).thenReturn(true);
+ when(configuration.getComponent(eq(TagResolver.class), (Supplier) any())).thenReturn(tagResolver);
+
+ AxonServerTenantEventSegmentFactory result = AxonServerTenantEventSegmentFactory.createFrom(configuration);
+
+ assertNotNull(result);
+ }
+
+ @Test
+ void createFromReturnsNullWhenConnectionManagerNotAvailable() {
+ when(configuration.hasComponent(AxonServerConnectionManager.class)).thenReturn(false);
+
+ AxonServerTenantEventSegmentFactory result = AxonServerTenantEventSegmentFactory.createFrom(configuration);
+
+ assertNull(result);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void createFromUsesDefaultTagResolverWhenNotConfigured() {
+ when(configuration.hasComponent(AxonServerConnectionManager.class)).thenReturn(true);
+ when(configuration.getComponent(eq(TagResolver.class), (Supplier) any()))
+ .thenAnswer(invocation -> {
+ // Return the default from the supplier
+ return invocation.getArgument(1, Supplier.class).get();
+ });
+
+ AxonServerTenantEventSegmentFactory result = AxonServerTenantEventSegmentFactory.createFrom(configuration);
+
+ assertNotNull(result);
+ }
+
+ @Test
+ void constructorRejectsNullConfiguration() {
+ assertThrows(NullPointerException.class, () ->
+ new AxonServerTenantEventSegmentFactory(null, tagResolver)
+ );
+ }
+
+ @Test
+ void constructorRejectsNullTagResolver() {
+ assertThrows(NullPointerException.class, () ->
+ new AxonServerTenantEventSegmentFactory(configuration, null)
+ );
+ }
+}
diff --git a/multitenancy-axon-server-connector/src/test/java/org/axonframework/extension/multitenancy/axonserver/MultiTenantAxonServerCommandBusConnectorTest.java b/multitenancy-axon-server-connector/src/test/java/org/axonframework/extension/multitenancy/axonserver/MultiTenantAxonServerCommandBusConnectorTest.java
new file mode 100644
index 0000000..a2365d5
--- /dev/null
+++ b/multitenancy-axon-server-connector/src/test/java/org/axonframework/extension/multitenancy/axonserver/MultiTenantAxonServerCommandBusConnectorTest.java
@@ -0,0 +1,283 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.axonserver;
+
+import io.axoniq.axonserver.connector.AxonServerConnection;
+import io.axoniq.axonserver.connector.Registration;
+import io.axoniq.axonserver.connector.command.CommandChannel;
+import org.axonframework.axonserver.connector.AxonServerConfiguration;
+import org.axonframework.axonserver.connector.AxonServerConnectionManager;
+import org.axonframework.common.AxonConfigurationException;
+import org.axonframework.common.configuration.Configuration;
+import org.axonframework.extension.multitenancy.core.NoSuchTenantException;
+import org.axonframework.extension.multitenancy.core.TargetTenantResolver;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.messaging.commandhandling.CommandMessage;
+import org.axonframework.messaging.commandhandling.distributed.CommandBusConnector;
+import org.axonframework.messaging.core.Message;
+import org.axonframework.messaging.core.QualifiedName;
+import org.junit.jupiter.api.*;
+
+import java.util.Collection;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Test class validating the {@link MultiTenantAxonServerCommandBusConnector}.
+ *
+ * @author Stefan Dragisic
+ * @author Theo Emanuelsson
+ */
+class MultiTenantAxonServerCommandBusConnectorTest {
+
+ private AxonServerConnectionManager connectionManager;
+ private AxonServerConfiguration axonServerConfiguration;
+ private TargetTenantResolver targetTenantResolver;
+
+ private static final TenantDescriptor TENANT_1 = TenantDescriptor.tenantWithId("tenant1");
+ private static final TenantDescriptor TENANT_2 = TenantDescriptor.tenantWithId("tenant2");
+
+ @BeforeEach
+ void setUp() {
+ connectionManager = mock(AxonServerConnectionManager.class);
+ axonServerConfiguration = mock(AxonServerConfiguration.class);
+ targetTenantResolver = mock(TargetTenantResolver.class);
+
+ when(axonServerConfiguration.getClientId()).thenReturn("test-client");
+ when(axonServerConfiguration.getComponentName()).thenReturn("test-component");
+ }
+
+ @Test
+ void buildWithAllRequiredComponentsSucceeds() {
+ MultiTenantAxonServerCommandBusConnector connector = MultiTenantAxonServerCommandBusConnector.builder()
+ .connectionManager(connectionManager)
+ .axonServerConfiguration(axonServerConfiguration)
+ .targetTenantResolver(targetTenantResolver)
+ .build();
+
+ assertNotNull(connector);
+ }
+
+ @Test
+ void buildWithoutConnectionManagerFails() {
+ assertThrows(AxonConfigurationException.class, () ->
+ MultiTenantAxonServerCommandBusConnector.builder()
+ .axonServerConfiguration(axonServerConfiguration)
+ .targetTenantResolver(targetTenantResolver)
+ .build()
+ );
+ }
+
+ @Test
+ void buildWithoutAxonServerConfigurationFails() {
+ assertThrows(AxonConfigurationException.class, () ->
+ MultiTenantAxonServerCommandBusConnector.builder()
+ .connectionManager(connectionManager)
+ .targetTenantResolver(targetTenantResolver)
+ .build()
+ );
+ }
+
+ @Test
+ void buildWithoutTargetTenantResolverFails() {
+ assertThrows(AxonConfigurationException.class, () ->
+ MultiTenantAxonServerCommandBusConnector.builder()
+ .connectionManager(connectionManager)
+ .axonServerConfiguration(axonServerConfiguration)
+ .build()
+ );
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void createFromReturnsConnectorWhenAllComponentsAvailable() {
+ Configuration config = mock(Configuration.class);
+ when(config.getOptionalComponent(AxonServerConnectionManager.class))
+ .thenReturn(Optional.of(connectionManager));
+ when(config.getOptionalComponent(TargetTenantResolver.class))
+ .thenReturn(Optional.of(targetTenantResolver));
+ when(config.getComponent(AxonServerConfiguration.class))
+ .thenReturn(axonServerConfiguration);
+
+ MultiTenantAxonServerCommandBusConnector result =
+ MultiTenantAxonServerCommandBusConnector.createFrom(config);
+
+ assertNotNull(result);
+ }
+
+ @Test
+ void createFromReturnsNullWhenConnectionManagerNotAvailable() {
+ Configuration config = mock(Configuration.class);
+ when(config.getOptionalComponent(AxonServerConnectionManager.class))
+ .thenReturn(Optional.empty());
+
+ MultiTenantAxonServerCommandBusConnector result =
+ MultiTenantAxonServerCommandBusConnector.createFrom(config);
+
+ assertNull(result);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void createFromReturnsNullWhenTargetTenantResolverNotAvailable() {
+ Configuration config = mock(Configuration.class);
+ when(config.getOptionalComponent(AxonServerConnectionManager.class))
+ .thenReturn(Optional.of(connectionManager));
+ when(config.getOptionalComponent(TargetTenantResolver.class))
+ .thenReturn(Optional.empty());
+
+ MultiTenantAxonServerCommandBusConnector result =
+ MultiTenantAxonServerCommandBusConnector.createFrom(config);
+
+ assertNull(result);
+ }
+
+ @Test
+ void registerTenantAddsConnectorForTenant() {
+ AxonServerConnection tenantConnection = mockTenantConnection();
+ when(connectionManager.getConnection("tenant1")).thenReturn(tenantConnection);
+
+ MultiTenantAxonServerCommandBusConnector connector = createConnector();
+
+ assertTrue(connector.connectors().isEmpty());
+
+ var registration = connector.registerTenant(TENANT_1);
+
+ assertEquals(1, connector.connectors().size());
+ assertTrue(connector.connectors().containsKey(TENANT_1));
+ assertNotNull(registration);
+ }
+
+ @Test
+ void unregisterTenantRemovesConnector() {
+ AxonServerConnection tenantConnection = mockTenantConnection();
+ when(connectionManager.getConnection("tenant1")).thenReturn(tenantConnection);
+
+ MultiTenantAxonServerCommandBusConnector connector = createConnector();
+ var registration = connector.registerTenant(TENANT_1);
+
+ assertEquals(1, connector.connectors().size());
+
+ registration.cancel();
+
+ assertTrue(connector.connectors().isEmpty());
+ }
+
+ @Test
+ void dispatchToUnknownTenantFails() {
+ MultiTenantAxonServerCommandBusConnector connector = createConnector();
+
+ CommandMessage command = mock(CommandMessage.class);
+ when(targetTenantResolver.resolveTenant(eq(command), any(Collection.class))).thenReturn(TENANT_1);
+
+ CompletableFuture> result = connector.dispatch(command, null);
+
+ assertTrue(result.isCompletedExceptionally());
+ assertThrows(ExecutionException.class, () -> result.get());
+ try {
+ result.get();
+ } catch (ExecutionException e) {
+ assertTrue(e.getCause() instanceof NoSuchTenantException);
+ } catch (InterruptedException e) {
+ fail("Unexpected interruption");
+ }
+ }
+
+ @Test
+ void subscribeWithNoTenantsCompletesSuccessfully() {
+ MultiTenantAxonServerCommandBusConnector connector = createConnector();
+
+ QualifiedName commandName = new QualifiedName("TestCommand");
+ CompletableFuture result = connector.subscribe(commandName, 100);
+
+ assertDoesNotThrow(() -> result.get());
+ }
+
+ @Test
+ void registerAndStartTenantReplaysSubscriptions() {
+ AxonServerConnection tenantConnection = mockTenantConnection();
+ when(connectionManager.getConnection("tenant1")).thenReturn(tenantConnection);
+
+ MultiTenantAxonServerCommandBusConnector connector = createConnector();
+
+ // Subscribe before any tenants
+ QualifiedName commandName = new QualifiedName("TestCommand");
+ connector.subscribe(commandName, 100);
+
+ // Register tenant - should replay subscription
+ connector.registerAndStartTenant(TENANT_1);
+
+ // Verify tenant connector was created
+ assertEquals(1, connector.connectors().size());
+ }
+
+ @Test
+ void onIncomingCommandSetsHandlerOnAllConnectors() {
+ AxonServerConnection tenant1Connection = mockTenantConnection();
+ AxonServerConnection tenant2Connection = mockTenantConnection();
+ when(connectionManager.getConnection("tenant1")).thenReturn(tenant1Connection);
+ when(connectionManager.getConnection("tenant2")).thenReturn(tenant2Connection);
+
+ MultiTenantAxonServerCommandBusConnector connector = createConnector();
+ connector.registerTenant(TENANT_1);
+ connector.registerTenant(TENANT_2);
+
+ CommandBusConnector.Handler handler = mock(CommandBusConnector.Handler.class);
+ connector.onIncomingCommand(handler);
+
+ // Handler should be set on all tenant connectors
+ assertEquals(2, connector.connectors().size());
+ }
+
+ @Test
+ void shutdownCompletesSuccessfully() {
+ AxonServerConnection tenantConnection = mockTenantConnection();
+ when(connectionManager.getConnection("tenant1")).thenReturn(tenantConnection);
+
+ MultiTenantAxonServerCommandBusConnector connector = createConnector();
+ connector.registerTenant(TENANT_1);
+
+ CompletableFuture result = connector.shutdown();
+
+ assertDoesNotThrow(() -> result.get());
+ }
+
+ private MultiTenantAxonServerCommandBusConnector createConnector() {
+ return MultiTenantAxonServerCommandBusConnector.builder()
+ .connectionManager(connectionManager)
+ .axonServerConfiguration(axonServerConfiguration)
+ .targetTenantResolver(targetTenantResolver)
+ .build();
+ }
+
+ private AxonServerConnection mockTenantConnection() {
+ AxonServerConnection connection = mock(AxonServerConnection.class);
+ CommandChannel commandChannel = mock(CommandChannel.class);
+
+ when(connection.commandChannel()).thenReturn(commandChannel);
+ when(connection.isConnected()).thenReturn(false);
+ when(commandChannel.prepareDisconnect()).thenReturn(CompletableFuture.completedFuture(null));
+ when(commandChannel.registerCommandHandler(any(), anyInt(), any()))
+ .thenReturn(mock(Registration.class));
+
+ return connection;
+ }
+}
diff --git a/multitenancy-axon-server-connector/src/test/java/org/axonframework/extension/multitenancy/axonserver/MultiTenantAxonServerQueryBusConnectorTest.java b/multitenancy-axon-server-connector/src/test/java/org/axonframework/extension/multitenancy/axonserver/MultiTenantAxonServerQueryBusConnectorTest.java
new file mode 100644
index 0000000..96fb9e6
--- /dev/null
+++ b/multitenancy-axon-server-connector/src/test/java/org/axonframework/extension/multitenancy/axonserver/MultiTenantAxonServerQueryBusConnectorTest.java
@@ -0,0 +1,288 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.axonserver;
+
+import io.axoniq.axonserver.connector.AxonServerConnection;
+import io.axoniq.axonserver.connector.Registration;
+import io.axoniq.axonserver.connector.query.QueryChannel;
+import org.axonframework.axonserver.connector.AxonServerConfiguration;
+import org.axonframework.axonserver.connector.AxonServerConnectionManager;
+import org.axonframework.common.AxonConfigurationException;
+import org.axonframework.common.configuration.Configuration;
+import org.axonframework.extension.multitenancy.core.TargetTenantResolver;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.messaging.core.Message;
+import org.axonframework.messaging.core.MessageStream;
+import org.axonframework.messaging.core.QualifiedName;
+import org.axonframework.messaging.queryhandling.QueryMessage;
+import org.axonframework.messaging.queryhandling.QueryResponseMessage;
+import org.axonframework.messaging.queryhandling.distributed.QueryBusConnector;
+import org.junit.jupiter.api.*;
+
+import java.util.Collection;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Test class validating the {@link MultiTenantAxonServerQueryBusConnector}.
+ *
+ * @author Stefan Dragisic
+ * @author Theo Emanuelsson
+ */
+class MultiTenantAxonServerQueryBusConnectorTest {
+
+ private AxonServerConnectionManager connectionManager;
+ private AxonServerConfiguration axonServerConfiguration;
+ private TargetTenantResolver targetTenantResolver;
+
+ private static final TenantDescriptor TENANT_1 = TenantDescriptor.tenantWithId("tenant1");
+ private static final TenantDescriptor TENANT_2 = TenantDescriptor.tenantWithId("tenant2");
+
+ @BeforeEach
+ void setUp() {
+ connectionManager = mock(AxonServerConnectionManager.class);
+ axonServerConfiguration = mock(AxonServerConfiguration.class);
+ targetTenantResolver = mock(TargetTenantResolver.class);
+
+ when(axonServerConfiguration.getClientId()).thenReturn("test-client");
+ when(axonServerConfiguration.getComponentName()).thenReturn("test-component");
+ }
+
+ @Test
+ void buildWithAllRequiredComponentsSucceeds() {
+ MultiTenantAxonServerQueryBusConnector connector = MultiTenantAxonServerQueryBusConnector.builder()
+ .connectionManager(connectionManager)
+ .axonServerConfiguration(axonServerConfiguration)
+ .targetTenantResolver(targetTenantResolver)
+ .build();
+
+ assertNotNull(connector);
+ }
+
+ @Test
+ void buildWithoutConnectionManagerFails() {
+ assertThrows(AxonConfigurationException.class, () ->
+ MultiTenantAxonServerQueryBusConnector.builder()
+ .axonServerConfiguration(axonServerConfiguration)
+ .targetTenantResolver(targetTenantResolver)
+ .build()
+ );
+ }
+
+ @Test
+ void buildWithoutAxonServerConfigurationFails() {
+ assertThrows(AxonConfigurationException.class, () ->
+ MultiTenantAxonServerQueryBusConnector.builder()
+ .connectionManager(connectionManager)
+ .targetTenantResolver(targetTenantResolver)
+ .build()
+ );
+ }
+
+ @Test
+ void buildWithoutTargetTenantResolverFails() {
+ assertThrows(AxonConfigurationException.class, () ->
+ MultiTenantAxonServerQueryBusConnector.builder()
+ .connectionManager(connectionManager)
+ .axonServerConfiguration(axonServerConfiguration)
+ .build()
+ );
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void createFromReturnsConnectorWhenAllComponentsAvailable() {
+ Configuration config = mock(Configuration.class);
+ when(config.getOptionalComponent(AxonServerConnectionManager.class))
+ .thenReturn(Optional.of(connectionManager));
+ when(config.getOptionalComponent(TargetTenantResolver.class))
+ .thenReturn(Optional.of(targetTenantResolver));
+ when(config.getComponent(AxonServerConfiguration.class))
+ .thenReturn(axonServerConfiguration);
+
+ MultiTenantAxonServerQueryBusConnector result =
+ MultiTenantAxonServerQueryBusConnector.createFrom(config);
+
+ assertNotNull(result);
+ }
+
+ @Test
+ void createFromReturnsNullWhenConnectionManagerNotAvailable() {
+ Configuration config = mock(Configuration.class);
+ when(config.getOptionalComponent(AxonServerConnectionManager.class))
+ .thenReturn(Optional.empty());
+
+ MultiTenantAxonServerQueryBusConnector result =
+ MultiTenantAxonServerQueryBusConnector.createFrom(config);
+
+ assertNull(result);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void createFromReturnsNullWhenTargetTenantResolverNotAvailable() {
+ Configuration config = mock(Configuration.class);
+ when(config.getOptionalComponent(AxonServerConnectionManager.class))
+ .thenReturn(Optional.of(connectionManager));
+ when(config.getOptionalComponent(TargetTenantResolver.class))
+ .thenReturn(Optional.empty());
+
+ MultiTenantAxonServerQueryBusConnector result =
+ MultiTenantAxonServerQueryBusConnector.createFrom(config);
+
+ assertNull(result);
+ }
+
+ @Test
+ void registerTenantAddsConnectorForTenant() {
+ AxonServerConnection tenantConnection = mockTenantConnection();
+ when(connectionManager.getConnection("tenant1")).thenReturn(tenantConnection);
+
+ MultiTenantAxonServerQueryBusConnector connector = createConnector();
+
+ assertTrue(connector.connectors().isEmpty());
+
+ var registration = connector.registerTenant(TENANT_1);
+
+ assertEquals(1, connector.connectors().size());
+ assertTrue(connector.connectors().containsKey(TENANT_1));
+ assertNotNull(registration);
+ }
+
+ @Test
+ void unregisterTenantRemovesConnector() {
+ AxonServerConnection tenantConnection = mockTenantConnection();
+ when(connectionManager.getConnection("tenant1")).thenReturn(tenantConnection);
+
+ MultiTenantAxonServerQueryBusConnector connector = createConnector();
+ var registration = connector.registerTenant(TENANT_1);
+
+ assertEquals(1, connector.connectors().size());
+
+ registration.cancel();
+
+ assertTrue(connector.connectors().isEmpty());
+ }
+
+ @Test
+ void queryToUnknownTenantReturnsFailedStream() {
+ MultiTenantAxonServerQueryBusConnector connector = createConnector();
+
+ QueryMessage query = mock(QueryMessage.class);
+ when(targetTenantResolver.resolveTenant(eq(query), any(Collection.class))).thenReturn(TENANT_1);
+
+ MessageStream result = connector.query(query, null);
+
+ assertNotNull(result);
+ // The stream should contain an error when consumed
+ }
+
+ @Test
+ void subscriptionQueryToUnknownTenantReturnsFailedStream() {
+ MultiTenantAxonServerQueryBusConnector connector = createConnector();
+
+ QueryMessage query = mock(QueryMessage.class);
+ when(targetTenantResolver.resolveTenant(eq(query), any(Collection.class))).thenReturn(TENANT_1);
+
+ MessageStream result = connector.subscriptionQuery(query, null, 100);
+
+ assertNotNull(result);
+ // The stream should contain an error when consumed
+ }
+
+ @Test
+ void subscribeWithNoTenantsCompletesSuccessfully() {
+ MultiTenantAxonServerQueryBusConnector connector = createConnector();
+
+ QualifiedName queryName = new QualifiedName("TestQuery");
+ CompletableFuture result = connector.subscribe(queryName);
+
+ assertDoesNotThrow(() -> result.get());
+ }
+
+ @Test
+ void registerAndStartTenantReplaysSubscriptions() {
+ AxonServerConnection tenantConnection = mockTenantConnection();
+ when(connectionManager.getConnection("tenant1")).thenReturn(tenantConnection);
+
+ MultiTenantAxonServerQueryBusConnector connector = createConnector();
+
+ // Subscribe before any tenants
+ QualifiedName queryName = new QualifiedName("TestQuery");
+ connector.subscribe(queryName);
+
+ // Register tenant - should replay subscription
+ connector.registerAndStartTenant(TENANT_1);
+
+ // Verify tenant connector was created
+ assertEquals(1, connector.connectors().size());
+ }
+
+ @Test
+ void onIncomingQuerySetsHandlerOnAllConnectors() {
+ AxonServerConnection tenant1Connection = mockTenantConnection();
+ AxonServerConnection tenant2Connection = mockTenantConnection();
+ when(connectionManager.getConnection("tenant1")).thenReturn(tenant1Connection);
+ when(connectionManager.getConnection("tenant2")).thenReturn(tenant2Connection);
+
+ MultiTenantAxonServerQueryBusConnector connector = createConnector();
+ connector.registerTenant(TENANT_1);
+ connector.registerTenant(TENANT_2);
+
+ QueryBusConnector.Handler handler = mock(QueryBusConnector.Handler.class);
+ connector.onIncomingQuery(handler);
+
+ // Handler should be set on all tenant connectors
+ assertEquals(2, connector.connectors().size());
+ }
+
+ @Test
+ void shutdownCompletesSuccessfully() {
+ AxonServerConnection tenantConnection = mockTenantConnection();
+ when(connectionManager.getConnection("tenant1")).thenReturn(tenantConnection);
+
+ MultiTenantAxonServerQueryBusConnector connector = createConnector();
+ connector.registerTenant(TENANT_1);
+
+ CompletableFuture result = connector.shutdown();
+
+ assertDoesNotThrow(() -> result.get());
+ }
+
+ private MultiTenantAxonServerQueryBusConnector createConnector() {
+ return MultiTenantAxonServerQueryBusConnector.builder()
+ .connectionManager(connectionManager)
+ .axonServerConfiguration(axonServerConfiguration)
+ .targetTenantResolver(targetTenantResolver)
+ .build();
+ }
+
+ private AxonServerConnection mockTenantConnection() {
+ AxonServerConnection connection = mock(AxonServerConnection.class);
+ QueryChannel queryChannel = mock(QueryChannel.class);
+
+ when(connection.queryChannel()).thenReturn(queryChannel);
+ when(connection.isConnected()).thenReturn(false);
+ when(queryChannel.registerQueryHandler(any(), any()))
+ .thenReturn(mock(Registration.class));
+
+ return connection;
+ }
+}
diff --git a/multitenancy-integration-tests-axon-server/pom.xml b/multitenancy-integration-tests-axon-server/pom.xml
new file mode 100644
index 0000000..adceb4f
--- /dev/null
+++ b/multitenancy-integration-tests-axon-server/pom.xml
@@ -0,0 +1,175 @@
+
+
+
+
+ 4.0.0
+
+
+ org.axonframework.extensions.multitenancy
+ axon-multitenancy-parent
+ 5.1.0-SNAPSHOT
+
+
+ axon-multitenancy-integration-tests-axon-server
+ Axon Framework Multi-Tenancy Extension - Axon Server Integration Tests
+
+ Integration tests for multi-tenancy with Axon Server using Testcontainers.
+ Tests tenant infrastructure routing and configuration with a single context.
+ Note: Multi-context isolation tests require Axon Server license.
+
+
+
+ 4.2.2
+ 3.27.3
+ 2.0.3
+
+
+
+
+
+ org.axonframework.extensions.multitenancy
+ axon-multitenancy
+ ${project.version}
+ test
+
+
+
+
+ org.axonframework.extensions.multitenancy
+ axon-multitenancy-axon-server-connector
+ ${project.version}
+ test
+
+
+
+
+ org.axonframework
+ axon-eventsourcing
+ test
+
+
+ org.axonframework
+ axon-modelling
+ ${axon.version}
+ test
+
+
+ org.axonframework
+ axon-messaging
+ test
+
+
+
+
+ org.axonframework
+ axon-server-connector
+ test
+
+
+
+
+ org.axonframework
+ axon-test
+ ${axon.version}
+ test
+
+
+
+
+ org.testcontainers
+ testcontainers-junit-jupiter
+ ${testcontainers.version}
+ test
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+
+
+ org.assertj
+ assertj-core
+ ${assertj.version}
+ test
+
+
+ org.awaitility
+ awaitility
+ ${awaitility.version}
+ test
+
+
+
+
+ jakarta.validation
+ jakarta.validation-api
+ 3.1.0
+ test
+
+
+
+
+ org.slf4j
+ slf4j-api
+ 2.0.17
+ test
+
+
+ org.apache.logging.log4j
+ log4j-slf4j2-impl
+ 2.24.3
+ test
+
+
+ org.apache.logging.log4j
+ log4j-core
+ 2.24.3
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ **/*IT.java
+ **/*Test.java
+
+ -Daxon.axonserver.suppressDownloadMessage=true
+
+
+
+
+ org.apache.maven.plugins
+ maven-deploy-plugin
+ 3.1.3
+
+ true
+
+
+
+
+
diff --git a/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/AxonServerMultiTenantIT.java b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/AxonServerMultiTenantIT.java
new file mode 100644
index 0000000..31658a4
--- /dev/null
+++ b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/AxonServerMultiTenantIT.java
@@ -0,0 +1,230 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.axonserver;
+
+import org.awaitility.Awaitility;
+import org.axonframework.axonserver.connector.AxonServerConfiguration;
+import org.axonframework.common.configuration.AxonConfiguration;
+import org.axonframework.eventsourcing.configuration.EventSourcingConfigurer;
+import org.axonframework.extension.multitenancy.core.MetadataBasedTenantResolver;
+import org.axonframework.extension.multitenancy.core.NoSuchTenantException;
+import org.axonframework.extension.multitenancy.core.TenantConnectPredicate;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.extension.multitenancy.core.TenantProvider;
+import org.axonframework.extension.multitenancy.core.configuration.MultiTenancyConfigurer;
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.read.coursestats.FindAllCourses;
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.read.coursestats.CourseStatsConfiguration;
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.read.coursestats.CourseStatsRepository;
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.read.coursestats.CoursesStats;
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.read.coursestats.InMemoryCourseStatsRepository;
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.shared.CourseId;
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.write.createcourse.CreateCourse;
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.write.createcourse.CreateCourseConfiguration;
+import org.axonframework.messaging.commandhandling.gateway.CommandGateway;
+import org.axonframework.messaging.core.MessageType;
+import org.axonframework.messaging.core.Metadata;
+import org.axonframework.messaging.queryhandling.GenericQueryMessage;
+import org.axonframework.messaging.queryhandling.QueryMessage;
+import org.axonframework.messaging.queryhandling.gateway.QueryGateway;
+import org.axonframework.test.server.AxonServerContainer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+
+/**
+ * Integration tests verifying multi-tenant infrastructure with Axon Server.
+ *
+ * Important: Without an Axon Server license, only the {@code default} context is available.
+ * These tests verify the full command → event → projection → query flow works through the
+ * multi-tenant infrastructure connected to Axon Server.
+ *
+ * @author Theo Emanuelsson
+ * @since 5.0.0
+ */
+@Testcontainers
+class AxonServerMultiTenantIT {
+
+ private static final String AXON_SERVER_IMAGE = "docker.axoniq.io/axoniq/axonserver:2025.2.0";
+ private static final TenantDescriptor DEFAULT_TENANT = TenantDescriptor.tenantWithId("default");
+
+ @Container
+ static final AxonServerContainer axonServer = new AxonServerContainer(AXON_SERVER_IMAGE)
+ .withDevMode(true)
+ .withDcbContext(true)
+ .withReuse(true);
+
+ private AxonConfiguration configuration;
+ private CommandGateway commandGateway;
+ private QueryGateway queryGateway;
+
+ @BeforeAll
+ static void beforeAll() {
+ assertThat(axonServer.isRunning()).isTrue();
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (configuration != null) {
+ configuration.shutdown();
+ configuration = null;
+ }
+ }
+
+ private void startApp() {
+ AxonServerConfiguration asConfig = new AxonServerConfiguration();
+ asConfig.setServers(axonServer.getAxonServerAddress());
+
+ EventSourcingConfigurer configurer = EventSourcingConfigurer.create();
+
+ configurer.componentRegistry(cr -> cr.registerComponent(
+ AxonServerConfiguration.class, c -> asConfig));
+
+ configurer.componentRegistry(cr -> cr.registerComponent(
+ TenantConnectPredicate.class,
+ c -> tenant -> "default".equals(tenant.tenantId())));
+
+ MultiTenancyConfigurer multiTenancyConfigurer = MultiTenancyConfigurer.enhance(configurer)
+ .registerTargetTenantResolver(config -> new MetadataBasedTenantResolver());
+
+ configurer = CreateCourseConfiguration.configure(configurer);
+ configurer = CourseStatsConfiguration.configure(configurer);
+
+ multiTenancyConfigurer.tenantComponent(CourseStatsRepository.class, tenant -> new InMemoryCourseStatsRepository());
+
+ configurer.messaging(mc -> mc.registerCorrelationDataProvider(config -> message -> {
+ Map result = new HashMap<>();
+ if (message.metadata().containsKey("tenantId")) {
+ result.put("tenantId", message.metadata().get("tenantId"));
+ }
+ return result;
+ }));
+
+ configuration = configurer.start();
+ commandGateway = configuration.getComponent(CommandGateway.class);
+ queryGateway = configuration.getComponent(QueryGateway.class);
+ }
+
+ private void waitForTenantRegistration() {
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(10))
+ .until(() -> {
+ TenantProvider provider = configuration.getComponent(TenantProvider.class);
+ return provider.getTenants().contains(DEFAULT_TENANT);
+ });
+ }
+
+ private void createCourseForTenant(TenantDescriptor tenant, CourseId courseId, String name, int capacity) {
+ commandGateway.send(
+ new CreateCourse(courseId, name, capacity),
+ Metadata.with("tenantId", tenant.tenantId()),
+ null
+ ).getResultMessage().join();
+ }
+
+ private List queryCoursesForTenant(TenantDescriptor tenant) {
+ QueryMessage query = new GenericQueryMessage(
+ new MessageType(FindAllCourses.class), new FindAllCourses()
+ ).andMetadata(Metadata.with("tenantId", tenant.tenantId()));
+ return queryGateway.query(query, FindAllCourses.Result.class)
+ .thenApply(FindAllCourses.Result::courses).join();
+ }
+
+ @Test
+ void commandCreatesEventWhichUpdatesProjection() {
+ startApp();
+ waitForTenantRegistration();
+
+ CourseId courseId = CourseId.random();
+ String courseName = "Test Course " + System.currentTimeMillis();
+
+ // Send command
+ assertDoesNotThrow(() ->
+ createCourseForTenant(DEFAULT_TENANT, courseId, courseName, 50)
+ );
+
+ // Wait for projection to process and verify
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(10))
+ .until(() -> queryCoursesForTenant(DEFAULT_TENANT).stream()
+ .anyMatch(c -> c.courseId().equals(courseId)));
+
+ List courses = queryCoursesForTenant(DEFAULT_TENANT);
+ assertThat(courses)
+ .anyMatch(c -> c.courseId().equals(courseId) && c.name().equals(courseName));
+ }
+
+ @Test
+ void multipleCoursesCanBeCreatedAndQueried() {
+ startApp();
+ waitForTenantRegistration();
+
+ CourseId course1 = CourseId.random();
+ CourseId course2 = CourseId.random();
+ String name1 = "Course One " + System.currentTimeMillis();
+ String name2 = "Course Two " + System.currentTimeMillis();
+
+ createCourseForTenant(DEFAULT_TENANT, course1, name1, 30);
+ createCourseForTenant(DEFAULT_TENANT, course2, name2, 25);
+
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(10))
+ .until(() -> {
+ List courses = queryCoursesForTenant(DEFAULT_TENANT);
+ return courses.stream().anyMatch(c -> c.courseId().equals(course1))
+ && courses.stream().anyMatch(c -> c.courseId().equals(course2));
+ });
+
+ List courses = queryCoursesForTenant(DEFAULT_TENANT);
+ assertThat(courses).anyMatch(c -> c.courseId().equals(course1) && c.name().equals(name1));
+ assertThat(courses).anyMatch(c -> c.courseId().equals(course2) && c.name().equals(name2));
+ }
+
+ @Test
+ void commandToNonExistentTenantFails() {
+ startApp();
+ waitForTenantRegistration();
+
+ TenantDescriptor nonExistentTenant = TenantDescriptor.tenantWithId("non-existent");
+ CourseId courseId = CourseId.random();
+
+ assertThatThrownBy(() ->
+ createCourseForTenant(nonExistentTenant, courseId, "Should Fail", 10)
+ ).hasCauseInstanceOf(NoSuchTenantException.class);
+ }
+
+ @Test
+ void commandWithoutTenantMetadataFails() {
+ startApp();
+ waitForTenantRegistration();
+
+ CourseId courseId = CourseId.random();
+ CreateCourse command = new CreateCourse(courseId, "Test Course without Tenant", 10);
+
+ assertThatThrownBy(() -> commandGateway.sendAndWait(command))
+ .isInstanceOf(NoSuchTenantException.class);
+ }
+}
diff --git a/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/event/CourseCreated.java b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/event/CourseCreated.java
new file mode 100644
index 0000000..1b5a287
--- /dev/null
+++ b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/event/CourseCreated.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.axonserver.event;
+
+import org.axonframework.eventsourcing.annotation.EventTag;
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.shared.CourseId;
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.shared.CourseTags;
+import org.axonframework.messaging.eventhandling.annotation.Event;
+
+/**
+ * Course created event.
+ *
+ * @param courseId course ID.
+ * @param name course name.
+ * @param capacity course capacity.
+ */
+@Event(name = "CourseCreated")
+public record CourseCreated(
+ @EventTag(key = CourseTags.COURSE_ID)
+ CourseId courseId,
+ String name,
+ int capacity
+) {
+}
diff --git a/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/CourseStatsConfiguration.java b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/CourseStatsConfiguration.java
new file mode 100644
index 0000000..4563ca6
--- /dev/null
+++ b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/CourseStatsConfiguration.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.axonserver.read.coursestats;
+
+import org.axonframework.eventsourcing.configuration.EventSourcingConfigurer;
+import org.axonframework.extension.multitenancy.messaging.eventhandling.processing.MultiTenantPooledStreamingEventProcessorModule;
+import org.axonframework.messaging.queryhandling.configuration.QueryHandlingModule;
+
+/**
+ * Configuration for course statistics projection and query handlers.
+ *
+ * Demonstrates two approaches for accessing tenant-scoped components using
+ * separate event processors:
+ *
+ *
{@link CoursesStatsProjectorViaInjection} - Direct parameter injection
+ *
{@link CoursesStatsProjectorViaContext} - Via {@code ProcessingContext.component()}
+ *
+ *
+ * Note: Tenant-scoped repository is registered separately via
+ * {@code MultiTenancyConfigurer.tenantComponent()} for global access.
+ */
+public class CourseStatsConfiguration {
+
+ public static final String PROCESSOR_VIA_INJECTION = "Projection_CourseStats_ViaInjection";
+ public static final String PROCESSOR_VIA_CONTEXT = "Projection_CourseStats_ViaContext";
+
+ public static EventSourcingConfigurer configure(EventSourcingConfigurer configurer) {
+ // Processor 1: Demonstrates direct parameter injection approach
+ var injectionProcessor = MultiTenantPooledStreamingEventProcessorModule
+ .create(PROCESSOR_VIA_INJECTION)
+ .eventHandlingComponents(c -> c
+ .autodetected(cfg -> new CoursesStatsProjectorViaInjection())
+ )
+ .notCustomized();
+
+ // Processor 2: Demonstrates ProcessingContext.component() approach
+ var contextProcessor = MultiTenantPooledStreamingEventProcessorModule
+ .create(PROCESSOR_VIA_CONTEXT)
+ .eventHandlingComponents(c -> c
+ .autodetected(cfg -> new CoursesStatsProjectorViaContext())
+ )
+ .notCustomized();
+
+ // Query handler uses direct parameter injection for tenant-scoped repository
+ QueryHandlingModule queryModule = QueryHandlingModule.named("Stats-Handler")
+ .queryHandlers()
+ .annotatedQueryHandlingComponent(cfg -> new FindAllCoursesQueryHandler())
+ .build();
+
+ return configurer
+ .componentRegistry(cr -> cr.registerModule(injectionProcessor.build()))
+ .componentRegistry(cr -> cr.registerModule(contextProcessor.build()))
+ .registerQueryHandlingModule(queryModule);
+ }
+
+ private CourseStatsConfiguration() {
+ // Prevent instantiation
+ }
+}
diff --git a/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/CourseStatsRepository.java b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/CourseStatsRepository.java
new file mode 100644
index 0000000..414477d
--- /dev/null
+++ b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/CourseStatsRepository.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.axonserver.read.coursestats;
+
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.shared.CourseId;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Repository of course statistics.
+ */
+public interface CourseStatsRepository {
+
+ CoursesStats save(CoursesStats stats);
+
+ Optional findById(CourseId courseId);
+
+ default CoursesStats findByIdOrThrow(CourseId courseId) {
+ return findById(courseId).orElseThrow(() -> new RuntimeException(
+ "Course with ID " + courseId + " does not exist!"));
+ }
+
+ List findAll();
+}
diff --git a/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/CoursesStats.java b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/CoursesStats.java
new file mode 100644
index 0000000..0caccd7
--- /dev/null
+++ b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/CoursesStats.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.axonserver.read.coursestats;
+
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.shared.CourseId;
+
+/**
+ * Read model representing statistics about a course.
+ *
+ * @param courseId course ID.
+ * @param name name of the course.
+ * @param capacity capacity of the course.
+ */
+public record CoursesStats(CourseId courseId, String name, int capacity) {
+
+ public CoursesStats name(String name) {
+ return new CoursesStats(courseId, name, capacity);
+ }
+
+ public CoursesStats capacity(int capacity) {
+ return new CoursesStats(courseId, name, capacity);
+ }
+}
diff --git a/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/CoursesStatsProjectorViaContext.java b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/CoursesStatsProjectorViaContext.java
new file mode 100644
index 0000000..293deb2
--- /dev/null
+++ b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/CoursesStatsProjectorViaContext.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.axonserver.read.coursestats;
+
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.event.CourseCreated;
+import org.axonframework.messaging.core.unitofwork.ProcessingContext;
+import org.axonframework.messaging.eventhandling.annotation.EventHandler;
+import org.axonframework.messaging.eventhandling.annotation.SequencingPolicy;
+import org.axonframework.messaging.eventhandling.sequencing.PropertySequencingPolicy;
+import org.axonframework.messaging.queryhandling.QueryUpdateEmitter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+/**
+ * Event handler that demonstrates accessing tenant-scoped components via
+ * {@link ProcessingContext#component(Class)}.
+ *
+ * This approach is useful when you need to access multiple tenant-scoped components
+ * or when the component type isn't known at compile time.
+ *
+ *
+ * @see CoursesStatsProjectorViaInjection for the direct injection approach
+ */
+@SequencingPolicy(type = PropertySequencingPolicy.class, parameters = {"courseId"})
+class CoursesStatsProjectorViaContext {
+
+ private static final Logger logger = LoggerFactory.getLogger(CoursesStatsProjectorViaContext.class);
+
+ @EventHandler
+ void handle(CourseCreated event, ProcessingContext context, QueryUpdateEmitter emitter) {
+ // Get tenant-scoped repository via ProcessingContext.component()
+ CourseStatsRepository repository = context.component(CourseStatsRepository.class);
+
+ var stats = new CoursesStats(
+ event.courseId(),
+ event.name(),
+ event.capacity()
+ );
+ repository.save(stats);
+ logger.info("[VIA-CONTEXT] Saved course: {}", event.courseId());
+
+ emitter.emit(
+ FindAllCourses.class,
+ q -> true,
+ new FindAllCourses.Result(List.of(stats))
+ );
+ }
+}
diff --git a/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/CoursesStatsProjectorViaInjection.java b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/CoursesStatsProjectorViaInjection.java
new file mode 100644
index 0000000..246a967
--- /dev/null
+++ b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/CoursesStatsProjectorViaInjection.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.axonserver.read.coursestats;
+
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.event.CourseCreated;
+import org.axonframework.messaging.eventhandling.annotation.EventHandler;
+import org.axonframework.messaging.eventhandling.annotation.SequencingPolicy;
+import org.axonframework.messaging.eventhandling.sequencing.PropertySequencingPolicy;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Event handler that demonstrates accessing tenant-scoped components via
+ * direct parameter injection.
+ *
+ * This approach is the most concise when you know the component type at compile time
+ * and only need a single tenant-scoped component.
+ *
+ *
+ * @see CoursesStatsProjectorViaContext for the ProcessingContext approach
+ */
+@SequencingPolicy(type = PropertySequencingPolicy.class, parameters = {"courseId"})
+class CoursesStatsProjectorViaInjection {
+
+ private static final Logger logger = LoggerFactory.getLogger(CoursesStatsProjectorViaInjection.class);
+
+ @EventHandler
+ void handle(CourseCreated event, CourseStatsRepository repository) {
+ // Repository is injected directly as a parameter - tenant is resolved automatically
+ var stats = new CoursesStats(
+ event.courseId(),
+ event.name(),
+ event.capacity()
+ );
+ repository.save(stats);
+ logger.info("[VIA-INJECTION] Saved course: {}", event.courseId());
+ }
+}
diff --git a/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/FindAllCourses.java b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/FindAllCourses.java
new file mode 100644
index 0000000..e4514c7
--- /dev/null
+++ b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/FindAllCourses.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.axonserver.read.coursestats;
+
+import java.util.List;
+
+/**
+ * Query to find all courses.
+ */
+public record FindAllCourses() {
+
+ /**
+ * Result containing all courses.
+ *
+ * @param courses the list of course stats.
+ */
+ public record Result(List courses) {
+ }
+}
diff --git a/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/FindAllCoursesQueryHandler.java b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/FindAllCoursesQueryHandler.java
new file mode 100644
index 0000000..0a24d82
--- /dev/null
+++ b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/FindAllCoursesQueryHandler.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.axonserver.read.coursestats;
+
+import org.axonframework.messaging.queryhandling.annotation.QueryHandler;
+
+/**
+ * Query handler for finding all courses.
+ *
+ * Uses parameter injection to receive the tenant-scoped repository.
+ * The repository is resolved based on the query's tenant context.
+ */
+public class FindAllCoursesQueryHandler {
+
+ @QueryHandler
+ FindAllCourses.Result handle(FindAllCourses query, CourseStatsRepository repository) {
+ return new FindAllCourses.Result(repository.findAll());
+ }
+}
diff --git a/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/InMemoryCourseStatsRepository.java b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/InMemoryCourseStatsRepository.java
new file mode 100644
index 0000000..8351aef
--- /dev/null
+++ b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/read/coursestats/InMemoryCourseStatsRepository.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.axonserver.read.coursestats;
+
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.shared.CourseId;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * In-memory implementation of the course statistics repository.
+ */
+public class InMemoryCourseStatsRepository implements CourseStatsRepository {
+
+ private final ConcurrentHashMap stats = new ConcurrentHashMap<>();
+
+ @Override
+ public CoursesStats save(CoursesStats stats) {
+ this.stats.put(stats.courseId(), stats);
+ return stats;
+ }
+
+ @Override
+ public Optional findById(CourseId courseId) {
+ return Optional.ofNullable(stats.get(courseId));
+ }
+
+ @Override
+ public List findAll() {
+ return stats.values().stream().toList();
+ }
+}
diff --git a/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/shared/CourseId.java b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/shared/CourseId.java
new file mode 100644
index 0000000..78a92ea
--- /dev/null
+++ b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/shared/CourseId.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.axonserver.shared;
+
+import jakarta.validation.constraints.NotNull;
+
+import java.util.UUID;
+
+/**
+ * Course ID value object.
+ *
+ * @param raw raw string ID representation.
+ */
+public record CourseId(@NotNull String raw) {
+
+ private final static String ENTITY_TYPE = "Course";
+
+ public CourseId {
+ if (raw == null || raw.isBlank()) {
+ throw new IllegalArgumentException("Course ID cannot be null or empty");
+ }
+ raw = withType(raw);
+ }
+
+ public static CourseId of(String raw) {
+ return new CourseId(raw);
+ }
+
+ public static CourseId random() {
+ return new CourseId(UUID.randomUUID().toString());
+ }
+
+ @Override
+ public String toString() {
+ return raw;
+ }
+
+ private static String withType(String id) {
+ return id.startsWith(ENTITY_TYPE + ":") ? id : ENTITY_TYPE + ":" + id;
+ }
+}
diff --git a/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/shared/CourseTags.java b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/shared/CourseTags.java
new file mode 100644
index 0000000..1426ec1
--- /dev/null
+++ b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/shared/CourseTags.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.axonserver.shared;
+
+/**
+ * Event tags for course-related events.
+ */
+public class CourseTags {
+
+ public static final String COURSE_ID = "courseId";
+
+ private CourseTags() {
+ // Prevent instantiation
+ }
+}
diff --git a/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/write/createcourse/CourseCreation.java b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/write/createcourse/CourseCreation.java
new file mode 100644
index 0000000..7322fc1
--- /dev/null
+++ b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/write/createcourse/CourseCreation.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.axonserver.write.createcourse;
+
+import jakarta.validation.Valid;
+import org.axonframework.eventsourcing.annotation.EventSourcedEntity;
+import org.axonframework.eventsourcing.annotation.EventSourcingHandler;
+import org.axonframework.eventsourcing.annotation.reflection.EntityCreator;
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.event.CourseCreated;
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.shared.CourseId;
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.shared.CourseTags;
+import org.axonframework.messaging.commandhandling.annotation.CommandHandler;
+import org.axonframework.messaging.eventhandling.gateway.EventAppender;
+
+/**
+ * Entity for course creation with internal command handler.
+ */
+@EventSourcedEntity(tagKey = CourseTags.COURSE_ID)
+public class CourseCreation {
+
+ private boolean created = false;
+ private CourseId id;
+ private int capacity;
+
+ @CommandHandler
+ public static void handle(@Valid CreateCourse command, EventAppender appender) {
+ appender.append(
+ new CourseCreated(
+ command.courseId(),
+ command.name(),
+ command.capacity()
+ )
+ );
+ }
+
+ @EntityCreator
+ public CourseCreation() {
+ }
+
+ @EventSourcingHandler
+ public void evolve(CourseCreated courseCreated) {
+ this.id = courseCreated.courseId();
+ this.created = true;
+ this.capacity = courseCreated.capacity();
+ }
+}
diff --git a/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/write/createcourse/CreateCourse.java b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/write/createcourse/CreateCourse.java
new file mode 100644
index 0000000..0ced2d3
--- /dev/null
+++ b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/write/createcourse/CreateCourse.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.axonserver.write.createcourse;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.shared.CourseId;
+import org.axonframework.modelling.annotation.TargetEntityId;
+
+/**
+ * Command to create a new course.
+ *
+ * @param courseId course ID.
+ * @param name course name.
+ * @param capacity course capacity.
+ */
+public record CreateCourse(
+ @Valid
+ @NotNull
+ @TargetEntityId
+ CourseId courseId,
+ @NotEmpty
+ @Size(min = 10)
+ String name,
+ @Min(value = 1, message = "Capacity must be a positive integer")
+ int capacity
+) {
+}
diff --git a/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/write/createcourse/CreateCourseConfiguration.java b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/write/createcourse/CreateCourseConfiguration.java
new file mode 100644
index 0000000..c3098da
--- /dev/null
+++ b/multitenancy-integration-tests-axon-server/src/test/java/org/axonframework/extension/multitenancy/integrationtests/axonserver/write/createcourse/CreateCourseConfiguration.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.axonserver.write.createcourse;
+
+import org.axonframework.eventsourcing.configuration.EventSourcedEntityModule;
+import org.axonframework.eventsourcing.configuration.EventSourcingConfigurer;
+import org.axonframework.extension.multitenancy.integrationtests.axonserver.shared.CourseId;
+
+/**
+ * Configuration for the CreateCourse command handler.
+ */
+public class CreateCourseConfiguration {
+
+ public static EventSourcingConfigurer configure(EventSourcingConfigurer configurer) {
+ var stateEntity = EventSourcedEntityModule
+ .autodetected(CourseId.class, CourseCreation.class);
+
+ return configurer.registerEntity(stateEntity);
+ }
+
+ private CreateCourseConfiguration() {
+ // Prevent instantiation
+ }
+}
diff --git a/multitenancy-integration-tests-axon-server/src/test/resources/log4j2-test.xml b/multitenancy-integration-tests-axon-server/src/test/resources/log4j2-test.xml
new file mode 100644
index 0000000..2d5cb9f
--- /dev/null
+++ b/multitenancy-integration-tests-axon-server/src/test/resources/log4j2-test.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/multitenancy-integration-tests-embedded/pom.xml b/multitenancy-integration-tests-embedded/pom.xml
new file mode 100644
index 0000000..81c2b24
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/pom.xml
@@ -0,0 +1,141 @@
+
+
+
+
+ 4.0.0
+
+
+ org.axonframework.extensions.multitenancy
+ axon-multitenancy-parent
+ 5.1.0-SNAPSHOT
+
+
+ axon-multitenancy-integration-tests-embedded
+ Axon Framework Multi-Tenancy Extension - Embedded Integration Tests
+
+ Integration tests for core multi-tenancy with embedded in-memory event store.
+ Tests tenant isolation, routing, and dynamic tenant registration.
+
+
+
+ 4.2.2
+ 3.27.3
+
+
+
+
+
+ org.axonframework.extensions.multitenancy
+ axon-multitenancy
+ ${project.version}
+ test
+
+
+
+
+ org.axonframework
+ axon-eventsourcing
+ test
+
+
+ org.axonframework
+ axon-modelling
+ ${axon.version}
+ test
+
+
+ org.axonframework
+ axon-messaging
+ test
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+
+
+ org.assertj
+ assertj-core
+ ${assertj.version}
+ test
+
+
+ org.awaitility
+ awaitility
+ ${awaitility.version}
+ test
+
+
+
+
+ jakarta.validation
+ jakarta.validation-api
+ 3.1.0
+ test
+
+
+
+
+ org.slf4j
+ slf4j-api
+ 2.0.17
+ test
+
+
+ org.apache.logging.log4j
+ log4j-slf4j2-impl
+ 2.24.3
+ test
+
+
+ org.apache.logging.log4j
+ log4j-core
+ 2.24.3
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ **/*IT.java
+ **/*Test.java
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-deploy-plugin
+ 3.1.3
+
+ true
+
+
+
+
+
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/EmbeddedMultiTenantIT.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/EmbeddedMultiTenantIT.java
new file mode 100644
index 0000000..7c2b7c4
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/EmbeddedMultiTenantIT.java
@@ -0,0 +1,434 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded;
+
+import org.awaitility.Awaitility;
+import org.axonframework.common.configuration.AxonConfiguration;
+import org.axonframework.eventsourcing.configuration.EventSourcingConfigurer;
+import org.axonframework.eventsourcing.eventstore.EventStore;
+import org.axonframework.eventsourcing.eventstore.InterceptingEventStore;
+import org.axonframework.extension.multitenancy.core.MetadataBasedTenantResolver;
+import org.axonframework.extension.multitenancy.core.NoSuchTenantException;
+import org.axonframework.extension.multitenancy.core.SimpleTenantProvider;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.extension.multitenancy.core.configuration.MultiTenancyConfigurer;
+import org.axonframework.extension.multitenancy.eventsourcing.eventstore.MultiTenantEventStore;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.read.coursestats.FindAllCourses;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.read.coursestats.CoursesStats;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.read.coursestats.CourseStatsConfiguration;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.read.coursestats.CourseStatsRepository;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.read.coursestats.InMemoryCourseStatsRepository;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.shared.CourseId;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.write.createcourse.CreateCourse;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.write.createcourse.CreateCourseConfiguration;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.automation.CourseNotificationConfiguration;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.automation.NotificationService;
+import org.axonframework.messaging.commandhandling.CommandBus;
+import org.axonframework.messaging.commandhandling.gateway.CommandGateway;
+import org.axonframework.messaging.core.MessageType;
+import org.axonframework.messaging.core.Metadata;
+import org.axonframework.messaging.queryhandling.GenericQueryMessage;
+import org.axonframework.messaging.queryhandling.QueryBus;
+import org.axonframework.messaging.queryhandling.QueryMessage;
+import org.axonframework.messaging.queryhandling.gateway.QueryGateway;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.axonframework.messaging.core.interception.DispatchInterceptorRegistry;
+import org.axonframework.messaging.core.MessageDispatchInterceptor;
+import org.axonframework.messaging.eventhandling.EventMessage;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+
+/**
+ * Integration tests verifying multi-tenant isolation with embedded in-memory event store.
+ *
+ * These tests verify the core multi-tenancy functionality:
+ *
+ *
Tenant isolation - data from tenant A not visible in tenant B
+ *
Tenant routing - commands/queries routed to correct tenant segment
+ *
Dynamic tenant registration - tenants can be added after startup
+ *
+ *
+ * @author Theo Emanuelsson
+ * @since 5.0.0
+ */
+class EmbeddedMultiTenantIT {
+
+ private static final TenantDescriptor TENANT_A = TenantDescriptor.tenantWithId("tenant-a");
+ private static final TenantDescriptor TENANT_B = TenantDescriptor.tenantWithId("tenant-b");
+
+ private SimpleTenantProvider tenantProvider;
+ private AxonConfiguration configuration;
+ private CommandGateway commandGateway;
+ private QueryGateway queryGateway;
+
+ @AfterEach
+ void tearDown() {
+ if (configuration != null) {
+ configuration.shutdown();
+ configuration = null;
+ }
+ }
+
+ private void startApp() {
+ tenantProvider = new SimpleTenantProvider(List.of(TENANT_A, TENANT_B));
+
+ // Create base configurer
+ EventSourcingConfigurer configurer = EventSourcingConfigurer.create();
+
+ // Enhance with multi-tenancy - this registers resolver factories early
+ MultiTenancyConfigurer multiTenancyConfigurer = MultiTenancyConfigurer.enhance(configurer)
+ .registerTenantProvider(config -> tenantProvider)
+ .registerTargetTenantResolver(config -> new MetadataBasedTenantResolver());
+
+ // Domain modules can be configured in ANY order relative to tenantComponent()
+ // because resolver factories are registered in enhance(), not tenantComponent()
+ configurer = CreateCourseConfiguration.configure(configurer);
+ configurer = CourseStatsConfiguration.configure(configurer);
+
+ // tenantComponent() can be called after domain modules are configured
+ multiTenancyConfigurer.tenantComponent(CourseStatsRepository.class, tenant -> new InMemoryCourseStatsRepository());
+
+ // Register correlation provider for metadata-based tenant resolution
+ // This ensures tenantId propagates from commands to events
+ configurer.messaging(mc -> mc.registerCorrelationDataProvider(config -> message -> {
+ Map result = new HashMap<>();
+ if (message.metadata().containsKey("tenantId")) {
+ result.put("tenantId", message.metadata().get("tenantId"));
+ }
+ return result;
+ }));
+
+ configuration = configurer.start();
+ commandGateway = configuration.getComponent(CommandGateway.class);
+ queryGateway = configuration.getComponent(QueryGateway.class);
+ }
+
+ private void createCourseForTenant(TenantDescriptor tenant, CourseId courseId, String name, int capacity) {
+ commandGateway.send(
+ new CreateCourse(courseId, name, capacity),
+ Metadata.with("tenantId", tenant.tenantId()),
+ null
+ ).getResultMessage().join();
+ }
+
+ private List queryCoursesForTenant(TenantDescriptor tenant) {
+ QueryMessage query = new GenericQueryMessage(
+ new MessageType(FindAllCourses.class), new FindAllCourses()
+ ).andMetadata(Metadata.with("tenantId", tenant.tenantId()));
+ return queryGateway.query(query, FindAllCourses.Result.class)
+ .thenApply(FindAllCourses.Result::courses).join();
+ }
+
+ @Test
+ void debugCorrelationAndInterceptorConfiguration() {
+ // Modified startApp with debug hooks
+ tenantProvider = new SimpleTenantProvider(List.of(TENANT_A, TENANT_B));
+ EventSourcingConfigurer configurer = EventSourcingConfigurer.create();
+ configurer = CreateCourseConfiguration.configure(configurer);
+ configurer = CourseStatsConfiguration.configure(configurer);
+
+ configurer.messaging(mc -> mc.registerCorrelationDataProvider(config -> message -> {
+ Map result = new HashMap<>();
+ if (message.metadata().containsKey("tenantId")) {
+ result.put("tenantId", message.metadata().get("tenantId"));
+ }
+ return result;
+ }));
+
+ MultiTenancyConfigurer.enhance(configurer)
+ .registerTenantProvider(config -> tenantProvider)
+ .registerTargetTenantResolver(config -> new MetadataBasedTenantResolver())
+ .tenantComponent(CourseStatsRepository.class, tenant -> new InMemoryCourseStatsRepository());
+
+ // Debug: Check if MultiTenancy enhancer actually runs by adding decorator at MIN_VALUE-1 (BEFORE MultiTenant)
+ configurer.componentRegistry(cr -> cr.registerDecorator(
+ EventStore.class,
+ Integer.MIN_VALUE + 10, // Between MIN_VALUE and +50 to see what's happening
+ (config, name, delegate) -> {
+ System.out.println("=== DEBUG @ MIN_VALUE+10 ===");
+ System.out.println(" delegate type: " + delegate.getClass().getName());
+ return delegate;
+ }
+ ));
+
+ // Debug: right after MultiTenantEventStore decorator (MIN_VALUE)
+ configurer.componentRegistry(cr -> cr.registerDecorator(
+ EventStore.class,
+ Integer.MIN_VALUE + 1,
+ (config, name, delegate) -> {
+ System.out.println("=== DEBUG @ MIN_VALUE+1 (after MultiTenant) ===");
+ System.out.println(" delegate type: " + delegate.getClass().getName());
+ return delegate;
+ }
+ ));
+
+ // Debug: right before InterceptingEventStore decorator (MIN_VALUE + 50)
+ configurer.componentRegistry(cr -> cr.registerDecorator(
+ EventStore.class,
+ Integer.MIN_VALUE + 49,
+ (config, name, delegate) -> {
+ System.out.println("=== DEBUG @ MIN_VALUE+49 (before Intercepting) ===");
+ System.out.println(" delegate type: " + delegate.getClass().getName());
+ DispatchInterceptorRegistry registry = config.getComponent(DispatchInterceptorRegistry.class);
+ List> interceptors = registry.eventInterceptors(config);
+ System.out.println(" Event dispatch interceptors: " + interceptors.size());
+ return delegate;
+ }
+ ));
+
+ // Debug: right after InterceptingEventStore decorator (MIN_VALUE + 50)
+ configurer.componentRegistry(cr -> cr.registerDecorator(
+ EventStore.class,
+ Integer.MIN_VALUE + 51,
+ (config, name, delegate) -> {
+ System.out.println("=== DEBUG @ MIN_VALUE+51 (after Intercepting) ===");
+ System.out.println(" delegate type: " + delegate.getClass().getName());
+ DispatchInterceptorRegistry registry = config.getComponent(DispatchInterceptorRegistry.class);
+ List> interceptors = registry.eventInterceptors(config);
+ System.out.println(" Event dispatch interceptors: " + interceptors.size());
+ return delegate;
+ }
+ ));
+
+ // Print decoration order constants to verify
+ System.out.println("=== DECORATION ORDER CONSTANTS ===");
+ System.out.println(" MultiTenantEventStore.DECORATION_ORDER = " + MultiTenantEventStore.DECORATION_ORDER);
+ System.out.println(" InterceptingEventStore.DECORATION_ORDER = " + InterceptingEventStore.DECORATION_ORDER);
+ System.out.println(" Integer.MIN_VALUE = " + Integer.MIN_VALUE);
+ System.out.println(" Integer.MIN_VALUE + 50 = " + (Integer.MIN_VALUE + 50));
+
+ configuration = configurer.start();
+ commandGateway = configuration.getComponent(CommandGateway.class);
+ queryGateway = configuration.getComponent(QueryGateway.class);
+
+ // Debug: Check EventStore type AFTER configuration
+ EventStore eventStore = configuration.getComponent(EventStore.class);
+ System.out.println("=== AFTER CONFIG: EventStore ===");
+ System.out.println("EventStore type: " + eventStore.getClass().getName());
+
+ // Debug: Check dispatch interceptors AFTER configuration
+ DispatchInterceptorRegistry dispatchRegistry = configuration.getComponent(DispatchInterceptorRegistry.class);
+ List> eventInterceptors = dispatchRegistry.eventInterceptors(configuration);
+ System.out.println("=== AFTER CONFIG: Event Dispatch Interceptors ===");
+ System.out.println("Number of interceptors: " + eventInterceptors.size());
+
+ // Expected: InterceptingEventStore wrapping MultiTenantEventStore
+ assertThat(eventStore).isInstanceOf(InterceptingEventStore.class)
+ .withFailMessage("EventStore should be wrapped in InterceptingEventStore but was: " + eventStore.getClass().getName());
+ }
+
+ @Test
+ void multiTenantConfigurationCreatesIsolatedSegments() {
+ startApp();
+
+ // Verify EventStore is wrapped with InterceptingEventStore (for dispatch interceptors)
+ EventStore eventStore = configuration.getComponent(EventStore.class);
+ assertThat(eventStore).isInstanceOf(InterceptingEventStore.class);
+
+ // Verify CommandBus and QueryBus are configured
+ assertThat(configuration.getComponent(CommandBus.class)).isNotNull();
+ QueryBus queryBus = configuration.getComponent(QueryBus.class);
+ assertThat(queryBus).isNotNull();
+ }
+
+ @Test
+ void sameEntityIdInDifferentTenantsDoNotConflict() {
+ startApp();
+
+ // Use the same CourseId in both tenants - should NOT conflict
+ CourseId sharedCourseId = CourseId.random();
+
+ // Register in tenant A
+ assertDoesNotThrow(() ->
+ createCourseForTenant(TENANT_A, sharedCourseId, "Introduction to Multi-Tenancy A", 30)
+ );
+
+ // Register same ID in tenant B - should succeed (different tenant segment)
+ assertDoesNotThrow(() ->
+ createCourseForTenant(TENANT_B, sharedCourseId, "Introduction to Multi-Tenancy B", 25)
+ );
+ }
+
+ @Test
+ void eventsFromTenantANotVisibleInTenantB() {
+ startApp();
+
+ // Create courses for both tenants
+ CourseId courseIdA = CourseId.random();
+ CourseId courseIdB = CourseId.random();
+ createCourseForTenant(TENANT_A, courseIdA, "Course for Tenant A", 20);
+ createCourseForTenant(TENANT_B, courseIdB, "Course for Tenant B", 30);
+
+ // Wait for both projections to process
+ Awaitility.await().atMost(Duration.ofSeconds(5)).until(() ->
+ !queryCoursesForTenant(TENANT_A).isEmpty() && !queryCoursesForTenant(TENANT_B).isEmpty()
+ );
+
+ // Tenant A should only see their own course
+ List tenantACourses = queryCoursesForTenant(TENANT_A);
+ assertThat(tenantACourses).hasSize(1);
+ assertThat(tenantACourses.get(0).courseId()).isEqualTo(courseIdA);
+ assertThat(tenantACourses.get(0).name()).isEqualTo("Course for Tenant A");
+
+ // Tenant B should only see their own course - not Tenant A's
+ List tenantBCourses = queryCoursesForTenant(TENANT_B);
+ assertThat(tenantBCourses).hasSize(1);
+ assertThat(tenantBCourses.get(0).courseId()).isEqualTo(courseIdB);
+ assertThat(tenantBCourses.get(0).name()).isEqualTo("Course for Tenant B");
+ }
+
+ @Test
+ void commandWithoutTenantMetadataFails() {
+ startApp();
+
+ CourseId courseId = CourseId.random();
+ CreateCourse command = new CreateCourse(courseId, "Test Course without Tenant", 10);
+
+ // Send without tenant metadata - should fail
+ assertThatThrownBy(() -> commandGateway.sendAndWait(command))
+ .isInstanceOf(NoSuchTenantException.class);
+ }
+
+ @Test
+ void dynamicTenantRegistrationWorks() {
+ startApp();
+
+ TenantDescriptor dynamicTenant = TenantDescriptor.tenantWithId("dynamic-tenant");
+
+ // Initially, operations to dynamic tenant should fail
+ CourseId courseId1 = CourseId.random();
+ assertThatThrownBy(() ->
+ createCourseForTenant(dynamicTenant, courseId1, "Test Course for Dynamic", 15)
+ ).hasCauseInstanceOf(NoSuchTenantException.class);
+
+ // Add the tenant dynamically
+ tenantProvider.addTenant(dynamicTenant);
+
+ // Now operations should succeed
+ CourseId courseId2 = CourseId.random();
+ assertDoesNotThrow(() ->
+ createCourseForTenant(dynamicTenant, courseId2, "Dynamic Tenant Course", 20)
+ );
+
+ // Verify the course was stored
+ Awaitility.await().atMost(Duration.ofSeconds(5)).until(() ->
+ !queryCoursesForTenant(dynamicTenant).isEmpty()
+ );
+ List dynamicTenantCourses = queryCoursesForTenant(dynamicTenant);
+ assertThat(dynamicTenantCourses).hasSize(1);
+ }
+
+ // --- Tests for CommandDispatcher tenant propagation ---
+
+ private Map notificationServices;
+
+ private void startAppWithNotifications() {
+ tenantProvider = new SimpleTenantProvider(List.of(TENANT_A, TENANT_B));
+ notificationServices = new ConcurrentHashMap<>();
+
+ EventSourcingConfigurer configurer = EventSourcingConfigurer.create();
+
+ MultiTenancyConfigurer multiTenancyConfigurer = MultiTenancyConfigurer.enhance(configurer)
+ .registerTenantProvider(config -> tenantProvider)
+ .registerTargetTenantResolver(config -> new MetadataBasedTenantResolver());
+
+ // Domain modules
+ configurer = CreateCourseConfiguration.configure(configurer);
+ configurer = CourseStatsConfiguration.configure(configurer);
+ configurer = CourseNotificationConfiguration.configure(configurer);
+
+ // Tenant-scoped components
+ multiTenancyConfigurer.tenantComponent(CourseStatsRepository.class, tenant -> new InMemoryCourseStatsRepository());
+ multiTenancyConfigurer.tenantComponent(NotificationService.class, tenant -> {
+ var service = new NotificationService(tenant.tenantId());
+ notificationServices.put(tenant.tenantId(), service);
+ return service;
+ });
+
+ // Correlation provider for tenant propagation
+ configurer.messaging(mc -> mc.registerCorrelationDataProvider(config -> message -> {
+ Map result = new HashMap<>();
+ if (message.metadata().containsKey("tenantId")) {
+ result.put("tenantId", message.metadata().get("tenantId"));
+ }
+ return result;
+ }));
+
+ configuration = configurer.start();
+ commandGateway = configuration.getComponent(CommandGateway.class);
+ queryGateway = configuration.getComponent(QueryGateway.class);
+ }
+
+ @Test
+ void commandDispatcherPropagatesTenantContext() {
+ startAppWithNotifications();
+
+ // Create courses for both tenants
+ CourseId courseIdA = CourseId.random();
+ CourseId courseIdB = CourseId.random();
+ createCourseForTenant(TENANT_A, courseIdA, "Course A", 20);
+ createCourseForTenant(TENANT_B, courseIdB, "Course B", 30);
+
+ // Wait for notifications to be sent via the automation
+ Awaitility.await().atMost(Duration.ofSeconds(10)).until(() -> {
+ var serviceA = notificationServices.get(TENANT_A.tenantId());
+ var serviceB = notificationServices.get(TENANT_B.tenantId());
+ return serviceA != null && !serviceA.getSentNotifications().isEmpty()
+ && serviceB != null && !serviceB.getSentNotifications().isEmpty();
+ });
+
+ // Get the tenant-specific NotificationService instances
+ NotificationService tenantAService = notificationServices.get(TENANT_A.tenantId());
+ NotificationService tenantBService = notificationServices.get(TENANT_B.tenantId());
+
+ // Verify each service was constructed with the correct tenant ID
+ assertThat(tenantAService.getTenantId()).isEqualTo(TENANT_A.tenantId());
+ assertThat(tenantBService.getTenantId()).isEqualTo(TENANT_B.tenantId());
+
+ // Verify tenant A's service received ONLY tenant A's notification
+ assertThat(tenantAService.getSentNotifications())
+ .as("Tenant A's service should receive exactly 1 notification")
+ .hasSize(1);
+ assertThat(tenantAService.getSentNotifications().get(0).message())
+ .as("Tenant A's notification should be about Course A")
+ .contains("Course A");
+ assertThat(tenantAService.getSentNotifications().get(0).message())
+ .as("Tenant A's service should NOT have Course B's notification")
+ .doesNotContain("Course B");
+
+ // Verify tenant B's service received ONLY tenant B's notification
+ assertThat(tenantBService.getSentNotifications())
+ .as("Tenant B's service should receive exactly 1 notification")
+ .hasSize(1);
+ assertThat(tenantBService.getSentNotifications().get(0).message())
+ .as("Tenant B's notification should be about Course B")
+ .contains("Course B");
+ assertThat(tenantBService.getSentNotifications().get(0).message())
+ .as("Tenant B's service should NOT have Course A's notification")
+ .doesNotContain("Course A");
+ }
+}
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/automation/CourseNotificationConfiguration.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/automation/CourseNotificationConfiguration.java
new file mode 100644
index 0000000..5b41c1d
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/automation/CourseNotificationConfiguration.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded.automation;
+
+import org.axonframework.eventsourcing.configuration.EventSourcedEntityModule;
+import org.axonframework.eventsourcing.configuration.EventSourcingConfigurer;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.shared.CourseId;
+import org.axonframework.extension.multitenancy.messaging.eventhandling.processing.MultiTenantPooledStreamingEventProcessorModule;
+import org.axonframework.messaging.commandhandling.configuration.CommandHandlingModule;
+
+/**
+ * Configuration for the course notification automation.
+ */
+public class CourseNotificationConfiguration {
+
+ public static EventSourcingConfigurer configure(EventSourcingConfigurer configurer) {
+ // Register the automation's state entity
+ var stateEntity = EventSourcedEntityModule.autodetected(
+ CourseId.class,
+ WhenCourseCreatedThenNotify.State.class
+ );
+ configurer.registerEntity(stateEntity);
+
+ // Register the event processor for the automation
+ var processor = MultiTenantPooledStreamingEventProcessorModule
+ .create("CourseNotifications")
+ .eventHandlingComponents(c -> c
+ .autodetected(cfg -> new WhenCourseCreatedThenNotify())
+ )
+ .notCustomized();
+
+ configurer.componentRegistry(cr -> cr.registerModule(processor.build()));
+
+ // Register the command handler for the automation
+ var commandHandlingModule = CommandHandlingModule
+ .named("SendCourseCreatedNotificationHandler")
+ .commandHandlers()
+ .annotatedCommandHandlingComponent(cfg -> new WhenCourseCreatedThenNotify())
+ .build();
+
+ configurer.registerCommandHandlingModule(commandHandlingModule);
+
+ return configurer;
+ }
+}
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/automation/NotificationService.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/automation/NotificationService.java
new file mode 100644
index 0000000..1f00c53
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/automation/NotificationService.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded.automation;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Simple notification service for testing tenant component isolation.
+ * Each tenant gets its own instance with isolated notification logs.
+ */
+public class NotificationService {
+
+ private static final Logger log = LoggerFactory.getLogger(NotificationService.class);
+
+ public record Notification(String recipientId, String message) {}
+
+ private final String tenantId;
+ private final List sentNotifications = Collections.synchronizedList(new ArrayList<>());
+
+ public NotificationService(String tenantId) {
+ this.tenantId = tenantId;
+ log.info("[TENANT-COMPONENT] NotificationService created for tenant: {}", tenantId);
+ }
+
+ public void sendNotification(Notification notification) {
+ log.info("[TENANT-COMPONENT] NotificationService[{}] received notification: {}",
+ tenantId, notification.message());
+ sentNotifications.add(notification);
+ }
+
+ public List getSentNotifications() {
+ return new ArrayList<>(sentNotifications);
+ }
+
+ public String getTenantId() {
+ return tenantId;
+ }
+}
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/automation/SendCourseCreatedNotification.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/automation/SendCourseCreatedNotification.java
new file mode 100644
index 0000000..ad65831
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/automation/SendCourseCreatedNotification.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded.automation;
+
+import org.axonframework.extension.multitenancy.integrationtests.embedded.shared.CourseId;
+import org.axonframework.modelling.annotation.TargetEntityId;
+
+/**
+ * Command to send a notification about course creation.
+ */
+public record SendCourseCreatedNotification(
+ @TargetEntityId CourseId courseId,
+ String courseName
+) {}
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/automation/WhenCourseCreatedThenNotify.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/automation/WhenCourseCreatedThenNotify.java
new file mode 100644
index 0000000..4648bde
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/automation/WhenCourseCreatedThenNotify.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded.automation;
+
+import org.axonframework.eventsourcing.annotation.EventSourcedEntity;
+import org.axonframework.eventsourcing.annotation.EventSourcingHandler;
+import org.axonframework.eventsourcing.annotation.reflection.EntityCreator;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.event.CourseCreated;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.event.CourseCreatedNotificationSent;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.shared.CourseId;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.shared.CourseTags;
+import org.axonframework.messaging.commandhandling.annotation.CommandHandler;
+import org.axonframework.messaging.commandhandling.gateway.CommandDispatcher;
+import org.axonframework.messaging.core.unitofwork.ProcessingContext;
+import org.axonframework.messaging.eventhandling.annotation.EventHandler;
+import org.axonframework.messaging.eventhandling.gateway.EventAppender;
+import org.axonframework.modelling.StateManager;
+import org.axonframework.modelling.annotation.InjectEntity;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Stateful automation that sends notifications when courses are created.
+ *
+ * This automation tests that tenant context is properly propagated through:
+ *
+ *
Event handlers receiving tenant-scoped events
+ *
CommandDispatcher propagating tenant context to dispatched commands
+ */
+public class WhenCourseCreatedThenNotify {
+
+ @EventHandler
+ CompletableFuture> react(
+ CourseCreated event,
+ CommandDispatcher commandDispatcher,
+ ProcessingContext context
+ ) {
+ var state = context.component(StateManager.class)
+ .loadEntity(State.class, event.courseId(), context)
+ .join();
+
+ return sendNotificationIfNotAlreadySent(event.courseId(), event.name(), state, commandDispatcher);
+ }
+
+ private CompletableFuture> sendNotificationIfNotAlreadySent(
+ CourseId courseId,
+ String courseName,
+ State state,
+ CommandDispatcher commandDispatcher
+ ) {
+ var automationState = state != null ? state : new State();
+
+ if (automationState.notified()) {
+ return CompletableFuture.completedFuture(null);
+ }
+
+ return commandDispatcher.send(
+ new SendCourseCreatedNotification(courseId, courseName),
+ Object.class
+ );
+ }
+
+ @CommandHandler
+ void decide(
+ SendCourseCreatedNotification command,
+ @InjectEntity State state,
+ ProcessingContext context
+ ) {
+ var automationState = state != null ? state : new State();
+
+ if (automationState.notified()) {
+ return;
+ }
+
+ // Send notification using tenant-scoped service from context
+ var notificationService = context.component(NotificationService.class);
+ var message = String.format("Course '%s' has been created", command.courseName());
+ notificationService.sendNotification(
+ new NotificationService.Notification("admin", message)
+ );
+
+ // Record that we've sent the notification
+ var eventAppender = EventAppender.forContext(context);
+ eventAppender.append(new CourseCreatedNotificationSent(
+ command.courseId(),
+ notificationService.getTenantId()
+ ));
+ }
+
+ /**
+ * State entity tracking whether a notification has been sent for a course.
+ */
+ @EventSourcedEntity(tagKey = CourseTags.COURSE_ID)
+ public record State(CourseId courseId, boolean notified) {
+
+ @EntityCreator
+ public State() {
+ this(null, false);
+ }
+
+ @EventSourcingHandler
+ State evolve(CourseCreated event) {
+ return new State(event.courseId(), this.notified);
+ }
+
+ @EventSourcingHandler
+ State evolve(CourseCreatedNotificationSent event) {
+ return new State(event.courseId(), true);
+ }
+ }
+}
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/event/CourseCreated.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/event/CourseCreated.java
new file mode 100644
index 0000000..7974772
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/event/CourseCreated.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded.event;
+
+import org.axonframework.eventsourcing.annotation.EventTag;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.shared.CourseId;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.shared.CourseTags;
+import org.axonframework.messaging.eventhandling.annotation.Event;
+
+/**
+ * Course created event.
+ *
+ * @param courseId course ID.
+ * @param name course name.
+ * @param capacity course capacity.
+ */
+@Event(name = "CourseCreated")
+public record CourseCreated(
+ @EventTag(key = CourseTags.COURSE_ID)
+ CourseId courseId,
+ String name,
+ int capacity
+) {
+}
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/event/CourseCreatedNotificationSent.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/event/CourseCreatedNotificationSent.java
new file mode 100644
index 0000000..6d91bd1
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/event/CourseCreatedNotificationSent.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded.event;
+
+import org.axonframework.eventsourcing.annotation.EventTag;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.shared.CourseId;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.shared.CourseTags;
+
+/**
+ * Event indicating that a course creation notification was sent.
+ */
+public record CourseCreatedNotificationSent(
+ @EventTag(key = CourseTags.COURSE_ID) CourseId courseId,
+ String tenantId
+) {}
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/CourseStatsConfiguration.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/CourseStatsConfiguration.java
new file mode 100644
index 0000000..b184307
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/CourseStatsConfiguration.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded.read.coursestats;
+
+import org.axonframework.eventsourcing.configuration.EventSourcingConfigurer;
+import org.axonframework.extension.multitenancy.messaging.eventhandling.processing.MultiTenantPooledStreamingEventProcessorModule;
+import org.axonframework.messaging.queryhandling.configuration.QueryHandlingModule;
+
+/**
+ * Configuration for course statistics projection and query handlers.
+ *
+ * Demonstrates two approaches for accessing tenant-scoped components using
+ * separate event processors:
+ *
+ *
{@link CoursesStatsProjectorViaInjection} - Direct parameter injection
+ *
{@link CoursesStatsProjectorViaContext} - Via {@code ProcessingContext.component()}
+ *
+ *
+ * Note: Tenant-scoped repository is registered separately via
+ * {@code MultiTenancyConfigurer.tenantComponent()} for global access.
+ */
+public class CourseStatsConfiguration {
+
+ public static final String PROCESSOR_VIA_INJECTION = "Projection_CourseStats_ViaInjection";
+ public static final String PROCESSOR_VIA_CONTEXT = "Projection_CourseStats_ViaContext";
+
+ public static EventSourcingConfigurer configure(EventSourcingConfigurer configurer) {
+ // Processor 1: Demonstrates direct parameter injection approach
+ var injectionProcessor = MultiTenantPooledStreamingEventProcessorModule
+ .create(PROCESSOR_VIA_INJECTION)
+ .eventHandlingComponents(c -> c
+ .autodetected(cfg -> new CoursesStatsProjectorViaInjection())
+ )
+ .notCustomized();
+
+ // Processor 2: Demonstrates ProcessingContext.component() approach
+ var contextProcessor = MultiTenantPooledStreamingEventProcessorModule
+ .create(PROCESSOR_VIA_CONTEXT)
+ .eventHandlingComponents(c -> c
+ .autodetected(cfg -> new CoursesStatsProjectorViaContext())
+ )
+ .notCustomized();
+
+ // Query handler uses direct parameter injection for tenant-scoped repository
+ QueryHandlingModule queryModule = QueryHandlingModule.named("Stats-Handler")
+ .queryHandlers()
+ .annotatedQueryHandlingComponent(cfg -> new FindAllCoursesQueryHandler())
+ .build();
+
+ return configurer
+ .componentRegistry(cr -> cr.registerModule(injectionProcessor.build()))
+ .componentRegistry(cr -> cr.registerModule(contextProcessor.build()))
+ .registerQueryHandlingModule(queryModule);
+ }
+
+ private CourseStatsConfiguration() {
+ // Prevent instantiation
+ }
+}
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/CourseStatsRepository.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/CourseStatsRepository.java
new file mode 100644
index 0000000..e686593
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/CourseStatsRepository.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded.read.coursestats;
+
+import org.axonframework.extension.multitenancy.integrationtests.embedded.shared.CourseId;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Repository of course statistics.
+ */
+public interface CourseStatsRepository {
+
+ CoursesStats save(CoursesStats stats);
+
+ Optional findById(CourseId courseId);
+
+ default CoursesStats findByIdOrThrow(CourseId courseId) {
+ return findById(courseId).orElseThrow(() -> new RuntimeException(
+ "Course with ID " + courseId + " does not exist!"));
+ }
+
+ List findAll();
+}
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/CoursesStats.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/CoursesStats.java
new file mode 100644
index 0000000..bcdd86b
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/CoursesStats.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded.read.coursestats;
+
+import org.axonframework.extension.multitenancy.integrationtests.embedded.shared.CourseId;
+
+/**
+ * Read model representing statistics about a course.
+ *
+ * @param courseId course ID.
+ * @param name name of the course.
+ * @param capacity capacity of the course.
+ */
+public record CoursesStats(CourseId courseId, String name, int capacity) {
+
+ public CoursesStats name(String name) {
+ return new CoursesStats(courseId, name, capacity);
+ }
+
+ public CoursesStats capacity(int capacity) {
+ return new CoursesStats(courseId, name, capacity);
+ }
+}
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/CoursesStatsProjectorViaContext.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/CoursesStatsProjectorViaContext.java
new file mode 100644
index 0000000..1bd98cd
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/CoursesStatsProjectorViaContext.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded.read.coursestats;
+
+import org.axonframework.extension.multitenancy.integrationtests.embedded.event.CourseCreated;
+import org.axonframework.messaging.core.unitofwork.ProcessingContext;
+import org.axonframework.messaging.eventhandling.annotation.EventHandler;
+import org.axonframework.messaging.eventhandling.annotation.SequencingPolicy;
+import org.axonframework.messaging.eventhandling.sequencing.PropertySequencingPolicy;
+import org.axonframework.messaging.queryhandling.QueryUpdateEmitter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+/**
+ * Event handler that demonstrates accessing tenant-scoped components via
+ * {@link ProcessingContext#component(Class)}.
+ *
+ * This approach is useful when you need to access multiple tenant-scoped components
+ * or when the component type isn't known at compile time.
+ *
+ *
+ * @see CoursesStatsProjectorViaInjection for the direct injection approach
+ */
+@SequencingPolicy(type = PropertySequencingPolicy.class, parameters = {"courseId"})
+class CoursesStatsProjectorViaContext {
+
+ private static final Logger logger = LoggerFactory.getLogger(CoursesStatsProjectorViaContext.class);
+
+ @EventHandler
+ void handle(CourseCreated event, ProcessingContext context, QueryUpdateEmitter emitter) {
+ // Get tenant-scoped repository via ProcessingContext.component()
+ CourseStatsRepository repository = context.component(CourseStatsRepository.class);
+
+ var stats = new CoursesStats(
+ event.courseId(),
+ event.name(),
+ event.capacity()
+ );
+ repository.save(stats);
+ logger.info("[VIA-CONTEXT] Saved course: {}", event.courseId());
+
+ emitter.emit(
+ FindAllCourses.class,
+ q -> true,
+ new FindAllCourses.Result(List.of(stats))
+ );
+ }
+}
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/CoursesStatsProjectorViaInjection.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/CoursesStatsProjectorViaInjection.java
new file mode 100644
index 0000000..cfc8121
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/CoursesStatsProjectorViaInjection.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded.read.coursestats;
+
+import org.axonframework.extension.multitenancy.integrationtests.embedded.event.CourseCreated;
+import org.axonframework.messaging.eventhandling.annotation.EventHandler;
+import org.axonframework.messaging.eventhandling.annotation.SequencingPolicy;
+import org.axonframework.messaging.eventhandling.sequencing.PropertySequencingPolicy;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Event handler that demonstrates accessing tenant-scoped components via
+ * direct parameter injection.
+ *
+ * This approach is the most concise when you know the component type at compile time
+ * and only need a single tenant-scoped component.
+ *
+ *
+ * @see CoursesStatsProjectorViaContext for the ProcessingContext approach
+ */
+@SequencingPolicy(type = PropertySequencingPolicy.class, parameters = {"courseId"})
+class CoursesStatsProjectorViaInjection {
+
+ private static final Logger logger = LoggerFactory.getLogger(CoursesStatsProjectorViaInjection.class);
+
+ @EventHandler
+ void handle(CourseCreated event, CourseStatsRepository repository) {
+ // Repository is injected directly as a parameter - tenant is resolved automatically
+ var stats = new CoursesStats(
+ event.courseId(),
+ event.name(),
+ event.capacity()
+ );
+ repository.save(stats);
+ logger.info("[VIA-INJECTION] Saved course: {}", event.courseId());
+ }
+}
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/FindAllCourses.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/FindAllCourses.java
new file mode 100644
index 0000000..5f2a790
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/FindAllCourses.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded.read.coursestats;
+
+import java.util.List;
+
+/**
+ * Query to find all courses.
+ */
+public record FindAllCourses() {
+
+ /**
+ * Result containing all courses.
+ *
+ * @param courses the list of course stats.
+ */
+ public record Result(List courses) {
+ }
+}
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/FindAllCoursesQueryHandler.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/FindAllCoursesQueryHandler.java
new file mode 100644
index 0000000..323b19e
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/FindAllCoursesQueryHandler.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded.read.coursestats;
+
+import org.axonframework.messaging.queryhandling.annotation.QueryHandler;
+
+/**
+ * Query handler for finding all courses.
+ *
+ * Uses parameter injection to receive the tenant-scoped repository.
+ * The repository is resolved based on the query's tenant context.
+ */
+public class FindAllCoursesQueryHandler {
+
+ @QueryHandler
+ FindAllCourses.Result handle(FindAllCourses query, CourseStatsRepository repository) {
+ return new FindAllCourses.Result(repository.findAll());
+ }
+}
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/InMemoryCourseStatsRepository.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/InMemoryCourseStatsRepository.java
new file mode 100644
index 0000000..59c7d09
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/read/coursestats/InMemoryCourseStatsRepository.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded.read.coursestats;
+
+import org.axonframework.extension.multitenancy.integrationtests.embedded.shared.CourseId;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * In-memory implementation of the course statistics repository.
+ */
+public class InMemoryCourseStatsRepository implements CourseStatsRepository {
+
+ private final ConcurrentHashMap stats = new ConcurrentHashMap<>();
+
+ @Override
+ public CoursesStats save(CoursesStats stats) {
+ this.stats.put(stats.courseId(), stats);
+ return stats;
+ }
+
+ @Override
+ public Optional findById(CourseId courseId) {
+ return Optional.ofNullable(stats.get(courseId));
+ }
+
+ @Override
+ public List findAll() {
+ return stats.values().stream().toList();
+ }
+}
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/shared/CourseId.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/shared/CourseId.java
new file mode 100644
index 0000000..be6bcae
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/shared/CourseId.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded.shared;
+
+import jakarta.validation.constraints.NotNull;
+
+import java.util.UUID;
+
+/**
+ * Course ID value object.
+ *
+ * @param raw raw string ID representation.
+ */
+public record CourseId(@NotNull String raw) {
+
+ private final static String ENTITY_TYPE = "Course";
+
+ public CourseId {
+ if (raw == null || raw.isBlank()) {
+ throw new IllegalArgumentException("Course ID cannot be null or empty");
+ }
+ raw = withType(raw);
+ }
+
+ public static CourseId of(String raw) {
+ return new CourseId(raw);
+ }
+
+ public static CourseId random() {
+ return new CourseId(UUID.randomUUID().toString());
+ }
+
+ @Override
+ public String toString() {
+ return raw;
+ }
+
+ private static String withType(String id) {
+ return id.startsWith(ENTITY_TYPE + ":") ? id : ENTITY_TYPE + ":" + id;
+ }
+}
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/shared/CourseTags.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/shared/CourseTags.java
new file mode 100644
index 0000000..65bea81
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/shared/CourseTags.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded.shared;
+
+/**
+ * Event tags for course-related events.
+ */
+public class CourseTags {
+
+ public static final String COURSE_ID = "courseId";
+
+ private CourseTags() {
+ // Prevent instantiation
+ }
+}
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/write/createcourse/CourseCreation.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/write/createcourse/CourseCreation.java
new file mode 100644
index 0000000..9810484
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/write/createcourse/CourseCreation.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded.write.createcourse;
+
+import jakarta.validation.Valid;
+import org.axonframework.eventsourcing.annotation.EventSourcedEntity;
+import org.axonframework.eventsourcing.annotation.EventSourcingHandler;
+import org.axonframework.eventsourcing.annotation.reflection.EntityCreator;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.event.CourseCreated;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.shared.CourseId;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.shared.CourseTags;
+import org.axonframework.messaging.commandhandling.annotation.CommandHandler;
+import org.axonframework.messaging.eventhandling.gateway.EventAppender;
+
+/**
+ * Entity for course creation with internal command handler.
+ */
+@EventSourcedEntity(tagKey = CourseTags.COURSE_ID)
+public class CourseCreation {
+
+ private boolean created = false;
+ private CourseId id;
+ private int capacity;
+
+ @CommandHandler
+ public static void handle(@Valid CreateCourse command, EventAppender appender) {
+ appender.append(
+ new CourseCreated(
+ command.courseId(),
+ command.name(),
+ command.capacity()
+ )
+ );
+ }
+
+ @EntityCreator
+ public CourseCreation() {
+ }
+
+ @EventSourcingHandler
+ public void evolve(CourseCreated courseCreated) {
+ this.id = courseCreated.courseId();
+ this.created = true;
+ this.capacity = courseCreated.capacity();
+ }
+}
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/write/createcourse/CreateCourse.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/write/createcourse/CreateCourse.java
new file mode 100644
index 0000000..354bbd1
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/write/createcourse/CreateCourse.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded.write.createcourse;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.shared.CourseId;
+import org.axonframework.modelling.annotation.TargetEntityId;
+
+/**
+ * Command to create a new course.
+ *
+ * @param courseId course ID.
+ * @param name course name.
+ * @param capacity course capacity.
+ */
+public record CreateCourse(
+ @Valid
+ @NotNull
+ @TargetEntityId
+ CourseId courseId,
+ @NotEmpty
+ @Size(min = 10)
+ String name,
+ @Min(value = 1, message = "Capacity must be a positive integer")
+ int capacity
+) {
+}
diff --git a/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/write/createcourse/CreateCourseConfiguration.java b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/write/createcourse/CreateCourseConfiguration.java
new file mode 100644
index 0000000..2eae101
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/embedded/write/createcourse/CreateCourseConfiguration.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.embedded.write.createcourse;
+
+import org.axonframework.eventsourcing.configuration.EventSourcedEntityModule;
+import org.axonframework.eventsourcing.configuration.EventSourcingConfigurer;
+import org.axonframework.extension.multitenancy.integrationtests.embedded.shared.CourseId;
+
+/**
+ * Configuration for the CreateCourse command handler.
+ */
+public class CreateCourseConfiguration {
+
+ public static EventSourcingConfigurer configure(EventSourcingConfigurer configurer) {
+ var stateEntity = EventSourcedEntityModule
+ .autodetected(CourseId.class, CourseCreation.class);
+
+ return configurer.registerEntity(stateEntity);
+ }
+
+ private CreateCourseConfiguration() {
+ // Prevent instantiation
+ }
+}
diff --git a/multitenancy-integration-tests-embedded/src/test/resources/log4j2.properties b/multitenancy-integration-tests-embedded/src/test/resources/log4j2.properties
new file mode 100644
index 0000000..2a217e4
--- /dev/null
+++ b/multitenancy-integration-tests-embedded/src/test/resources/log4j2.properties
@@ -0,0 +1,19 @@
+# Log4j2 configuration for integration tests
+status = warn
+name = MultitenancyEmbeddedTestConfig
+
+appender.console.type = Console
+appender.console.name = Console
+appender.console.layout.type = PatternLayout
+appender.console.layout.pattern = %d{HH:mm:ss.SSS} [%t] %-5level %-30.30logger{1.} [%X] - %msg%n
+
+rootLogger.level = info
+rootLogger.appenderRef.console.ref = Console
+
+# Reduce noise from Axon Framework internals
+logger.axon.name = org.axonframework
+logger.axon.level = info
+
+# Show multi-tenancy debug info
+logger.multitenancy.name = org.axonframework.extensions.multitenancy
+logger.multitenancy.level = debug
diff --git a/multitenancy-integration-tests-springboot-axonserver/pom.xml b/multitenancy-integration-tests-springboot-axonserver/pom.xml
new file mode 100644
index 0000000..87d708f
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-axonserver/pom.xml
@@ -0,0 +1,207 @@
+
+
+
+
+ 4.0.0
+
+
+ org.axonframework.extensions.multitenancy
+ axon-multitenancy-parent
+ 5.1.0-SNAPSHOT
+
+
+ axon-multitenancy-integration-tests-springboot-axonserver
+ Axon Framework Multi-Tenancy Extension - Spring Boot Axon Server Integration Tests
+
+ Spring Boot integration tests for multi-tenancy with Axon Server using Testcontainers.
+ Tests verify that multi-tenant infrastructure works correctly through Spring Boot autoconfiguration.
+ Note: Without Axon Server license, only 'default' context is available.
+
+
+
+ 4.2.2
+ 3.27.3
+ 2.0.3
+ 2.3.232
+
+ 3.1.0
+
+
+
+
+
+ org.axonframework.extensions.multitenancy
+ axon-multitenancy-spring-boot-starter
+ ${project.version}
+ test
+
+
+
+ org.axonframework.extensions.multitenancy
+ axon-multitenancy-spring
+ ${project.version}
+ test
+
+
+
+
+ org.axonframework.extensions.multitenancy
+ axon-multitenancy-axon-server-connector
+ ${project.version}
+ test
+
+
+
+
+ org.axonframework
+ axon-eventsourcing
+ test
+
+
+ org.axonframework
+ axon-modelling
+ ${axon.version}
+ test
+
+
+ org.axonframework
+ axon-messaging
+ test
+
+
+
+
+ org.axonframework
+ axon-server-connector
+ test
+
+
+
+
+ org.axonframework
+ axon-test
+ ${axon.version}
+ test
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter
+ ${spring.boot.version}
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+ ${spring.boot.version}
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ ${spring.boot.version}
+ test
+
+
+
+
+ com.h2database
+ h2
+ ${h2.version}
+ test
+
+
+
+
+ org.testcontainers
+ testcontainers-junit-jupiter
+ ${testcontainers.version}
+ test
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+
+
+ org.assertj
+ assertj-core
+ ${assertj.version}
+ test
+
+
+ org.awaitility
+ awaitility
+ ${awaitility.version}
+ test
+
+
+
+
+ jakarta.validation
+ jakarta.validation-api
+ 3.1.0
+ test
+
+
+
+
+ org.slf4j
+ slf4j-api
+ ${slf4j.version}
+ test
+
+
+ ch.qos.logback
+ logback-classic
+ 1.5.15
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ **/*IT.java
+ **/*Test.java
+
+ -Daxon.axonserver.suppressDownloadMessage=true
+
+
+
+
+ org.apache.maven.plugins
+ maven-deploy-plugin
+ 3.1.3
+
+ true
+
+
+
+
+
diff --git a/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/SpringBootAxonServerMultiTenantIT.java b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/SpringBootAxonServerMultiTenantIT.java
new file mode 100644
index 0000000..0afabd4
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/SpringBootAxonServerMultiTenantIT.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver;
+
+import org.awaitility.Awaitility;
+import org.axonframework.extension.multitenancy.core.NoSuchTenantException;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.read.coursestats.CourseStatsReadModel;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.read.coursestats.FindAllCourses;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.shared.CourseId;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.write.createcourse.CreateCourse;
+import org.axonframework.messaging.commandhandling.gateway.CommandGateway;
+import org.axonframework.messaging.core.MessageType;
+import org.axonframework.messaging.core.Metadata;
+import org.axonframework.messaging.queryhandling.GenericQueryMessage;
+import org.axonframework.messaging.queryhandling.QueryMessage;
+import org.axonframework.messaging.queryhandling.gateway.QueryGateway;
+import org.axonframework.test.server.AxonServerContainer;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.time.Duration;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.config.TestMultiTenancyConfiguration.DEFAULT_TENANT;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+
+/**
+ * Spring Boot integration tests verifying multi-tenant infrastructure with Axon Server.
+ *
+ * Important: Without an Axon Server license, only the {@code default} context is available.
+ * These tests verify the full command → event → projection → query flow works through the
+ * multi-tenant infrastructure connected to Axon Server with Spring Boot autoconfiguration.
+ *
+ * @author Theo Emanuelsson
+ * @since 5.0.0
+ */
+@SpringBootTest(classes = TestApplication.class)
+@Testcontainers
+class SpringBootAxonServerMultiTenantIT {
+
+ private static final String AXON_SERVER_IMAGE = "docker.axoniq.io/axoniq/axonserver:2025.2.0";
+
+ @Container
+ static final AxonServerContainer axonServer = new AxonServerContainer(AXON_SERVER_IMAGE)
+ .withDevMode(true)
+ .withDcbContext(true)
+ .withReuse(true);
+
+ @DynamicPropertySource
+ static void axonServerProperties(DynamicPropertyRegistry registry) {
+ registry.add("axon.axonserver.servers", axonServer::getAxonServerAddress);
+ }
+
+ @Autowired
+ private CommandGateway commandGateway;
+
+ @Autowired
+ private QueryGateway queryGateway;
+
+ private void createCourseForTenant(TenantDescriptor tenant, CourseId courseId, String name, int capacity) {
+ commandGateway.send(
+ new CreateCourse(courseId, name, capacity),
+ Metadata.with("tenantId", tenant.tenantId()),
+ null
+ ).getResultMessage().join();
+ }
+
+ private List queryCoursesForTenant(TenantDescriptor tenant) {
+ QueryMessage query = new GenericQueryMessage(
+ new MessageType(FindAllCourses.class), new FindAllCourses()
+ ).andMetadata(Metadata.with("tenantId", tenant.tenantId()));
+ return queryGateway.query(query, FindAllCourses.Result.class)
+ .thenApply(FindAllCourses.Result::courses).join();
+ }
+
+ @Test
+ void commandCreatesEventWhichUpdatesProjection() {
+ CourseId courseId = CourseId.random();
+ String courseName = "Test Course " + System.currentTimeMillis();
+
+ // Send command
+ assertDoesNotThrow(() ->
+ createCourseForTenant(DEFAULT_TENANT, courseId, courseName, 50)
+ );
+
+ // Wait for projection to process and verify
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(10))
+ .until(() -> queryCoursesForTenant(DEFAULT_TENANT).stream()
+ .anyMatch(c -> c.getCourseId().equals(courseId.raw())));
+
+ List courses = queryCoursesForTenant(DEFAULT_TENANT);
+ assertThat(courses)
+ .anyMatch(c -> c.getCourseId().equals(courseId.raw()) && c.getName().equals(courseName));
+ }
+
+ @Test
+ void multipleCoursesCanBeCreatedAndQueried() {
+ CourseId course1 = CourseId.random();
+ CourseId course2 = CourseId.random();
+ String name1 = "Course One " + System.currentTimeMillis();
+ String name2 = "Course Two " + System.currentTimeMillis();
+
+ createCourseForTenant(DEFAULT_TENANT, course1, name1, 30);
+ createCourseForTenant(DEFAULT_TENANT, course2, name2, 25);
+
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(10))
+ .until(() -> {
+ List courses = queryCoursesForTenant(DEFAULT_TENANT);
+ return courses.stream().anyMatch(c -> c.getCourseId().equals(course1.raw()))
+ && courses.stream().anyMatch(c -> c.getCourseId().equals(course2.raw()));
+ });
+
+ List courses = queryCoursesForTenant(DEFAULT_TENANT);
+ assertThat(courses).anyMatch(c -> c.getCourseId().equals(course1.raw()) && c.getName().equals(name1));
+ assertThat(courses).anyMatch(c -> c.getCourseId().equals(course2.raw()) && c.getName().equals(name2));
+ }
+
+ @Test
+ void commandToNonExistentTenantFails() {
+ TenantDescriptor nonExistentTenant = TenantDescriptor.tenantWithId("non-existent");
+ CourseId courseId = CourseId.random();
+
+ assertThatThrownBy(() ->
+ createCourseForTenant(nonExistentTenant, courseId, "Should Fail", 10)
+ ).hasCauseInstanceOf(NoSuchTenantException.class);
+ }
+
+ @Test
+ void commandWithoutTenantMetadataFails() {
+ CourseId courseId = CourseId.random();
+ CreateCourse command = new CreateCourse(courseId, "Test Course without Tenant", 10);
+
+ assertThatThrownBy(() -> commandGateway.sendAndWait(command))
+ .isInstanceOf(NoSuchTenantException.class);
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/TestApplication.java b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/TestApplication.java
new file mode 100644
index 0000000..d43f97b
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/TestApplication.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * Spring Boot test application for Axon Server multi-tenancy integration tests.
+ */
+@SpringBootApplication
+public class TestApplication {
+}
diff --git a/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/config/TestMultiTenancyConfiguration.java b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/config/TestMultiTenancyConfiguration.java
new file mode 100644
index 0000000..23c7284
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/config/TestMultiTenancyConfiguration.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.config;
+
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.extension.multitenancy.spring.data.jpa.TenantDataSourceProvider;
+import org.axonframework.extension.multitenancy.spring.data.jpa.TenantEntityManagerFactoryBuilder;
+import org.springframework.boot.jdbc.DataSourceBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import javax.sql.DataSource;
+
+/**
+ * Configuration for multi-tenancy tests with Axon Server.
+ *
+ * Note: Without Axon Server license, only the 'default' context is available.
+ * The tenant provider is discovered automatically from Axon Server contexts.
+ */
+@Configuration
+public class TestMultiTenancyConfiguration {
+
+ public static final TenantDescriptor DEFAULT_TENANT = TenantDescriptor.tenantWithId("default");
+
+ @Bean
+ public TenantDataSourceProvider tenantDataSourceProvider() {
+ Map cache = new ConcurrentHashMap<>();
+ return tenant -> cache.computeIfAbsent(tenant.tenantId(), id ->
+ DataSourceBuilder.create()
+ .url("jdbc:h2:mem:" + id + ";DB_CLOSE_DELAY=-1;MODE=PostgreSQL")
+ .driverClassName("org.h2.Driver")
+ .username("sa")
+ .password("")
+ .build()
+ );
+ }
+
+ /**
+ * Configures the TenantEntityManagerFactoryBuilder with entity packages and DDL auto.
+ * This ensures tenant databases have the schema created and entity classes are scanned.
+ */
+ @Bean
+ public TenantEntityManagerFactoryBuilder tenantEntityManagerFactoryBuilder(
+ TenantDataSourceProvider dataSourceProvider) {
+ return TenantEntityManagerFactoryBuilder
+ .forDataSourceProvider(dataSourceProvider)
+ .packagesToScan("org.axonframework.extensions.multitenancy.integrationtests.springboot.axonserver.domain.read.coursestats")
+ .jpaProperty("hibernate.hbm2ddl.auto", "create-drop")
+ .jpaProperty("hibernate.show_sql", "true")
+ .build();
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/event/CourseCreated.java b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/event/CourseCreated.java
new file mode 100644
index 0000000..8d6d131
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/event/CourseCreated.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.event;
+
+import org.axonframework.eventsourcing.annotation.EventTag;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.shared.CourseId;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.shared.CourseTags;
+import org.axonframework.messaging.eventhandling.annotation.Event;
+
+/**
+ * Course created event.
+ *
+ * @param courseId course ID.
+ * @param name course name.
+ * @param capacity course capacity.
+ */
+@Event(name = "CourseCreated")
+public record CourseCreated(
+ @EventTag(key = CourseTags.COURSE_ID)
+ CourseId courseId,
+ String name,
+ int capacity
+) {
+}
diff --git a/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/read/coursestats/CourseStatsJpaRepository.java b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/read/coursestats/CourseStatsJpaRepository.java
new file mode 100644
index 0000000..9738cee
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/read/coursestats/CourseStatsJpaRepository.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.read.coursestats;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+/**
+ * Spring Data JPA repository for course statistics.
+ *
+ * When {@code axon.multi-tenancy.jpa.tenant-repositories=true}, this repository
+ * is automatically discovered and registered as a tenant component. No special
+ * annotations are required - just extend {@link JpaRepository} (or any Spring Data
+ * repository interface).
+ *
+ * When injected into event/query handlers, this repository is automatically
+ * scoped to the tenant from the message's metadata.
+ */
+public interface CourseStatsJpaRepository extends JpaRepository {
+}
diff --git a/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/read/coursestats/CourseStatsProjector.java b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/read/coursestats/CourseStatsProjector.java
new file mode 100644
index 0000000..160a29d
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/read/coursestats/CourseStatsProjector.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.read.coursestats;
+
+import org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.event.CourseCreated;
+import org.axonframework.messaging.eventhandling.annotation.EventHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+/**
+ * Event handler that projects course events to the JPA repository.
+ * The repository parameter is automatically scoped to the tenant from the event's metadata.
+ */
+@Component
+public class CourseStatsProjector {
+
+ private static final Logger logger = LoggerFactory.getLogger(CourseStatsProjector.class);
+
+ @EventHandler
+ public void on(CourseCreated event, CourseStatsJpaRepository repository) {
+ logger.info("Processing CourseCreated event for course: {}", event.courseId());
+ repository.save(new CourseStatsReadModel(
+ event.courseId().raw(),
+ event.name(),
+ event.capacity()
+ ));
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/read/coursestats/CourseStatsReadModel.java b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/read/coursestats/CourseStatsReadModel.java
new file mode 100644
index 0000000..6036971
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/read/coursestats/CourseStatsReadModel.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.read.coursestats;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+
+/**
+ * JPA entity representing course statistics in the read model.
+ */
+@Entity
+@Table(name = "course_stats")
+public class CourseStatsReadModel {
+
+ @Id
+ private String courseId;
+ private String name;
+ private int capacity;
+
+ public CourseStatsReadModel() {
+ // Required by JPA
+ }
+
+ public CourseStatsReadModel(String courseId, String name, int capacity) {
+ this.courseId = courseId;
+ this.name = name;
+ this.capacity = capacity;
+ }
+
+ public String getCourseId() {
+ return courseId;
+ }
+
+ public void setCourseId(String courseId) {
+ this.courseId = courseId;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public int getCapacity() {
+ return capacity;
+ }
+
+ public void setCapacity(int capacity) {
+ this.capacity = capacity;
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/read/coursestats/FindAllCourses.java b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/read/coursestats/FindAllCourses.java
new file mode 100644
index 0000000..e0f75aa
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/read/coursestats/FindAllCourses.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.read.coursestats;
+
+import java.util.List;
+
+/**
+ * Query to find all courses for a tenant.
+ */
+public record FindAllCourses() {
+
+ /**
+ * Result containing all courses.
+ *
+ * @param courses the list of course stats entities.
+ */
+ public record Result(List courses) {
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/read/coursestats/FindAllCoursesQueryHandler.java b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/read/coursestats/FindAllCoursesQueryHandler.java
new file mode 100644
index 0000000..173c429
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/read/coursestats/FindAllCoursesQueryHandler.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.read.coursestats;
+
+import org.axonframework.messaging.queryhandling.annotation.QueryHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+/**
+ * Query handler for finding all courses.
+ * The repository parameter is automatically scoped to the tenant from the query's metadata.
+ */
+@Component
+public class FindAllCoursesQueryHandler {
+
+ private static final Logger logger = LoggerFactory.getLogger(FindAllCoursesQueryHandler.class);
+
+ @QueryHandler
+ public FindAllCourses.Result handle(FindAllCourses query, CourseStatsJpaRepository repository) {
+ logger.info("Handling FindAllCourses query");
+ return new FindAllCourses.Result(repository.findAll());
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/shared/CourseId.java b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/shared/CourseId.java
new file mode 100644
index 0000000..681d5d9
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/shared/CourseId.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.shared;
+
+import jakarta.validation.constraints.NotNull;
+
+import java.util.UUID;
+
+/**
+ * Course ID value object.
+ *
+ * @param raw raw string ID representation.
+ */
+public record CourseId(@NotNull String raw) {
+
+ private final static String ENTITY_TYPE = "Course";
+
+ public CourseId {
+ if (raw == null || raw.isBlank()) {
+ throw new IllegalArgumentException("Course ID cannot be null or empty");
+ }
+ raw = withType(raw);
+ }
+
+ public static CourseId of(String raw) {
+ return new CourseId(raw);
+ }
+
+ public static CourseId random() {
+ return new CourseId(UUID.randomUUID().toString());
+ }
+
+ @Override
+ public String toString() {
+ return raw;
+ }
+
+ private static String withType(String id) {
+ return id.startsWith(ENTITY_TYPE + ":") ? id : ENTITY_TYPE + ":" + id;
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/shared/CourseTags.java b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/shared/CourseTags.java
new file mode 100644
index 0000000..959c862
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/shared/CourseTags.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.shared;
+
+/**
+ * Event tags for course-related events.
+ */
+public class CourseTags {
+
+ public static final String COURSE_ID = "courseId";
+
+ private CourseTags() {
+ // Prevent instantiation
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/write/createcourse/CourseCreation.java b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/write/createcourse/CourseCreation.java
new file mode 100644
index 0000000..81ed997
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/write/createcourse/CourseCreation.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.write.createcourse;
+
+import jakarta.validation.Valid;
+import org.axonframework.eventsourcing.annotation.EventSourcingHandler;
+import org.axonframework.eventsourcing.annotation.reflection.EntityCreator;
+import org.axonframework.extension.spring.stereotype.EventSourced;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.event.CourseCreated;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.shared.CourseId;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.shared.CourseTags;
+import org.axonframework.messaging.commandhandling.annotation.CommandHandler;
+import org.axonframework.messaging.eventhandling.gateway.EventAppender;
+
+/**
+ * Entity for course creation with internal command handler.
+ * Uses {@code @EventSourced} (Spring stereotype) which includes
+ * {@code @Component} and {@code @Scope("prototype")}.
+ */
+@EventSourced(tagKey = CourseTags.COURSE_ID, idType = CourseId.class)
+public class CourseCreation {
+
+ private boolean created = false;
+ private CourseId id;
+ private int capacity;
+
+ @CommandHandler
+ public static void handle(@Valid CreateCourse command, EventAppender appender) {
+ appender.append(
+ new CourseCreated(
+ command.courseId(),
+ command.name(),
+ command.capacity()
+ )
+ );
+ }
+
+ @EntityCreator
+ public CourseCreation() {
+ }
+
+ @EventSourcingHandler
+ public void evolve(CourseCreated courseCreated) {
+ this.id = courseCreated.courseId();
+ this.created = true;
+ this.capacity = courseCreated.capacity();
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/write/createcourse/CreateCourse.java b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/write/createcourse/CreateCourse.java
new file mode 100644
index 0000000..93b241f
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-axonserver/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/axonserver/domain/write/createcourse/CreateCourse.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.write.createcourse;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.axonserver.domain.shared.CourseId;
+import org.axonframework.modelling.annotation.TargetEntityId;
+
+/**
+ * Command to create a new course.
+ *
+ * @param courseId course ID.
+ * @param name course name.
+ * @param capacity course capacity.
+ */
+public record CreateCourse(
+ @Valid
+ @NotNull
+ @TargetEntityId
+ CourseId courseId,
+ @NotEmpty
+ @Size(min = 10)
+ String name,
+ @Min(value = 1, message = "Capacity must be a positive integer")
+ int capacity
+) {
+}
diff --git a/multitenancy-integration-tests-springboot-axonserver/src/test/resources/application.yml b/multitenancy-integration-tests-springboot-axonserver/src/test/resources/application.yml
new file mode 100644
index 0000000..7e5996c
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-axonserver/src/test/resources/application.yml
@@ -0,0 +1,9 @@
+axon:
+ multi-tenancy:
+ jpa:
+ tenant-repositories: true
+
+logging:
+ level:
+ org.axonframework: INFO
+ org.axonframework.extensions.multitenancy: DEBUG
diff --git a/multitenancy-integration-tests-springboot-embedded/pom.xml b/multitenancy-integration-tests-springboot-embedded/pom.xml
new file mode 100644
index 0000000..350a060
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-embedded/pom.xml
@@ -0,0 +1,173 @@
+
+
+
+
+ 4.0.0
+
+
+ org.axonframework.extensions.multitenancy
+ axon-multitenancy-parent
+ 5.1.0-SNAPSHOT
+
+
+ axon-multitenancy-integration-tests-springboot-embedded
+ Axon Framework Multi-Tenancy Extension - Spring Boot Embedded Integration Tests
+
+ Spring Boot integration tests for multi-tenancy with Spring Data JPA repositories.
+ Tests tenant-scoped repository injection and tenant isolation using H2 in-memory databases.
+
+
+
+ 4.2.2
+ 3.27.3
+ 2.3.232
+
+ 3.1.0
+
+
+
+
+
+ org.axonframework.extensions.multitenancy
+ axon-multitenancy-spring-boot-starter
+ ${project.version}
+ test
+
+
+
+ org.axonframework.extensions.multitenancy
+ axon-multitenancy-spring
+ ${project.version}
+ test
+
+
+
+
+ org.axonframework
+ axon-eventsourcing
+ test
+
+
+ org.axonframework
+ axon-modelling
+ ${axon.version}
+ test
+
+
+ org.axonframework
+ axon-messaging
+ test
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter
+ ${spring.boot.version}
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+ ${spring.boot.version}
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ ${spring.boot.version}
+ test
+
+
+
+
+ com.h2database
+ h2
+ ${h2.version}
+ test
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+
+
+ org.assertj
+ assertj-core
+ ${assertj.version}
+ test
+
+
+ org.awaitility
+ awaitility
+ ${awaitility.version}
+ test
+
+
+
+
+ jakarta.validation
+ jakarta.validation-api
+ 3.1.0
+ test
+
+
+
+
+ org.slf4j
+ slf4j-api
+ ${slf4j.version}
+ test
+
+
+ ch.qos.logback
+ logback-classic
+ 1.5.15
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ **/*IT.java
+ **/*Test.java
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-deploy-plugin
+ 3.1.3
+
+ true
+
+
+
+
+
diff --git a/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/SpringBootEmbeddedMultiTenantIT.java b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/SpringBootEmbeddedMultiTenantIT.java
new file mode 100644
index 0000000..1e57c66
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/SpringBootEmbeddedMultiTenantIT.java
@@ -0,0 +1,277 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.embedded;
+
+import org.awaitility.Awaitility;
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.extension.multitenancy.core.NoSuchTenantException;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.read.coursestats.CourseStatsReadModel;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.read.coursestats.FindAllCourses;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.shared.TenantAuditService;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.shared.CourseId;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.write.createcourse.CreateCourse;
+import org.axonframework.messaging.commandhandling.gateway.CommandGateway;
+import org.axonframework.messaging.core.MessageType;
+import org.axonframework.messaging.core.Metadata;
+import org.axonframework.messaging.queryhandling.GenericQueryMessage;
+import org.axonframework.messaging.queryhandling.QueryMessage;
+import org.axonframework.messaging.queryhandling.gateway.QueryGateway;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.annotation.DirtiesContext;
+
+import java.time.Duration;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.config.TestMultiTenancyConfiguration.TENANT_A;
+import static org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.config.TestMultiTenancyConfiguration.TENANT_B;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+
+/**
+ * Spring Boot integration tests verifying multi-tenant isolation with JPA repositories.
+ *
+ * Key differences from the embedded integration tests:
+ *
+ *
Uses Spring Boot autoconfiguration (not raw Configurer)
+ *
Handlers are auto-discovered via component scanning
+ *
Spring Data JPA repositories are automatically tenant-scoped via
+ * {@link org.axonframework.extension.multitenancy.spring.data.TenantRepositoryParameterResolverFactory}
+ *
+ *
+ * @author Theo Emanuelsson
+ * @since 5.0.0
+ */
+@SpringBootTest(classes = TestApplication.class)
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
+class SpringBootEmbeddedMultiTenantIT {
+
+ @Autowired
+ private CommandGateway commandGateway;
+
+ @Autowired
+ private QueryGateway queryGateway;
+
+ @BeforeEach
+ void setUp() {
+ TenantAuditService.clearAllAuditEntries();
+ }
+
+ private void createCourseForTenant(TenantDescriptor tenant, CourseId courseId, String name, int capacity) {
+ commandGateway.send(
+ new CreateCourse(courseId, name, capacity),
+ Metadata.with("tenantId", tenant.tenantId()),
+ null
+ ).getResultMessage().join();
+ }
+
+ private List queryCoursesForTenant(TenantDescriptor tenant) {
+ QueryMessage query = new GenericQueryMessage(
+ new MessageType(FindAllCourses.class), new FindAllCourses()
+ ).andMetadata(Metadata.with("tenantId", tenant.tenantId()));
+ return queryGateway.query(query, FindAllCourses.Result.class)
+ .thenApply(FindAllCourses.Result::courses).join();
+ }
+
+ @Test
+ void tenantScopedRepositoryInjection_repositorySavesToCorrectTenantDatabase() {
+ CourseId courseIdA = CourseId.random();
+ CourseId courseIdB = CourseId.random();
+
+ // Create courses in different tenants
+ createCourseForTenant(TENANT_A, courseIdA, "Spring Boot Course A", 25);
+ createCourseForTenant(TENANT_B, courseIdB, "Spring Boot Course B", 35);
+
+ // Wait for projections to process
+ Awaitility.await().atMost(Duration.ofSeconds(10)).until(() ->
+ !queryCoursesForTenant(TENANT_A).isEmpty() && !queryCoursesForTenant(TENANT_B).isEmpty()
+ );
+
+ // Verify tenant A only sees their course
+ List tenantACourses = queryCoursesForTenant(TENANT_A);
+ assertThat(tenantACourses).hasSize(1);
+ assertThat(tenantACourses.get(0).getCourseId()).isEqualTo(courseIdA.raw());
+ assertThat(tenantACourses.get(0).getName()).isEqualTo("Spring Boot Course A");
+
+ // Verify tenant B only sees their course
+ List tenantBCourses = queryCoursesForTenant(TENANT_B);
+ assertThat(tenantBCourses).hasSize(1);
+ assertThat(tenantBCourses.get(0).getCourseId()).isEqualTo(courseIdB.raw());
+ assertThat(tenantBCourses.get(0).getName()).isEqualTo("Spring Boot Course B");
+ }
+
+ @Test
+ void fullTenantIsolation_dataFromTenantANotVisibleInTenantB() {
+ CourseId courseIdA1 = CourseId.random();
+ CourseId courseIdA2 = CourseId.random();
+ CourseId courseIdB = CourseId.random();
+
+ // Create two courses for tenant A
+ createCourseForTenant(TENANT_A, courseIdA1, "First Course TenantA", 10);
+ createCourseForTenant(TENANT_A, courseIdA2, "Second Course TenantA", 15);
+
+ // Create one course for tenant B
+ createCourseForTenant(TENANT_B, courseIdB, "Only Course TenantB", 20);
+
+ // Wait for projections
+ Awaitility.await().atMost(Duration.ofSeconds(10)).until(() -> {
+ List a = queryCoursesForTenant(TENANT_A);
+ List b = queryCoursesForTenant(TENANT_B);
+ return a.size() == 2 && b.size() == 1;
+ });
+
+ // Tenant A sees exactly 2 courses
+ List tenantACourses = queryCoursesForTenant(TENANT_A);
+ assertThat(tenantACourses).hasSize(2);
+ assertThat(tenantACourses).extracting(CourseStatsReadModel::getName)
+ .containsExactlyInAnyOrder("First Course TenantA", "Second Course TenantA");
+
+ // Tenant B sees exactly 1 course - NOT tenant A's courses
+ List tenantBCourses = queryCoursesForTenant(TENANT_B);
+ assertThat(tenantBCourses).hasSize(1);
+ assertThat(tenantBCourses.get(0).getName()).isEqualTo("Only Course TenantB");
+ }
+
+ @Test
+ void sameEntityIdInDifferentTenants_noConflicts() {
+ // Use same CourseId in both tenants
+ CourseId sharedCourseId = CourseId.random();
+
+ // Create in tenant A
+ assertDoesNotThrow(() ->
+ createCourseForTenant(TENANT_A, sharedCourseId, "Shared ID Course A", 30)
+ );
+
+ // Create same ID in tenant B - should succeed (different tenant database)
+ assertDoesNotThrow(() ->
+ createCourseForTenant(TENANT_B, sharedCourseId, "Shared ID Course B", 40)
+ );
+
+ // Wait and verify both exist independently
+ Awaitility.await().atMost(Duration.ofSeconds(10)).until(() ->
+ !queryCoursesForTenant(TENANT_A).isEmpty() && !queryCoursesForTenant(TENANT_B).isEmpty()
+ );
+
+ List tenantACourses = queryCoursesForTenant(TENANT_A);
+ List tenantBCourses = queryCoursesForTenant(TENANT_B);
+
+ // Both tenants have a course with the same ID but different data
+ assertThat(tenantACourses).hasSize(1);
+ assertThat(tenantBCourses).hasSize(1);
+ assertThat(tenantACourses.get(0).getCourseId()).isEqualTo(sharedCourseId.raw());
+ assertThat(tenantBCourses.get(0).getCourseId()).isEqualTo(sharedCourseId.raw());
+ assertThat(tenantACourses.get(0).getName()).isEqualTo("Shared ID Course A");
+ assertThat(tenantBCourses.get(0).getName()).isEqualTo("Shared ID Course B");
+ }
+
+ @Test
+ void commandWithoutTenantMetadata_failsWithNoSuchTenantException() {
+ CourseId courseId = CourseId.random();
+ CreateCourse command = new CreateCourse(courseId, "Test Course No Tenant", 10);
+
+ // Send without tenant metadata - should fail
+ assertThatThrownBy(() -> commandGateway.sendAndWait(command))
+ .isInstanceOf(NoSuchTenantException.class);
+ }
+
+ @Test
+ void queryWithoutTenantMetadata_failsWithNoSuchTenantException() {
+ // First create some data
+ createCourseForTenant(TENANT_A, CourseId.random(), "Course for QueryTest", 15);
+
+ // Wait for projection
+ Awaitility.await().atMost(Duration.ofSeconds(10)).until(() ->
+ !queryCoursesForTenant(TENANT_A).isEmpty()
+ );
+
+ // Query without tenant metadata
+ QueryMessage query = new GenericQueryMessage(
+ new MessageType(FindAllCourses.class), new FindAllCourses()
+ );
+ // No tenant metadata added
+
+ assertThatThrownBy(() -> queryGateway.query(query, FindAllCourses.Result.class).join())
+ .hasCauseInstanceOf(NoSuchTenantException.class);
+ }
+
+ @Test
+ void tenantComponentInjection_serviceReceivesCorrectTenantContext() {
+ CourseId courseIdA = CourseId.random();
+ CourseId courseIdB = CourseId.random();
+
+ // Create courses in different tenants
+ createCourseForTenant(TENANT_A, courseIdA, "TenantComponent Test A", 10);
+ createCourseForTenant(TENANT_B, courseIdB, "TenantComponent Test B", 20);
+
+ // Wait for projections to process (which triggers audit recording)
+ Awaitility.await().atMost(Duration.ofSeconds(10)).until(() ->
+ TenantAuditService.getAllAuditEntries().size() >= 2
+ );
+
+ // Verify tenant A's audit service recorded with correct tenant context
+ var tenantAEntries = TenantAuditService.getAuditEntriesForTenant(TENANT_A.tenantId());
+ assertThat(tenantAEntries).hasSize(1);
+ assertThat(tenantAEntries.get(0).action()).contains(courseIdA.raw());
+
+ // Verify tenant B's audit service recorded with correct tenant context
+ var tenantBEntries = TenantAuditService.getAuditEntriesForTenant(TENANT_B.tenantId());
+ assertThat(tenantBEntries).hasSize(1);
+ assertThat(tenantBEntries.get(0).action()).contains(courseIdB.raw());
+
+ // Verify no cross-tenant contamination
+ assertThat(tenantAEntries.get(0).action()).doesNotContain(courseIdB.raw());
+ assertThat(tenantBEntries.get(0).action()).doesNotContain(courseIdA.raw());
+
+ // Verify Spring DI worked - Clock was injected and timestamps are present
+ assertThat(tenantAEntries.get(0).timestamp()).isNotNull();
+ assertThat(tenantBEntries.get(0).timestamp()).isNotNull();
+ }
+
+ @Test
+ void tenantComponentIsolation_eachTenantGetsOwnServiceInstance() {
+ // Create multiple courses for tenant A
+ CourseId courseIdA1 = CourseId.random();
+ CourseId courseIdA2 = CourseId.random();
+ createCourseForTenant(TENANT_A, courseIdA1, "Course A1", 10);
+ createCourseForTenant(TENANT_A, courseIdA2, "Course A2", 15);
+
+ // Create one course for tenant B
+ CourseId courseIdB = CourseId.random();
+ createCourseForTenant(TENANT_B, courseIdB, "Course B", 20);
+
+ // Wait for all projections
+ Awaitility.await().atMost(Duration.ofSeconds(10)).until(() ->
+ TenantAuditService.getAllAuditEntries().size() >= 3
+ );
+
+ // Tenant A should have 2 audit entries
+ var tenantAEntries = TenantAuditService.getAuditEntriesForTenant(TENANT_A.tenantId());
+ assertThat(tenantAEntries).hasSize(2);
+
+ // Tenant B should have 1 audit entry
+ var tenantBEntries = TenantAuditService.getAuditEntriesForTenant(TENANT_B.tenantId());
+ assertThat(tenantBEntries).hasSize(1);
+
+ // All entries for tenant A should have tenant A's ID
+ assertThat(tenantAEntries).allMatch(e -> e.tenantId().equals(TENANT_A.tenantId()));
+
+ // All entries for tenant B should have tenant B's ID
+ assertThat(tenantBEntries).allMatch(e -> e.tenantId().equals(TENANT_B.tenantId()));
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/TestApplication.java b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/TestApplication.java
new file mode 100644
index 0000000..dbbf669
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/TestApplication.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.embedded;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+
+import java.time.Clock;
+
+/**
+ * Spring Boot test application for multi-tenancy integration tests.
+ * Uses auto-discovered handlers and tenant-scoped Spring Data JPA repositories.
+ *
+ * When multi-tenancy is enabled (default), the {@code MultiTenancyAutoConfigurationImportFilter}
+ * automatically excludes conflicting autoconfiguration classes:
+ *
+ *
InfrastructureConfiguration - replaced by MultiTenantMessageHandlerLookup
+ *
JpaAutoConfiguration - replaced by TenantTokenStoreFactory
+ *
JpaEventStoreAutoConfiguration - replaced by MultiTenantEventStore via SPI
+ *
HibernateJpaAutoConfiguration - excluded when TenantDataSourceProvider is present
+ *
JpaRepositoriesAutoConfiguration - excluded when TenantDataSourceProvider is present
+ *
+ */
+@SpringBootApplication
+public class TestApplication {
+
+ /**
+ * Provides a Clock bean to demonstrate that TenantComponent implementations
+ * can receive Spring dependencies via constructor injection.
+ */
+ @Bean
+ public Clock clock() {
+ return Clock.systemUTC();
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/config/TestMultiTenancyConfiguration.java b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/config/TestMultiTenancyConfiguration.java
new file mode 100644
index 0000000..6567821
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/config/TestMultiTenancyConfiguration.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.config;
+
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.extension.multitenancy.core.TenantProvider;
+import org.axonframework.extension.multitenancy.core.SimpleTenantProvider;
+import org.axonframework.extension.multitenancy.spring.data.jpa.TenantDataSourceProvider;
+import org.axonframework.extension.multitenancy.spring.data.jpa.TenantEntityManagerFactoryBuilder;
+import org.springframework.boot.jdbc.DataSourceBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import javax.sql.DataSource;
+
+/**
+ * Configuration for multi-tenancy tests.
+ * Provides tenant definitions and H2 database-per-tenant via {@link TenantDataSourceProvider}.
+ */
+@Configuration
+public class TestMultiTenancyConfiguration {
+
+ public static final TenantDescriptor TENANT_A = TenantDescriptor.tenantWithId("tenant-a");
+ public static final TenantDescriptor TENANT_B = TenantDescriptor.tenantWithId("tenant-b");
+
+ @Bean
+ public TenantProvider tenantProvider() {
+ return new SimpleTenantProvider(List.of(TENANT_A, TENANT_B));
+ }
+
+ @Bean
+ public TenantDataSourceProvider tenantDataSourceProvider() {
+ Map cache = new ConcurrentHashMap<>();
+ return tenant -> cache.computeIfAbsent(tenant.tenantId(), id ->
+ DataSourceBuilder.create()
+ .url("jdbc:h2:mem:" + id + ";DB_CLOSE_DELAY=-1;MODE=PostgreSQL")
+ .driverClassName("org.h2.Driver")
+ .username("sa")
+ .password("")
+ .build()
+ );
+ }
+
+ /**
+ * Configures the TenantEntityManagerFactoryBuilder with entity packages and DDL auto.
+ * This ensures tenant databases have the schema created and entity classes are scanned.
+ */
+ @Bean
+ public TenantEntityManagerFactoryBuilder tenantEntityManagerFactoryBuilder(
+ TenantDataSourceProvider dataSourceProvider) {
+ return TenantEntityManagerFactoryBuilder
+ .forDataSourceProvider(dataSourceProvider)
+ .packagesToScan("org.axonframework.extensions.multitenancy.integrationtests.springboot.embedded.domain.read.coursestats")
+ .jpaProperty("hibernate.hbm2ddl.auto", "create-drop")
+ .jpaProperty("hibernate.show_sql", "true")
+ .build();
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/event/CourseCreated.java b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/event/CourseCreated.java
new file mode 100644
index 0000000..bbce53a
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/event/CourseCreated.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.event;
+
+import org.axonframework.eventsourcing.annotation.EventTag;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.shared.CourseId;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.shared.CourseTags;
+import org.axonframework.messaging.eventhandling.annotation.Event;
+
+/**
+ * Course created event.
+ *
+ * @param courseId course ID.
+ * @param name course name.
+ * @param capacity course capacity.
+ */
+@Event(name = "CourseCreated")
+public record CourseCreated(
+ @EventTag(key = CourseTags.COURSE_ID)
+ CourseId courseId,
+ String name,
+ int capacity
+) {
+}
diff --git a/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/read/coursestats/CourseStatsJpaRepository.java b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/read/coursestats/CourseStatsJpaRepository.java
new file mode 100644
index 0000000..80c5179
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/read/coursestats/CourseStatsJpaRepository.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.read.coursestats;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+/**
+ * Spring Data JPA repository for course statistics.
+ *
+ * When {@code axon.multi-tenancy.jpa.tenant-repositories=true}, this repository
+ * is automatically discovered and registered as a tenant component. No special
+ * annotations are required - just extend {@link JpaRepository} (or any Spring Data
+ * repository interface).
+ *
+ * When injected into event/query handlers, this repository is automatically
+ * scoped to the tenant from the message's metadata.
+ */
+public interface CourseStatsJpaRepository extends JpaRepository {
+}
diff --git a/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/read/coursestats/CourseStatsProjector.java b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/read/coursestats/CourseStatsProjector.java
new file mode 100644
index 0000000..1717868
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/read/coursestats/CourseStatsProjector.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.read.coursestats;
+
+import org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.event.CourseCreated;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.shared.TenantAuditService;
+import org.axonframework.messaging.eventhandling.annotation.EventHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+/**
+ * Event handler that projects course events to the JPA repository.
+ * The repository parameter is automatically scoped to the tenant from the event's metadata.
+ * The audit service is also tenant-scoped via {@link org.axonframework.extension.multitenancy.spring.TenantComponent}.
+ */
+@Component
+public class CourseStatsProjector {
+
+ private static final Logger logger = LoggerFactory.getLogger(CourseStatsProjector.class);
+
+ @EventHandler
+ public void on(CourseCreated event, CourseStatsJpaRepository repository, TenantAuditService auditService) {
+ logger.info("Processing CourseCreated event for course: {} in tenant: {}",
+ event.courseId(), auditService.getTenantId());
+
+ // Record audit entry using tenant-scoped service
+ auditService.recordAudit("course_created:" + event.courseId().raw());
+
+ repository.save(new CourseStatsReadModel(
+ event.courseId().raw(),
+ event.name(),
+ event.capacity()
+ ));
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/read/coursestats/CourseStatsReadModel.java b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/read/coursestats/CourseStatsReadModel.java
new file mode 100644
index 0000000..b9355c5
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/read/coursestats/CourseStatsReadModel.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.read.coursestats;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+
+/**
+ * JPA entity representing course statistics in the read model.
+ */
+@Entity
+@Table(name = "course_stats")
+public class CourseStatsReadModel {
+
+ @Id
+ private String courseId;
+ private String name;
+ private int capacity;
+
+ public CourseStatsReadModel() {
+ // Required by JPA
+ }
+
+ public CourseStatsReadModel(String courseId, String name, int capacity) {
+ this.courseId = courseId;
+ this.name = name;
+ this.capacity = capacity;
+ }
+
+ public String getCourseId() {
+ return courseId;
+ }
+
+ public void setCourseId(String courseId) {
+ this.courseId = courseId;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public int getCapacity() {
+ return capacity;
+ }
+
+ public void setCapacity(int capacity) {
+ this.capacity = capacity;
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/read/coursestats/FindAllCourses.java b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/read/coursestats/FindAllCourses.java
new file mode 100644
index 0000000..9c93665
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/read/coursestats/FindAllCourses.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.read.coursestats;
+
+import java.util.List;
+
+/**
+ * Query to find all courses for a tenant.
+ */
+public record FindAllCourses() {
+
+ /**
+ * Result containing all courses.
+ *
+ * @param courses the list of course stats entities.
+ */
+ public record Result(List courses) {
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/read/coursestats/FindAllCoursesQueryHandler.java b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/read/coursestats/FindAllCoursesQueryHandler.java
new file mode 100644
index 0000000..a8764fa
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/read/coursestats/FindAllCoursesQueryHandler.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.read.coursestats;
+
+import org.axonframework.messaging.queryhandling.annotation.QueryHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+/**
+ * Query handler for finding all courses.
+ * The repository parameter is automatically scoped to the tenant from the query's metadata.
+ */
+@Component
+public class FindAllCoursesQueryHandler {
+
+ private static final Logger logger = LoggerFactory.getLogger(FindAllCoursesQueryHandler.class);
+
+ @QueryHandler
+ public FindAllCourses.Result handle(FindAllCourses query, CourseStatsJpaRepository repository) {
+ logger.info("Handling FindAllCourses query");
+ return new FindAllCourses.Result(repository.findAll());
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/shared/CourseId.java b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/shared/CourseId.java
new file mode 100644
index 0000000..09a1ea4
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/shared/CourseId.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.shared;
+
+import jakarta.validation.constraints.NotNull;
+
+import java.util.UUID;
+
+/**
+ * Course ID value object.
+ *
+ * @param raw raw string ID representation.
+ */
+public record CourseId(@NotNull String raw) {
+
+ private final static String ENTITY_TYPE = "Course";
+
+ public CourseId {
+ if (raw == null || raw.isBlank()) {
+ throw new IllegalArgumentException("Course ID cannot be null or empty");
+ }
+ raw = withType(raw);
+ }
+
+ public static CourseId of(String raw) {
+ return new CourseId(raw);
+ }
+
+ public static CourseId random() {
+ return new CourseId(UUID.randomUUID().toString());
+ }
+
+ @Override
+ public String toString() {
+ return raw;
+ }
+
+ private static String withType(String id) {
+ return id.startsWith(ENTITY_TYPE + ":") ? id : ENTITY_TYPE + ":" + id;
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/shared/CourseTags.java b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/shared/CourseTags.java
new file mode 100644
index 0000000..b70cc67
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/shared/CourseTags.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.shared;
+
+/**
+ * Event tags for course-related events.
+ */
+public class CourseTags {
+
+ public static final String COURSE_ID = "courseId";
+
+ private CourseTags() {
+ // Prevent instantiation
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/shared/TenantAuditService.java b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/shared/TenantAuditService.java
new file mode 100644
index 0000000..d979fe6
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/shared/TenantAuditService.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.shared;
+
+import org.axonframework.extension.multitenancy.core.TenantDescriptor;
+import org.axonframework.extension.multitenancy.spring.TenantComponent;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/**
+ * A simple tenant-scoped service for testing {@link TenantComponent} auto-configuration.
+ *
+ * This service records audit entries for each tenant. Each tenant gets their own instance
+ * with isolated audit logs, demonstrating tenant-scoped component injection.
+ *
+ * Note: This class does NOT have @Component annotation - it's discovered and managed
+ * by the TenantComponent auto-configuration.
+ *
+ * This class also demonstrates that TenantComponent implementations can receive Spring
+ * dependencies (like {@link Clock}) via constructor injection.
+ */
+public class TenantAuditService implements TenantComponent {
+
+ /**
+ * Static registry to track all audit entries across all tenant instances.
+ * Used by tests to verify tenant isolation.
+ */
+ private static final List ALL_AUDIT_ENTRIES = new CopyOnWriteArrayList<>();
+
+ private final Clock clock;
+ private final String tenantId;
+ private final List localAuditLog = new ArrayList<>();
+
+ /**
+ * Constructor for the factory instance (no tenant context).
+ * Spring will use this constructor when creating the factory via AutowireCapableBeanFactory,
+ * injecting the Clock bean.
+ */
+ public TenantAuditService(Clock clock) {
+ this.clock = clock;
+ this.tenantId = null;
+ }
+
+ /**
+ * Constructor for tenant-specific instances.
+ * The Clock dependency is passed from the factory instance.
+ */
+ private TenantAuditService(Clock clock, String tenantId) {
+ this.clock = clock;
+ this.tenantId = tenantId;
+ }
+
+ @Override
+ public TenantAuditService createForTenant(TenantDescriptor tenant) {
+ return new TenantAuditService(this.clock, tenant.tenantId());
+ }
+
+ /**
+ * Records an audit entry for this tenant.
+ * Uses the injected Clock to timestamp the entry, proving Spring DI works.
+ */
+ public void recordAudit(String action) {
+ if (tenantId == null) {
+ throw new IllegalStateException("Cannot record audit on factory instance - no tenant context");
+ }
+ if (clock == null) {
+ throw new IllegalStateException("Clock was not injected - Spring DI failed!");
+ }
+ Instant timestamp = clock.instant();
+ String entry = tenantId + ":" + action + "@" + timestamp;
+ localAuditLog.add(entry);
+ ALL_AUDIT_ENTRIES.add(new AuditEntry(tenantId, action, timestamp));
+ }
+
+ /**
+ * Returns the tenant ID this service instance is scoped to.
+ */
+ public String getTenantId() {
+ return tenantId;
+ }
+
+ /**
+ * Returns the local audit log for this tenant instance.
+ */
+ public List getLocalAuditLog() {
+ return Collections.unmodifiableList(localAuditLog);
+ }
+
+ /**
+ * Returns all audit entries across all tenants (for test verification).
+ */
+ public static List getAllAuditEntries() {
+ return Collections.unmodifiableList(ALL_AUDIT_ENTRIES);
+ }
+
+ /**
+ * Clears all audit entries (call in test setup).
+ */
+ public static void clearAllAuditEntries() {
+ ALL_AUDIT_ENTRIES.clear();
+ }
+
+ /**
+ * Returns audit entries for a specific tenant.
+ */
+ public static List getAuditEntriesForTenant(String tenantId) {
+ return ALL_AUDIT_ENTRIES.stream()
+ .filter(e -> e.tenantId().equals(tenantId))
+ .toList();
+ }
+
+ /**
+ * Audit entry record with timestamp to prove Clock injection works.
+ */
+ public record AuditEntry(String tenantId, String action, Instant timestamp) {
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/write/createcourse/CourseCreation.java b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/write/createcourse/CourseCreation.java
new file mode 100644
index 0000000..bcad722
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/write/createcourse/CourseCreation.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.write.createcourse;
+
+import jakarta.validation.Valid;
+import org.axonframework.eventsourcing.annotation.EventSourcingHandler;
+import org.axonframework.eventsourcing.annotation.reflection.EntityCreator;
+import org.axonframework.extension.spring.stereotype.EventSourced;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.event.CourseCreated;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.shared.CourseId;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.shared.CourseTags;
+import org.axonframework.messaging.commandhandling.annotation.CommandHandler;
+import org.axonframework.messaging.eventhandling.gateway.EventAppender;
+
+/**
+ * Entity for course creation with internal command handler.
+ * Uses {@code @EventSourced} (Spring stereotype) which includes
+ * {@code @Component} and {@code @Scope("prototype")}.
+ */
+@EventSourced(tagKey = CourseTags.COURSE_ID, idType = CourseId.class)
+public class CourseCreation {
+
+ private boolean created = false;
+ private CourseId id;
+ private int capacity;
+
+ @CommandHandler
+ public static void handle(@Valid CreateCourse command, EventAppender appender) {
+ appender.append(
+ new CourseCreated(
+ command.courseId(),
+ command.name(),
+ command.capacity()
+ )
+ );
+ }
+
+ @EntityCreator
+ public CourseCreation() {
+ }
+
+ @EventSourcingHandler
+ public void evolve(CourseCreated courseCreated) {
+ this.id = courseCreated.courseId();
+ this.created = true;
+ this.capacity = courseCreated.capacity();
+ }
+}
diff --git a/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/write/createcourse/CreateCourse.java b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/write/createcourse/CreateCourse.java
new file mode 100644
index 0000000..bee35d2
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-embedded/src/test/java/org/axonframework/extension/multitenancy/integrationtests/springboot/embedded/domain/write/createcourse/CreateCourse.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.write.createcourse;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import org.axonframework.extension.multitenancy.integrationtests.springboot.embedded.domain.shared.CourseId;
+import org.axonframework.modelling.annotation.TargetEntityId;
+
+/**
+ * Command to create a new course.
+ *
+ * @param courseId course ID.
+ * @param name course name.
+ * @param capacity course capacity.
+ */
+public record CreateCourse(
+ @Valid
+ @NotNull
+ @TargetEntityId
+ CourseId courseId,
+ @NotEmpty
+ @Size(min = 10)
+ String name,
+ @Min(value = 1, message = "Capacity must be a positive integer")
+ int capacity
+) {
+}
diff --git a/multitenancy-integration-tests-springboot-embedded/src/test/resources/application.yml b/multitenancy-integration-tests-springboot-embedded/src/test/resources/application.yml
new file mode 100644
index 0000000..54b2976
--- /dev/null
+++ b/multitenancy-integration-tests-springboot-embedded/src/test/resources/application.yml
@@ -0,0 +1,11 @@
+axon:
+ axonserver:
+ enabled: false
+ multi-tenancy:
+ jpa:
+ tenant-repositories: true
+
+logging:
+ level:
+ org.axonframework: INFO
+ org.axonframework.extensions.multitenancy: DEBUG
diff --git a/multitenancy-spring-boot-3-integrationtests/pom.xml b/multitenancy-spring-boot-3-integrationtests/pom.xml
deleted file mode 100644
index 7d24cf6..0000000
--- a/multitenancy-spring-boot-3-integrationtests/pom.xml
+++ /dev/null
@@ -1,213 +0,0 @@
-
-
-
-
-
- org.springframework.boot
- spring-boot-starter-parent
- 3.3.4
-
-
- 4.0.0
-
- axon-multitenancy-spring-boot-3-integrationtests
- org.axonframework.extensions.multitenancy
-
- Axon Framework Multi-Tenancy Extension - Spring Boot 3 Integration Tests
-
- Module used to test the integration with Spring Boot 3
-
- 4.12.1-SNAPSHOT
-
- jar
-
-
- 4.12.0
- 1.21.3
-
- 3.4.2
- 0.8.13
-
-
-
-
-
- org.axonframework
- axon-messaging
- ${axon.version}
-
-
- org.axonframework
- axon-spring-boot-autoconfigure
- ${axon.version}
-
-
- org.axonframework
- axon-test
- ${axon.version}
- test
-
-
- org.axonframework.extensions.multitenancy
- axon-multitenancy-spring-boot-starter
- ${project.version}
-
-
-
- org.springframework.boot
- spring-boot-starter
-
-
- org.yaml
- snakeyaml
-
-
-
-
- org.springframework.boot
- spring-boot-starter-log4j2
-
-
- org.springframework.boot
- spring-boot-starter-test
-
-
-
- com.fasterxml.jackson.datatype
- jackson-datatype-jsr310
- test
-
-
-
- org.awaitility
- awaitility
- test
-
-
- org.testcontainers
- junit-jupiter
- test
-
-
- org.testcontainers
- testcontainers
- test
-
-
-
-
-
-
- org.testcontainers
- testcontainers-bom
- ${testcoontainers.version}
- pom
- import
-
-
-
-
-
-
-
-
- org.apache.maven.plugins
- maven-compiler-plugin
-
- 17
- 17
- UTF-8
- true
- true
-
-
-
-
- org.apache.maven.plugins
- maven-jar-plugin
- ${jar-plugin.version}
-
-
-
- true
-
-
-
-
-
-
- org.apache.maven.plugins
- maven-deploy-plugin
- ${maven-deploy-plugin.version}
-
- true
-
-
-
-
-
-
-
- coverage
-
- false
-
- coverage
-
-
-
-
-
- org.apache.maven.plugins
- maven-surefire-plugin
- ${maven-surefire-plugin.version}
-
-
- -Djava.awt.headless=true ${surefireArgLine}
-
-
-
-
- org.jacoco
- jacoco-maven-plugin
- ${jacoco-plugin.version}
-
-
- prepare-agent-for-unit-tests
- initialize
-
- prepare-agent
-
-
- surefireArgLine
- ${project.build.directory}/jacoco-ut.exec
-
-
-
-
-
-
-
-
-
-
-
- sonatype-snapshots
- https://oss.sonatype.org/content/repositories/snapshots
-
-
-
\ No newline at end of file
diff --git a/multitenancy-spring-boot-3-integrationtests/src/test/java/org/axonframework/extensions/multitenancy/integration/MultiTenancyIntegrationTest.java b/multitenancy-spring-boot-3-integrationtests/src/test/java/org/axonframework/extensions/multitenancy/integration/MultiTenancyIntegrationTest.java
deleted file mode 100644
index f5747ba..0000000
--- a/multitenancy-spring-boot-3-integrationtests/src/test/java/org/axonframework/extensions/multitenancy/integration/MultiTenancyIntegrationTest.java
+++ /dev/null
@@ -1,217 +0,0 @@
-/*
- * Copyright (c) 2010-2023. Axon Framework
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.axonframework.extensions.multitenancy.integration;
-
-import org.axonframework.axonserver.connector.AxonServerConfiguration;
-import org.axonframework.commandhandling.CommandBus;
-import org.axonframework.commandhandling.CommandMessage;
-import org.axonframework.commandhandling.GenericCommandMessage;
-import org.axonframework.extensions.multitenancy.components.NoSuchTenantException;
-import org.axonframework.extensions.multitenancy.components.TenantDescriptor;
-import org.axonframework.extensions.multitenancy.components.commandhandeling.MultiTenantCommandBus;
-import org.axonframework.extensions.multitenancy.components.queryhandeling.MultiTenantQueryBus;
-import org.axonframework.extensions.multitenancy.components.queryhandeling.MultiTenantQueryUpdateEmitter;
-import org.axonframework.messaging.GenericMessage;
-import org.axonframework.messaging.Message;
-import org.axonframework.messaging.responsetypes.InstanceResponseType;
-import org.axonframework.queryhandling.GenericQueryMessage;
-import org.axonframework.queryhandling.QueryBus;
-import org.axonframework.queryhandling.QueryMessage;
-import org.axonframework.queryhandling.QueryResponseMessage;
-import org.axonframework.queryhandling.QueryUpdateEmitter;
-import org.axonframework.test.server.AxonServerContainer;
-import org.junit.jupiter.api.*;
-import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
-import org.springframework.boot.test.context.runner.ApplicationContextRunner;
-import org.springframework.context.annotation.EnableMBeanExport;
-import org.springframework.jmx.support.RegistrationPolicy;
-import org.springframework.test.context.ContextConfiguration;
-import org.testcontainers.junit.jupiter.Container;
-import org.testcontainers.junit.jupiter.Testcontainers;
-
-import java.time.Duration;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.atomic.AtomicReference;
-
-import static org.awaitility.Awaitility.await;
-import static org.axonframework.extensions.multitenancy.autoconfig.TenantConfiguration.TENANT_CORRELATION_KEY;
-import static org.junit.jupiter.api.Assertions.*;
-
-@Testcontainers
-class MultiTenancyIntegrationTest {
-
- // The tenantId is "default" as the used Axon Server test container only allows a single context.
- private static final String TENANT_ID = "default";
-
- private ApplicationContextRunner testApplicationContext;
-
- @Container
- private static final AxonServerContainer AXON_SERVER_CONTAINER = new AxonServerContainer();
-
- @BeforeEach
- void setUp() {
- testApplicationContext = new ApplicationContextRunner()
- .withSystemProperties("disable-axoniq-console-message=true")
- .withPropertyValues("axon.axonserver.enabled=true")
- .withPropertyValues("axon.axonserver.servers=" + AXON_SERVER_CONTAINER.getAxonServerAddress())
- .withUserConfiguration(DefaultContext.class);
- }
-
- @Test
- void willUseRegisteredTenantForCommand() {
- testApplicationContext.run(context -> {
- CommandBus commandBus = context.getBean(CommandBus.class);
- assertNotNull(commandBus);
- assertTrue(commandBus instanceof MultiTenantCommandBus);
- registerTenant((MultiTenantCommandBus) commandBus);
- subscribeCommandHandler(commandBus);
- executeCommand(commandBus);
- });
- }
-
- @Test
- void commandFailsWhenNoTenantSet() {
- testApplicationContext.run(context -> {
- CommandBus commandBus = context.getBean(CommandBus.class);
- assertNotNull(commandBus);
- assertTrue(commandBus instanceof MultiTenantCommandBus);
- executeCommandWhileTenantNotSet(commandBus);
- });
- }
-
- @Test
- void willUseRegisteredTenantForQuery() {
- testApplicationContext.run(context -> {
- QueryUpdateEmitter emitter = context.getBean(QueryUpdateEmitter.class);
- assertNotNull(emitter);
- assertTrue(emitter instanceof MultiTenantQueryUpdateEmitter);
- QueryBus queryBus = context.getBean(QueryBus.class);
- assertNotNull(queryBus);
- assertTrue(queryBus instanceof MultiTenantQueryBus);
- registerTenant(
- (MultiTenantQueryUpdateEmitter) emitter, (MultiTenantQueryBus) queryBus
- );
- subscribeQueryHandler(queryBus);
- executeQuery(queryBus);
- });
- }
-
- @Test
- void queryFailsWhenNoTenantSet() {
- testApplicationContext.run(context -> {
- QueryBus queryBus = context.getBean(QueryBus.class);
- assertNotNull(queryBus);
- assertTrue(queryBus instanceof MultiTenantQueryBus);
- executeQueryWhileTenantNotSet(queryBus);
- });
- }
-
- @Test
- void heartBeatDisabled() {
- testApplicationContext.run(context -> {
- AxonServerConfiguration axonServerConfiguration = context.getBean(AxonServerConfiguration.class);
- assertNotNull(axonServerConfiguration);
- assertFalse(axonServerConfiguration.getHeartbeat().isEnabled());
- });
- }
-
- @Test
- void heartBeatEnabled() {
- testApplicationContext.withPropertyValues("axon.axonserver.heartbeat.enabled=true")
- .run(context -> {
- AxonServerConfiguration axonServerConfiguration =
- context.getBean(AxonServerConfiguration.class);
- assertNotNull(axonServerConfiguration);
- assertTrue(axonServerConfiguration.getHeartbeat().isEnabled());
- });
- }
-
- private void registerTenant(MultiTenantCommandBus commandBus) {
- //noinspection resource
- commandBus.registerTenant(TenantDescriptor.tenantWithId(TENANT_ID));
- }
-
- private void registerTenant(MultiTenantQueryUpdateEmitter emitter, MultiTenantQueryBus queryBus) {
- //noinspection resource
- emitter.registerTenant(TenantDescriptor.tenantWithId(TENANT_ID));
- //noinspection resource
- queryBus.registerTenant(TenantDescriptor.tenantWithId(TENANT_ID));
- }
-
- private void subscribeCommandHandler(CommandBus commandBus) {
- //noinspection resource
- commandBus.subscribe("testCommand", e -> "correct");
- }
-
- private void subscribeQueryHandler(QueryBus queryBus) {
- //noinspection resource
- queryBus.subscribe("testQuery", String.class, e -> "correct");
- }
-
- private void executeCommand(CommandBus commandBus) {
- Message message = new GenericMessage<>("hi");
- Map metadata = new HashMap<>();
- metadata.put(TENANT_CORRELATION_KEY, TENANT_ID);
- CommandMessage command = new GenericCommandMessage<>(message, "testCommand").withMetaData(metadata);
- AtomicReference result = new AtomicReference<>();
- commandBus.dispatch(
- command,
- (commandMessage, commandResultMessage) -> result.set((String) commandResultMessage.getPayload())
- );
- await().atMost(Duration.ofSeconds(5)).until(() -> result.get() != null);
- assertEquals("correct", result.get());
- }
-
- private void executeQuery(QueryBus queryBus) throws ExecutionException, InterruptedException {
- Message message = new GenericMessage<>("hi");
- Map metadata = new HashMap<>();
- metadata.put(TENANT_CORRELATION_KEY, TENANT_ID);
- QueryMessage query =
- new GenericQueryMessage<>(message, "testQuery", new InstanceResponseType<>(String.class))
- .withMetaData(metadata);
- QueryResponseMessage> responseMessage = queryBus.query(query).get();
- assertEquals("correct", responseMessage.getPayload());
- }
-
- private void executeCommandWhileTenantNotSet(CommandBus commandBus) {
- Message message = new GenericMessage<>("hi");
- CommandMessage command = new GenericCommandMessage<>(message, "anotherCommand");
- AtomicReference result = new AtomicReference<>();
- commandBus.dispatch(
- command,
- (commandMessage, commandResultMessage) -> result.set(commandResultMessage.exceptionResult())
- );
- await().atMost(Duration.ofSeconds(5)).until(() -> result.get() != null);
- assertTrue(result.get() instanceof NoSuchTenantException);
- }
-
- private void executeQueryWhileTenantNotSet(QueryBus queryBus) {
- Message message = new GenericMessage<>("hi");
- QueryMessage query =
- new GenericQueryMessage<>(message, "anotherQuery", new InstanceResponseType<>(String.class));
- assertThrows(NoSuchTenantException.class, () -> queryBus.query(query));
- }
-
- @ContextConfiguration
- @EnableAutoConfiguration
- @EnableMBeanExport(registration = RegistrationPolicy.IGNORE_EXISTING)
- public static class DefaultContext {
-
- }
-}
diff --git a/multitenancy-spring-boot-3-integrationtests/src/test/resources/logback-test.xml b/multitenancy-spring-boot-3-integrationtests/src/test/resources/logback-test.xml
deleted file mode 100644
index 1c6d76e..0000000
--- a/multitenancy-spring-boot-3-integrationtests/src/test/resources/logback-test.xml
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
- %d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n
-
-
-
-
-
-
-
-
-
-
-
diff --git a/multitenancy-spring-boot-autoconfigure/pom.xml b/multitenancy-spring-boot-autoconfigure/pom.xml
index 7b1212e..791fb6a 100644
--- a/multitenancy-spring-boot-autoconfigure/pom.xml
+++ b/multitenancy-spring-boot-autoconfigure/pom.xml
@@ -19,7 +19,7 @@
axon-multitenancy-parentorg.axonframework.extensions.multitenancy
- 4.12.1-SNAPSHOT
+ 5.1.0-SNAPSHOTaxon-multitenancy-spring-boot-autoconfigure
@@ -31,75 +31,93 @@
- 5.3.393.27.3
-
-
- org.axonframework
- axon-configuration
-
+
org.axonframeworkaxon-messaging
+
- org.axonframework
- axon-server-connector
-
-
- org.axonframework
+ org.axonframework.extensions.springaxon-spring
- compile
- org.axonframework
+ org.axonframework.extensions.springaxon-spring-boot-autoconfigure
- provided
+
org.axonframework.extensions.multitenancyaxon-multitenancy${project.version}
-
+
+ org.axonframework.extensions.multitenancy
+ axon-multitenancy-spring
+ ${project.version}
+ true
+
+
+ org.axonframework.extensions.multitenancy
+ axon-multitenancy-axon-server-connector
+ ${project.version}
+ true
+
+
+
+ org.springframework.data
+ spring-data-jpa
+ true
+
+
org.springframework.bootspring-boot-starter${spring.boot.version}true
-
-
- org.yaml
- snakeyaml
-
- org.springframework.boot
- spring-boot-starter-test
+ spring-boot-configuration-processor${spring.boot.version}
- test
-
-
- net.minidev
- json-smart
-
-
+ trueorg.springframework.boot
- spring-boot-test-autoconfigure
+ spring-boot-autoconfigure-processor${spring.boot.version}
- test
+ true
+
org.springframeworkspring-jdbc
- ${spring.web.version}
- compile
+ ${spring.version}
+ true
+
+
+
+ org.springframework
+ spring-r2dbc
+ ${spring.version}
+ true
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ ${spring.boot.version}
+ test
+
+
+ org.springframework.boot
+ spring-boot-test-autoconfigure
+ ${spring.boot.version}
+ testorg.springframework
diff --git a/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancyAutoConfiguration.java b/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancyAutoConfiguration.java
new file mode 100644
index 0000000..a37c914
--- /dev/null
+++ b/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancyAutoConfiguration.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.autoconfig;
+
+import org.axonframework.common.configuration.Configuration;
+import org.axonframework.eventsourcing.eventstore.EventStore;
+import org.axonframework.extension.multitenancy.core.MetadataBasedTenantResolver;
+import org.axonframework.extension.multitenancy.core.TargetTenantResolver;
+import org.axonframework.extension.multitenancy.core.TenantConnectPredicate;
+import org.axonframework.extension.multitenancy.core.configuration.MultiTenantEventProcessorPredicate;
+import org.axonframework.messaging.core.Message;
+import org.axonframework.messaging.core.correlation.CorrelationDataProvider;
+import org.axonframework.messaging.eventhandling.EventSink;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigureAfter;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Primary;
+
+/**
+ * Auto-configuration for multi-tenant Axon Framework support beans.
+ *
+ * This configuration provides:
+ *
+ *
Property binding via {@link MultiTenancyProperties}
+ *
Default {@link TargetTenantResolver} using message metadata
+ *
Default {@link TenantConnectPredicate} accepting all tenants
+ *
{@link CorrelationDataProvider} for tenant ID propagation
+ *
{@link MultiTenantEventProcessorPredicate} for event processor configuration
+ *
+ *
+ * The actual multi-tenant infrastructure components (MultiTenantCommandBus, MultiTenantQueryBus,
+ * MultiTenantEventStore) are created by the {@code MultiTenancyConfigurationDefaults} SPI enhancer
+ * in the core module. This autoconfiguration only provides the supporting beans that the SPI
+ * enhancer uses.
+ *
+ * For Axon Server deployments, the {@code DistributedMultiTenancyConfigurationDefaults} in the
+ * {@code multitenancy-axon-server-connector} module provides additional components like
+ * {@code AxonServerTenantProvider} and multi-tenant connectors.
+ *
+ * Multi-tenancy can be disabled by setting {@code axon.multi-tenancy.enabled=false}.
+ *
+ * @author Stefan Dragisic
+ * @author Theo Emanuelsson
+ * @since 5.0.0
+ * @see MultiTenancyProperties
+ * @see org.axonframework.extension.multitenancy.core.configuration.MultiTenancyConfigurationDefaults
+ */
+@AutoConfiguration
+@ConditionalOnProperty(value = "axon.multi-tenancy.enabled", matchIfMissing = true)
+@AutoConfigureAfter(MultiTenancyAxonServerAutoConfiguration.class)
+@EnableConfigurationProperties(MultiTenancyProperties.class)
+public class MultiTenancyAutoConfiguration {
+
+ /**
+ * Creates a default {@link TenantConnectPredicate} that accepts all tenants.
+ *
+ * Users can override this bean to filter which tenants should be connected.
+ *
+ * @return a predicate that returns {@code true} for all tenants
+ */
+ @Bean
+ @ConditionalOnMissingBean
+ public TenantConnectPredicate tenantFilterPredicate() {
+ return tenant -> true;
+ }
+
+ /**
+ * Creates a predicate that determines whether multi-tenancy should be enabled
+ * for a given event processor.
+ *
+ * By default, enables multi-tenancy for all processors. Users can override
+ * this bean to selectively enable/disable multi-tenancy per processor.
+ *
+ * @return a predicate that enables multi-tenancy for all processors
+ */
+ @Bean
+ @ConditionalOnMissingBean
+ public MultiTenantEventProcessorPredicate multiTenantEventProcessorPredicate() {
+ return MultiTenantEventProcessorPredicate.enableMultiTenancy();
+ }
+
+ /**
+ * Creates the target tenant resolver that extracts the tenant from message metadata.
+ *
+ * Uses the {@code tenantKey} property from {@link MultiTenancyProperties} to determine
+ * which metadata key contains the tenant identifier. Defaults to "tenantId".
+ *
+ * This resolver is used by the SPI-registered multi-tenant components to route
+ * messages to the appropriate tenant.
+ *
+ * @param properties the multi-tenancy configuration properties
+ * @return the metadata-based tenant resolver
+ */
+ @Bean
+ @ConditionalOnMissingBean
+ public TargetTenantResolver targetTenantResolver(MultiTenancyProperties properties) {
+ return new MetadataBasedTenantResolver(properties.getTenantKey());
+ }
+
+ /**
+ * Creates the correlation data provider that propagates tenant information
+ * to new messages dispatched during message handling.
+ *
+ * Uses the {@code tenantKey} property from {@link MultiTenancyProperties} to determine
+ * which metadata key to propagate. This ensures that when a command handler dispatches
+ * events or other commands, the tenant ID is automatically included in the new messages.
+ *
+ * @param properties the multi-tenancy configuration properties
+ * @return the tenant correlation provider
+ */
+ @Bean
+ @ConditionalOnMissingBean(TenantCorrelationProvider.class)
+ public CorrelationDataProvider tenantCorrelationProvider(MultiTenancyProperties properties) {
+ return new TenantCorrelationProvider(properties.getTenantKey());
+ }
+
+ /**
+ * Provides the primary {@link EventSink} bean to resolve ambiguity when multiple beans
+ * implement {@code EventSink}.
+ *
+ * This is necessary because when multi-tenancy is enabled, both the decorated
+ * {@link EventStore} and the {@code TenantEventStoreProvider} component are exposed
+ * as Spring beans by the {@code SpringComponentRegistry}. Since {@code MultiTenantEventStore}
+ * implements both interfaces, Spring sees duplicate candidates for {@code EventSink}.
+ *
+ * This bean delegates to the Axon {@link Configuration}'s {@link EventStore}, ensuring
+ * the properly decorated event store is used as the primary {@code EventSink}.
+ *
+ * @param configuration the Axon configuration
+ * @return the primary event sink (the configured event store)
+ */
+ @Bean
+ @Primary
+ public EventSink primaryEventSink(Configuration configuration) {
+ return configuration.getComponent(EventStore.class);
+ }
+}
diff --git a/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancyAutoConfigurationImportFilter.java b/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancyAutoConfigurationImportFilter.java
new file mode 100644
index 0000000..2821f82
--- /dev/null
+++ b/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancyAutoConfigurationImportFilter.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.autoconfig;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter;
+import org.springframework.boot.autoconfigure.AutoConfigurationMetadata;
+import org.springframework.context.EnvironmentAware;
+import org.springframework.core.env.Environment;
+
+import java.util.Set;
+
+/**
+ * An {@link AutoConfigurationImportFilter} that excludes conflicting autoconfiguration
+ * classes when multi-tenancy is enabled.
+ *
+ * When {@code axon.multi-tenancy.enabled=true} (the default), this filter prevents
+ * the following Axon Framework autoconfiguration classes from loading:
+ *
+ *
{@code InfrastructureConfiguration} - Replaced by multi-tenant message handler lookup
+ *
{@code JpaAutoConfiguration} - TokenStore replaced by TenantTokenStoreFactory
+ *
{@code JpaEventStoreAutoConfiguration} - EventStore replaced by MultiTenantEventStore via SPI
+ *
+ *
+ * Additionally, when {@code axon.multi-tenancy.jpa.tenant-repositories=true}, this filter
+ * also excludes Spring Boot's JPA autoconfiguration to allow per-tenant EntityManagerFactory:
+ *
+ *
{@code HibernateJpaAutoConfiguration} - Replaced by tenant-specific EMF instances
+ *
{@code JpaRepositoriesAutoConfiguration} - Replaced by tenant-scoped repositories
+ *
+ *
+ * @author Theo Emanuelsson
+ * @since 5.0.0
+ * @see MultiTenantEventProcessingAutoConfiguration
+ * @see MultiTenancySpringDataJpaAutoConfiguration
+ */
+public class MultiTenancyAutoConfigurationImportFilter implements AutoConfigurationImportFilter, EnvironmentAware {
+
+ private static final Logger logger = LoggerFactory.getLogger(MultiTenancyAutoConfigurationImportFilter.class);
+
+ /**
+ * Axon Framework autoconfiguration classes to exclude when multi-tenancy is enabled.
+ */
+ private static final Set EXCLUDED_WHEN_MULTITENANCY_ENABLED = Set.of(
+ // Provides MessageHandlerLookup which creates standard (non-multi-tenant) event processors
+ "org.axonframework.extension.springboot.autoconfig.InfrastructureConfiguration",
+ // Provides single JpaTokenStore - we need TenantTokenStoreFactory for per-tenant stores
+ "org.axonframework.extension.springboot.autoconfig.JpaAutoConfiguration",
+ // Provides JPA EventStorageEngine - we use MultiTenantEventStore via SPI instead
+ "org.axonframework.extension.springboot.autoconfig.JpaEventStoreAutoConfiguration"
+ );
+
+ /**
+ * Spring Boot JPA autoconfiguration classes to exclude when tenant repositories are enabled.
+ */
+ private static final Set EXCLUDED_WHEN_TENANT_REPOSITORIES_ENABLED = Set.of(
+ // Provides default EntityManagerFactory - we use per-tenant EMF instances
+ "org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration",
+ // Provides default repository beans - we use tenant-scoped repositories
+ "org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration"
+ );
+
+ private Environment environment;
+
+ @Override
+ public void setEnvironment(Environment environment) {
+ this.environment = environment;
+ }
+
+ @Override
+ public boolean[] match(String[] autoConfigurationClasses, AutoConfigurationMetadata autoConfigurationMetadata) {
+ boolean multiTenancyEnabled = isMultiTenancyEnabled();
+ boolean tenantRepositoriesEnabled = isTenantRepositoriesEnabled();
+
+ if (autoConfigurationClasses.length > 1) {
+ logger.debug("MultiTenancyAutoConfigurationImportFilter: multiTenancyEnabled={}, tenantRepositoriesEnabled={}",
+ multiTenancyEnabled, tenantRepositoriesEnabled);
+ }
+
+ boolean[] matches = new boolean[autoConfigurationClasses.length];
+ for (int i = 0; i < autoConfigurationClasses.length; i++) {
+ String className = autoConfigurationClasses[i];
+ if (className == null) {
+ matches[i] = true; // Include null entries (they're already filtered out elsewhere)
+ continue;
+ }
+
+ // Exclude Axon Framework autoconfiguration when multi-tenancy is enabled
+ if (multiTenancyEnabled && EXCLUDED_WHEN_MULTITENANCY_ENABLED.contains(className)) {
+ logger.debug("Multi-tenancy enabled: EXCLUDING [{}]", className);
+ matches[i] = false;
+ continue;
+ }
+
+ // Exclude Spring JPA autoconfiguration when tenant repositories are enabled
+ if (tenantRepositoriesEnabled && EXCLUDED_WHEN_TENANT_REPOSITORIES_ENABLED.contains(className)) {
+ logger.debug("Tenant repositories enabled: EXCLUDING [{}]", className);
+ matches[i] = false;
+ continue;
+ }
+
+ matches[i] = true; // INCLUDE
+ }
+ return matches;
+ }
+
+ /**
+ * Checks if multi-tenancy is enabled via the {@code axon.multi-tenancy.enabled} property.
+ * Defaults to {@code true} if the property is not set.
+ *
+ * @return {@code true} if multi-tenancy is enabled, {@code false} otherwise
+ */
+ private boolean isMultiTenancyEnabled() {
+ if (environment == null) {
+ // Default to enabled if environment is not available
+ return true;
+ }
+ return environment.getProperty("axon.multi-tenancy.enabled", Boolean.class, true);
+ }
+
+ /**
+ * Checks if tenant repositories are enabled via the {@code axon.multi-tenancy.jpa.tenant-repositories} property.
+ * Defaults to {@code false} if the property is not set.
+ *
+ * @return {@code true} if tenant repositories are enabled, {@code false} otherwise
+ */
+ private boolean isTenantRepositoriesEnabled() {
+ if (environment == null) {
+ return false;
+ }
+ return environment.getProperty("axon.multi-tenancy.jpa.tenant-repositories", Boolean.class, false);
+ }
+}
diff --git a/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancyAxonServerAutoConfiguration.java b/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancyAxonServerAutoConfiguration.java
new file mode 100644
index 0000000..010625a
--- /dev/null
+++ b/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancyAxonServerAutoConfiguration.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.autoconfig;
+
+import org.axonframework.axonserver.connector.AxonServerConfiguration;
+import org.axonframework.axonserver.connector.AxonServerConnectionManager;
+import org.axonframework.extension.springboot.autoconfig.AxonAutoConfiguration;
+import org.axonframework.extension.multitenancy.axonserver.AxonServerTenantProvider;
+import org.axonframework.extension.multitenancy.core.TenantConnectPredicate;
+import org.axonframework.extension.multitenancy.core.TenantProvider;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigureAfter;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+
+/**
+ * Auto-configuration for Axon Server multi-tenancy integration.
+ *
+ * This configuration provides property binding for the {@code AxonServerTenantProvider}
+ * which is auto-registered via SPI by the {@code multitenancy-axon-server-connector} module.
+ *
+ * When Axon Server is available and multi-tenancy is enabled, this configuration ensures
+ * that the {@link AxonServerTenantProvider} is properly configured with:
+ *
+ *
Predefined contexts from {@code axon.multi-tenancy.axon-server.contexts}
+ *
Admin context filtering from {@code axon.multi-tenancy.axon-server.filter-admin-contexts}
+ *
+ *
+ * The actual {@link TenantProvider} registration is handled by
+ * {@code DistributedMultiTenancyConfigurationDefaults} in the connector module via SPI. This auto-configuration
+ * only provides property binding and conditional bean overrides.
+ *
+ * @author Stefan Dragisic
+ * @author Theo Emanuelsson
+ * @since 5.0.0
+ * @see AxonServerTenantProvider
+ * @see MultiTenancyProperties
+ */
+@AutoConfiguration
+@AutoConfigureAfter(AxonAutoConfiguration.class)
+@ConditionalOnClass(AxonServerConfiguration.class)
+@ConditionalOnProperty(value = {"axon.axonserver.enabled", "axon.multi-tenancy.enabled"}, matchIfMissing = true)
+@EnableConfigurationProperties(MultiTenancyProperties.class)
+public class MultiTenancyAxonServerAutoConfiguration {
+
+ /**
+ * Creates an {@link AxonServerTenantProvider} with property-based configuration.
+ *
+ * This bean is only created if:
+ *
+ *
An {@link AxonServerConnectionManager} is available
+ *
No other {@link TenantProvider} has been registered
+ *
+ *
+ * In most cases, the {@code DistributedMultiTenancyConfigurationDefaults} from the connector module
+ * will have already registered the provider via SPI. This bean serves as a fallback
+ * that includes Spring Boot property binding for contexts and filtering.
+ *
+ * @param properties the multi-tenancy configuration properties
+ * @param tenantConnectPredicate predicate for filtering which contexts become tenants
+ * @param connectionManager the Axon Server connection manager
+ * @return the configured Axon Server tenant provider
+ */
+ @Bean
+ @ConditionalOnBean(AxonServerConnectionManager.class)
+ @ConditionalOnMissingBean(TenantProvider.class)
+ public TenantProvider tenantProvider(MultiTenancyProperties properties,
+ TenantConnectPredicate tenantConnectPredicate,
+ AxonServerConnectionManager connectionManager) {
+ MultiTenancyProperties.AxonServerProperties axonServerProps = properties.getAxonServer();
+
+ TenantConnectPredicate effectivePredicate = tenantConnectPredicate;
+ if (axonServerProps.isFilterAdminContexts()) {
+ // Filter out admin contexts (those starting with "_")
+ effectivePredicate = tenant ->
+ tenantConnectPredicate.test(tenant) &&
+ !tenant.tenantId().startsWith("_");
+ }
+
+ return AxonServerTenantProvider.builder()
+ .axonServerConnectionManager(connectionManager)
+ .preDefinedContexts(axonServerProps.getContexts())
+ .tenantConnectPredicate(effectivePredicate)
+ .build();
+ }
+}
diff --git a/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancyJdbcAutoConfiguration.java b/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancyJdbcAutoConfiguration.java
new file mode 100644
index 0000000..0c3f2df
--- /dev/null
+++ b/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancyJdbcAutoConfiguration.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.autoconfig;
+
+import jakarta.annotation.Nonnull;
+import org.axonframework.extension.multitenancy.core.TenantComponentRegistry;
+import org.axonframework.extension.multitenancy.core.TenantProvider;
+import org.axonframework.extension.multitenancy.messaging.core.annotation.TenantComponentResolverFactory;
+import org.axonframework.extension.multitenancy.spring.data.jpa.TenantDataSourceProvider;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+
+/**
+ * Auto-configuration for multi-tenant JDBC support.
+ *
+ * This configuration is activated when {@code axon.multi-tenancy.jdbc.enabled=true}.
+ * When enabled, it registers {@link JdbcTemplate} and {@link NamedParameterJdbcTemplate}
+ * as tenant-scoped components that can be injected into message handlers.
+ *
+ * This provides a lightweight alternative to JPA for projections and queries,
+ * offering better performance for simple CRUD operations without ORM overhead.
+ *
+ * Example usage:
+ *
{@code
+ * @Component
+ * public class CourseProjector {
+ * @EventHandler
+ * public void on(CourseCreatedEvent event, JdbcTemplate jdbcTemplate) {
+ * // jdbcTemplate is automatically scoped to the tenant from event metadata
+ * jdbcTemplate.update(
+ * "INSERT INTO courses (id, title) VALUES (?, ?)",
+ * event.courseId(), event.title()
+ * );
+ * }
+ * }
+ * }
+ * Each tenant will receive a {@link JdbcTemplate} instance configured with their
+ * tenant-specific {@link javax.sql.DataSource}.
+ *
+ * @param dataSourceProvider the provider for tenant-specific DataSources
+ * @param resolverFactory the factory for creating tenant component resolvers
+ * @param tenantProvider the tenant provider for lifecycle management
+ * @return a registry of tenant-scoped JdbcTemplate instances
+ */
+ @Bean
+ @ConditionalOnMissingBean(name = "tenantJdbcTemplateRegistry")
+ public TenantComponentRegistry tenantJdbcTemplateRegistry(
+ @Nonnull TenantDataSourceProvider dataSourceProvider,
+ @Nonnull TenantComponentResolverFactory resolverFactory,
+ @Nonnull TenantProvider tenantProvider) {
+
+ logger.debug("Registering JdbcTemplate as tenant component");
+
+ TenantComponentRegistry registry = resolverFactory.registerComponent(
+ JdbcTemplate.class,
+ tenant -> {
+ logger.debug("Creating JdbcTemplate for tenant {}", tenant.tenantId());
+ return new JdbcTemplate(dataSourceProvider.apply(tenant));
+ }
+ );
+
+ tenantProvider.subscribe(registry);
+ tenantProvider.getTenants().forEach(registry::registerTenant);
+
+ return registry;
+ }
+
+ /**
+ * Registers {@link NamedParameterJdbcTemplate} as a tenant-scoped component.
+ *
+ * Each tenant will receive a {@link NamedParameterJdbcTemplate} instance that
+ * allows using named parameters (e.g., {@code :paramName}) instead of positional
+ * parameters ({@code ?}) in SQL queries.
+ *
+ * @param dataSourceProvider the provider for tenant-specific DataSources
+ * @param resolverFactory the factory for creating tenant component resolvers
+ * @param tenantProvider the tenant provider for lifecycle management
+ * @return a registry of tenant-scoped NamedParameterJdbcTemplate instances
+ */
+ @Bean
+ @ConditionalOnMissingBean(name = "tenantNamedParameterJdbcTemplateRegistry")
+ public TenantComponentRegistry tenantNamedParameterJdbcTemplateRegistry(
+ @Nonnull TenantDataSourceProvider dataSourceProvider,
+ @Nonnull TenantComponentResolverFactory resolverFactory,
+ @Nonnull TenantProvider tenantProvider) {
+
+ logger.debug("Registering NamedParameterJdbcTemplate as tenant component");
+
+ TenantComponentRegistry registry = resolverFactory.registerComponent(
+ NamedParameterJdbcTemplate.class,
+ tenant -> {
+ logger.debug("Creating NamedParameterJdbcTemplate for tenant {}", tenant.tenantId());
+ return new NamedParameterJdbcTemplate(dataSourceProvider.apply(tenant));
+ }
+ );
+
+ tenantProvider.subscribe(registry);
+ tenantProvider.getTenants().forEach(registry::registerTenant);
+
+ return registry;
+ }
+}
diff --git a/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancyProperties.java b/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancyProperties.java
new file mode 100644
index 0000000..734e5db
--- /dev/null
+++ b/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancyProperties.java
@@ -0,0 +1,450 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.autoconfig;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Configuration properties for Axon Framework multi-tenancy extension.
+ *
+ * These properties allow customization of multi-tenancy behavior including
+ * enabling/disabling the feature, configuring the tenant key used in message
+ * metadata, and providing a static list of tenants for non-Axon Server deployments.
+ *
+ * @author Theo Emanuelsson
+ * @since 5.0.0
+ */
+@ConfigurationProperties("axon.multi-tenancy")
+public class MultiTenancyProperties {
+
+ /**
+ * Whether multi-tenancy is enabled. Defaults to {@code true}.
+ */
+ private boolean enabled = true;
+
+ /**
+ * The metadata key used to identify the tenant. Defaults to {@code "tenantId"}.
+ * This key is used by {@link org.axonframework.extension.multitenancy.core.MetadataBasedTenantResolver}
+ * to extract the tenant from message metadata.
+ */
+ private String tenantKey = TenantConfiguration.TENANT_CORRELATION_KEY;
+
+ /**
+ * Static list of tenant identifiers. Use this for non-Axon Server deployments
+ * where tenants are known at configuration time.
+ *
+ * When using Axon Server, tenants are typically discovered dynamically from
+ * Axon Server contexts via the {@code multitenancy-axon-server-connector} module.
+ */
+ private List tenants = new ArrayList<>();
+
+ /**
+ * Axon Server specific configuration for multi-tenancy.
+ */
+ private AxonServerProperties axonServer = new AxonServerProperties();
+
+ /**
+ * Data access configuration for multi-tenant repositories.
+ */
+ private DataAccessProperties dataAccess = new DataAccessProperties();
+
+ /**
+ * JPA-specific configuration for multi-tenant data access.
+ */
+ private JpaProperties jpa = new JpaProperties();
+
+ /**
+ * JDBC-specific configuration for multi-tenant data access.
+ */
+ private JdbcProperties jdbc = new JdbcProperties();
+
+ /**
+ * R2DBC-specific configuration for multi-tenant reactive data access.
+ */
+ private R2dbcProperties r2dbc = new R2dbcProperties();
+
+ /**
+ * Returns whether multi-tenancy is enabled.
+ *
+ * @return {@code true} if multi-tenancy is enabled, {@code false} otherwise
+ */
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ /**
+ * Sets whether multi-tenancy is enabled.
+ *
+ * @param enabled {@code true} to enable multi-tenancy, {@code false} to disable
+ */
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ /**
+ * Returns the metadata key used to identify the tenant.
+ *
+ * @return the tenant key
+ */
+ public String getTenantKey() {
+ return tenantKey;
+ }
+
+ /**
+ * Sets the metadata key used to identify the tenant.
+ *
+ * @param tenantKey the tenant key
+ */
+ public void setTenantKey(String tenantKey) {
+ this.tenantKey = tenantKey;
+ }
+
+ /**
+ * Returns the static list of tenant identifiers.
+ *
+ * @return the list of tenant identifiers
+ */
+ public List getTenants() {
+ return tenants;
+ }
+
+ /**
+ * Sets the static list of tenant identifiers.
+ *
+ * @param tenants the list of tenant identifiers
+ */
+ public void setTenants(List tenants) {
+ this.tenants = tenants;
+ }
+
+ /**
+ * Returns the Axon Server specific configuration.
+ *
+ * @return the Axon Server properties
+ */
+ public AxonServerProperties getAxonServer() {
+ return axonServer;
+ }
+
+ /**
+ * Sets the Axon Server specific configuration.
+ *
+ * @param axonServer the Axon Server properties
+ */
+ public void setAxonServer(AxonServerProperties axonServer) {
+ this.axonServer = axonServer;
+ }
+
+ /**
+ * Returns the data access configuration.
+ *
+ * @return the data access properties
+ */
+ public DataAccessProperties getDataAccess() {
+ return dataAccess;
+ }
+
+ /**
+ * Sets the data access configuration.
+ *
+ * @param dataAccess the data access properties
+ */
+ public void setDataAccess(DataAccessProperties dataAccess) {
+ this.dataAccess = dataAccess;
+ }
+
+ /**
+ * Returns the JPA-specific configuration.
+ *
+ * @return the JPA properties
+ */
+ public JpaProperties getJpa() {
+ return jpa;
+ }
+
+ /**
+ * Sets the JPA-specific configuration.
+ *
+ * @param jpa the JPA properties
+ */
+ public void setJpa(JpaProperties jpa) {
+ this.jpa = jpa;
+ }
+
+ /**
+ * Returns the JDBC-specific configuration.
+ *
+ * @return the JDBC properties
+ */
+ public JdbcProperties getJdbc() {
+ return jdbc;
+ }
+
+ /**
+ * Sets the JDBC-specific configuration.
+ *
+ * @param jdbc the JDBC properties
+ */
+ public void setJdbc(JdbcProperties jdbc) {
+ this.jdbc = jdbc;
+ }
+
+ /**
+ * Returns the R2DBC-specific configuration.
+ *
+ * @return the R2DBC properties
+ */
+ public R2dbcProperties getR2dbc() {
+ return r2dbc;
+ }
+
+ /**
+ * Sets the R2DBC-specific configuration.
+ *
+ * @param r2dbc the R2DBC properties
+ */
+ public void setR2dbc(R2dbcProperties r2dbc) {
+ this.r2dbc = r2dbc;
+ }
+
+ /**
+ * Axon Server specific properties for multi-tenancy configuration.
+ *
+ * These properties are passed to the {@code AxonServerTenantProvider} in the
+ * {@code multitenancy-axon-server-connector} module when Axon Server is used.
+ */
+ public static class AxonServerProperties {
+
+ /**
+ * Comma-separated list of predefined Axon Server context names to use as tenants.
+ * When set, the tenant provider will not query Axon Server's Admin API for contexts
+ * and will not subscribe to context update events.
+ *
+ * Leave empty to discover tenants dynamically from Axon Server.
+ */
+ private String contexts;
+
+ /**
+ * Whether to filter out admin contexts (those starting with "_") from
+ * the list of tenants. Defaults to {@code true}.
+ */
+ private boolean filterAdminContexts = true;
+
+ /**
+ * Returns the comma-separated list of predefined context names.
+ *
+ * @return the predefined contexts, or {@code null} if dynamic discovery is used
+ */
+ public String getContexts() {
+ return contexts;
+ }
+
+ /**
+ * Sets the comma-separated list of predefined context names.
+ *
+ * @param contexts the predefined contexts
+ */
+ public void setContexts(String contexts) {
+ this.contexts = contexts;
+ }
+
+ /**
+ * Returns whether admin contexts should be filtered out.
+ *
+ * @return {@code true} if admin contexts are filtered, {@code false} otherwise
+ */
+ public boolean isFilterAdminContexts() {
+ return filterAdminContexts;
+ }
+
+ /**
+ * Sets whether admin contexts should be filtered out.
+ *
+ * @param filterAdminContexts {@code true} to filter admin contexts
+ */
+ public void setFilterAdminContexts(boolean filterAdminContexts) {
+ this.filterAdminContexts = filterAdminContexts;
+ }
+ }
+
+ /**
+ * Data access properties for multi-tenant Spring Data repository injection.
+ *
+ * When enabled, Spring Data JPA repositories can be injected as handler parameters
+ * and will automatically be scoped to the current message's tenant.
+ */
+ public static class DataAccessProperties {
+
+ /**
+ * Whether tenant-scoped data access is enabled. Defaults to {@code true}.
+ *
+ * When enabled, event handlers can receive tenant-scoped Spring Data repositories
+ * as parameters, automatically configured for the message's tenant.
+ */
+ private boolean enabled = true;
+
+ /**
+ * Returns whether tenant-scoped data access is enabled.
+ *
+ * @return {@code true} if enabled, {@code false} otherwise
+ */
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ /**
+ * Sets whether tenant-scoped data access is enabled.
+ *
+ * @param enabled {@code true} to enable tenant-scoped data access
+ */
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+ }
+
+ /**
+ * JPA-specific properties for multi-tenant data access.
+ *
+ * When {@code tenant-repositories} is enabled, Spring Boot's default JPA autoconfiguration
+ * is excluded (HibernateJpaAutoConfiguration, JpaRepositoriesAutoConfiguration), and
+ * tenant-specific EntityManagerFactory instances are used instead.
+ */
+ public static class JpaProperties {
+
+ /**
+ * Whether to enable per-tenant JPA repositories. Defaults to {@code false}.
+ *
+ * When enabled:
+ *
+ *
Spring Boot's default JPA autoconfiguration is excluded
+ *
A {@link org.axonframework.extension.multitenancy.spring.data.jpa.TenantDataSourceProvider}
+ * bean is required
+ *
Spring Data repositories extending {@link org.springframework.data.repository.Repository}
+ * are automatically scoped to the current message's tenant
+ *
+ *
+ * When disabled, JPA works normally with Spring Boot's default single-datasource configuration.
+ */
+ private boolean tenantRepositories = false;
+
+ /**
+ * Returns whether per-tenant JPA repositories are enabled.
+ *
+ * @return {@code true} if tenant repositories are enabled, {@code false} otherwise
+ */
+ public boolean isTenantRepositories() {
+ return tenantRepositories;
+ }
+
+ /**
+ * Sets whether per-tenant JPA repositories are enabled.
+ *
+ * @param tenantRepositories {@code true} to enable per-tenant repositories
+ */
+ public void setTenantRepositories(boolean tenantRepositories) {
+ this.tenantRepositories = tenantRepositories;
+ }
+ }
+
+ /**
+ * JDBC-specific properties for multi-tenant data access.
+ *
+ * When enabled, {@link org.springframework.jdbc.core.JdbcTemplate} and
+ * {@link org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate}
+ * are registered as tenant-scoped components that can be injected into message handlers.
+ *
+ * This provides a lightweight alternative to JPA for projections and queries.
+ */
+ public static class JdbcProperties {
+
+ /**
+ * Whether to enable per-tenant JDBC templates. Defaults to {@code false}.
+ *
+ * When enabled:
+ *
+ *
A {@link org.axonframework.extension.multitenancy.spring.data.jpa.TenantDataSourceProvider}
+ * bean is required
+ *
{@link org.springframework.jdbc.core.JdbcTemplate} can be injected into message handlers
+ *
{@link org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate} can be injected
+ * into message handlers
+ * When enabled, {@link org.springframework.r2dbc.core.DatabaseClient}
+ * is registered as a tenant-scoped component that can be injected into message handlers
+ * for non-blocking database operations.
+ *
+ * R2DBC is beneficial in high-concurrency scenarios when not using virtual threads.
+ */
+ public static class R2dbcProperties {
+
+ /**
+ * Whether to enable per-tenant R2DBC database clients. Defaults to {@code false}.
+ *
+ * When enabled:
+ *
+ *
A {@link org.axonframework.extension.multitenancy.spring.data.r2dbc.TenantConnectionFactoryProvider}
+ * bean is required
+ *
{@link org.springframework.r2dbc.core.DatabaseClient} can be injected into message handlers
+ *
+ */
+ private boolean enabled = false;
+
+ /**
+ * Returns whether per-tenant R2DBC database clients are enabled.
+ *
+ * @return {@code true} if R2DBC is enabled, {@code false} otherwise
+ */
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ /**
+ * Sets whether per-tenant R2DBC database clients are enabled.
+ *
+ * @param enabled {@code true} to enable per-tenant R2DBC
+ */
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+ }
+}
diff --git a/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancyR2dbcAutoConfiguration.java b/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancyR2dbcAutoConfiguration.java
new file mode 100644
index 0000000..221dd90
--- /dev/null
+++ b/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancyR2dbcAutoConfiguration.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.autoconfig;
+
+import jakarta.annotation.Nonnull;
+import org.axonframework.extension.multitenancy.core.TenantComponentRegistry;
+import org.axonframework.extension.multitenancy.core.TenantProvider;
+import org.axonframework.extension.multitenancy.messaging.core.annotation.TenantComponentResolverFactory;
+import org.axonframework.extension.multitenancy.spring.data.r2dbc.TenantConnectionFactoryProvider;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.r2dbc.core.DatabaseClient;
+
+/**
+ * Auto-configuration for multi-tenant R2DBC (reactive database) support.
+ *
+ * This configuration is activated when {@code axon.multi-tenancy.r2dbc.enabled=true}.
+ * When enabled, it registers {@link DatabaseClient} as a tenant-scoped component
+ * that can be injected into message handlers for non-blocking database operations.
+ *
+ * R2DBC provides reactive, non-blocking database access which can be beneficial
+ * in high-concurrency scenarios when not using virtual threads.
+ *
+ * Example usage:
+ *
{@code
+ * @Component
+ * public class CourseProjector {
+ * @EventHandler
+ * public Mono on(CourseCreatedEvent event, DatabaseClient databaseClient) {
+ * // databaseClient is automatically scoped to the tenant from event metadata
+ * return databaseClient
+ * .sql("INSERT INTO courses (id, title) VALUES (:id, :title)")
+ * .bind("id", event.courseId())
+ * .bind("title", event.title())
+ * .then();
+ * }
+ * }
+ * }
+ * Each tenant will receive a {@link DatabaseClient} instance configured with their
+ * tenant-specific {@link io.r2dbc.spi.ConnectionFactory}.
+ *
+ * @param connectionFactoryProvider the provider for tenant-specific ConnectionFactories
+ * @param resolverFactory the factory for creating tenant component resolvers
+ * @param tenantProvider the tenant provider for lifecycle management
+ * @return a registry of tenant-scoped DatabaseClient instances
+ */
+ @Bean
+ @ConditionalOnMissingBean(name = "tenantDatabaseClientRegistry")
+ public TenantComponentRegistry tenantDatabaseClientRegistry(
+ @Nonnull TenantConnectionFactoryProvider connectionFactoryProvider,
+ @Nonnull TenantComponentResolverFactory resolverFactory,
+ @Nonnull TenantProvider tenantProvider) {
+
+ logger.debug("Registering DatabaseClient as tenant component");
+
+ TenantComponentRegistry registry = resolverFactory.registerComponent(
+ DatabaseClient.class,
+ tenant -> {
+ logger.debug("Creating DatabaseClient for tenant {}", tenant.tenantId());
+ return DatabaseClient.create(connectionFactoryProvider.apply(tenant));
+ }
+ );
+
+ tenantProvider.subscribe(registry);
+ tenantProvider.getTenants().forEach(registry::registerTenant);
+
+ return registry;
+ }
+}
diff --git a/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancySpringDataJpaAutoConfiguration.java b/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancySpringDataJpaAutoConfiguration.java
new file mode 100644
index 0000000..cc92a8c
--- /dev/null
+++ b/multitenancy-spring-boot-autoconfigure/src/main/java/org/axonframework/extension/multitenancy/autoconfig/MultiTenancySpringDataJpaAutoConfiguration.java
@@ -0,0 +1,324 @@
+/*
+ * Copyright (c) 2010-2025. Axon Framework
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.axonframework.extension.multitenancy.autoconfig;
+
+import jakarta.annotation.Nonnull;
+import org.axonframework.common.configuration.ComponentRegistry;
+import org.axonframework.common.configuration.ConfigurationEnhancer;
+import org.axonframework.extension.multitenancy.core.TargetTenantResolver;
+import org.axonframework.extension.multitenancy.core.TenantComponentFactory;
+import org.axonframework.extension.multitenancy.core.TenantComponentRegistry;
+import org.axonframework.extension.multitenancy.core.TenantProvider;
+import org.axonframework.extension.multitenancy.messaging.core.annotation.TenantComponentResolverFactory;
+import org.axonframework.extension.multitenancy.messaging.core.unitofwork.annotation.TenantAwareProcessingContextResolverFactory;
+import org.axonframework.extension.multitenancy.spring.data.jpa.TenantDataSourceProvider;
+import org.axonframework.extension.multitenancy.spring.data.jpa.TenantEntityManagerFactoryBuilder;
+import org.axonframework.extension.multitenancy.spring.data.jpa.TenantJpaRepositoryFactory;
+import org.axonframework.extension.multitenancy.spring.data.jpa.TenantTransactionManagerBuilder;
+import org.axonframework.messaging.core.Message;
+import org.axonframework.messaging.core.annotation.MultiParameterResolverFactory;
+import org.axonframework.messaging.core.configuration.reflection.ParameterResolverFactoryUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.BeanFactory;
+import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition;
+import org.springframework.beans.factory.config.BeanDefinition;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigurationPackages;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
+import org.springframework.core.type.filter.AssignableTypeFilter;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.repository.Repository;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Auto-configuration for multi-tenant Spring Data JPA support.
+ *
+ * This configuration is activated when {@code axon.multi-tenancy.jpa.tenant-repositories=true}.
+ * When enabled, it:
+ *
+ *
Requires a {@link TenantDataSourceProvider} bean to be present
+ *
Creates a {@link TenantEntityManagerFactoryBuilder} for building tenant-specific EntityManagerFactories
+ *
Creates a {@link TenantTransactionManagerBuilder} for building tenant-specific TransactionManagers
+ *
Scans for all Spring Data repository interfaces (extending {@link Repository})
+ *
Registers each repository as a tenant component for automatic tenant-scoped injection
+ *
+ *
+ * When this property is enabled, Spring Boot's default JPA autoconfiguration (HibernateJpaAutoConfiguration,
+ * JpaRepositoriesAutoConfiguration) is automatically excluded by {@link MultiTenancyAutoConfigurationImportFilter}.
+ * This means you don't need any special annotations - just define your repository interfaces as usual:
+ *
{@code
+ * public interface OrderRepository extends JpaRepository {
+ * List findByCustomerId(String customerId);
+ * }
+ *
+ * @Component
+ * public class OrderProjector {
+ * @EventHandler
+ * public void on(OrderCreatedEvent event, OrderRepository repository) {
+ * // repository is automatically scoped to the tenant from event metadata
+ * repository.save(new Order(event.orderId(), event.customerId()));
+ * }
+ * }
+ * }
+ *
+ * @author Theo Emanuelsson
+ * @since 5.0.0
+ * @see TenantDataSourceProvider
+ * @see TenantEntityManagerFactoryBuilder
+ * @see MultiTenancyAutoConfigurationImportFilter
+ */
+@AutoConfiguration(after = MultiTenancyAutoConfiguration.class)
+@ConditionalOnClass(JpaRepository.class)
+@ConditionalOnBean(TenantDataSourceProvider.class)
+@ConditionalOnProperty(value = "axon.multi-tenancy.jpa.tenant-repositories", havingValue = "true")
+@EnableConfigurationProperties(MultiTenancyProperties.class)
+public class MultiTenancySpringDataJpaAutoConfiguration {
+
+ private static final Logger logger = LoggerFactory.getLogger(MultiTenancySpringDataJpaAutoConfiguration.class);
+
+ /**
+ * Creates a {@link TenantEntityManagerFactoryBuilder} for building tenant-specific EntityManagerFactories.
+ *
+ * @param dataSourceProvider the provider for tenant-specific DataSources
+ * @return a builder for tenant-specific EntityManagerFactories
+ */
+ @Bean
+ @ConditionalOnMissingBean(TenantEntityManagerFactoryBuilder.class)
+ public TenantEntityManagerFactoryBuilder tenantEntityManagerFactoryBuilder(
+ TenantDataSourceProvider dataSourceProvider) {
+ return TenantEntityManagerFactoryBuilder
+ .forDataSourceProvider(dataSourceProvider)
+ .build();
+ }
+
+ /**
+ * Creates a {@link TenantTransactionManagerBuilder} for building tenant-specific TransactionManagers.
+ *
+ * Each tenant's {@link jakarta.persistence.EntityManagerFactory} requires its own
+ * {@link org.springframework.orm.jpa.JpaTransactionManager} to properly manage transactions.
+ *
+ * @param emfBuilder the tenant EntityManagerFactory builder
+ * @return a builder for tenant-specific TransactionManagers
+ */
+ @Bean
+ @ConditionalOnMissingBean(TenantTransactionManagerBuilder.class)
+ public TenantTransactionManagerBuilder tenantTransactionManagerBuilder(
+ TenantEntityManagerFactoryBuilder emfBuilder) {
+ return TenantTransactionManagerBuilder
+ .forEntityManagerFactoryBuilder(emfBuilder)
+ .build();
+ }
+
+ /**
+ * Creates a {@link ConfigurationEnhancer} that registers Spring Data repository interfaces
+ * as tenant components.
+ *
+ * This enhancer scans the classpath for interfaces extending {@link Repository},
+ * using the same base packages as Spring Boot's auto-configuration.
+ *
+ * @param emfBuilder the tenant EntityManagerFactory builder
+ * @param txBuilder the tenant TransactionManager builder
+ * @param beanFactory the bean factory to get auto-configuration packages
+ * @return a configuration enhancer for tenant repository registration
+ */
+ @Bean
+ public ConfigurationEnhancer tenantRepositoryConfigurationEnhancer(
+ TenantEntityManagerFactoryBuilder emfBuilder,
+ TenantTransactionManagerBuilder txBuilder,
+ BeanFactory beanFactory) {
+
+ // Get base packages from Spring Boot's auto-configuration
+ List basePackages = AutoConfigurationPackages.has(beanFactory)
+ ? AutoConfigurationPackages.get(beanFactory)
+ : List.of();
+
+ // Scan for Spring Data repository interfaces
+ Set> repositoryTypes = scanForRepositories(basePackages);
+
+ if (repositoryTypes.isEmpty()) {
+ logger.debug("No Spring Data repository interfaces found in packages: {}", basePackages);
+ return componentRegistry -> { /* no-op */ };
+ }
+
+ logger.debug("Found {} Spring Data repository interfaces for tenant component registration",
+ repositoryTypes.size());
+
+ return new TenantRepositoryEnhancer(repositoryTypes, emfBuilder, txBuilder);
+ }
+
+ /**
+ * Scans the classpath for interfaces extending {@link Repository}.
+ *
+ * This finds all Spring Data repository interfaces including those extending
+ * {@code JpaRepository}, {@code CrudRepository}, {@code PagingAndSortingRepository}, etc.
+ */
+ private Set> scanForRepositories(List basePackages) {
+ Set> repositoryTypes = new HashSet<>();
+
+ if (basePackages.isEmpty()) {
+ logger.warn("No base packages configured for repository scanning. " +
+ "Ensure your application has @EnableAutoConfiguration or @SpringBootApplication.");
+ return repositoryTypes;
+ }
+
+ // Create scanner that can find interfaces (not just concrete classes)
+ ClassPathScanningCandidateComponentProvider scanner =
+ new ClassPathScanningCandidateComponentProvider(false) {
+ @Override
+ protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) {
+ // Allow interfaces (default implementation only allows concrete classes)
+ return beanDefinition.getMetadata().isInterface()
+ || super.isCandidateComponent(beanDefinition);
+ }
+ };
+ // Scan for any interface extending Spring Data's Repository
+ scanner.addIncludeFilter(new AssignableTypeFilter(Repository.class));
+
+ for (String basePackage : basePackages) {
+ logger.debug("Scanning package {} for Spring Data repositories", basePackage);
+ for (BeanDefinition bd : scanner.findCandidateComponents(basePackage)) {
+ try {
+ Class> repositoryType = Class.forName(bd.getBeanClassName());
+ // Skip the Repository interface itself and Spring's built-in interfaces
+ if (isUserDefinedRepository(repositoryType)) {
+ repositoryTypes.add(repositoryType);
+ logger.debug("Discovered repository: {}", repositoryType.getName());
+ }
+ } catch (ClassNotFoundException e) {
+ logger.warn("Could not load repository class: {}", bd.getBeanClassName(), e);
+ }
+ }
+ }
+
+ return repositoryTypes;
+ }
+
+ /**
+ * Checks if a repository type is a user-defined repository (not a Spring Data built-in).
+ */
+ private boolean isUserDefinedRepository(Class> repositoryType) {
+ // Skip Spring Data's own interfaces
+ String packageName = repositoryType.getPackageName();
+ return !packageName.startsWith("org.springframework.data");
+ }
+
+ /**
+ * A {@link ConfigurationEnhancer} that registers Spring Data repository interfaces
+ * as tenant components.
+ */
+ private static class TenantRepositoryEnhancer implements ConfigurationEnhancer {
+
+ private final Set> repositoryTypes;
+ private final TenantEntityManagerFactoryBuilder emfBuilder;
+ private final TenantTransactionManagerBuilder txBuilder;
+
+ TenantRepositoryEnhancer(Set> repositoryTypes,
+ TenantEntityManagerFactoryBuilder emfBuilder,
+ TenantTransactionManagerBuilder txBuilder) {
+ this.repositoryTypes = repositoryTypes;
+ this.emfBuilder = emfBuilder;
+ this.txBuilder = txBuilder;
+ }
+
+ @Override
+ public int order() {
+ // Run after MultiTenancyConfigurationDefaults (which is MAX_VALUE - 1)
+ return Integer.MAX_VALUE - 2;
+ }
+
+ @Override
+ public void enhance(@Nonnull ComponentRegistry componentRegistry) {
+ logger.debug("Registering {} Spring Data repositories as tenant components", repositoryTypes.size());
+
+ // Collect tenant component registrations
+ List> registrations = new ArrayList<>();
+ for (Class> repositoryType : repositoryTypes) {
+ registrations.add(createRegistration(repositoryType));
+ }
+
+ // Register a ParameterResolverFactory that creates tenant-scoped repositories
+ ParameterResolverFactoryUtils.registerToComponentRegistry(
+ componentRegistry,
+ config -> {
+ @SuppressWarnings("unchecked")
+ TargetTenantResolver tenantResolver =
+ config.getComponent(TargetTenantResolver.class);
+
+ // Create the TenantComponentResolverFactory
+ TenantComponentResolverFactory componentFactory =
+ new TenantComponentResolverFactory(tenantResolver);
+
+ // Get tenant provider for lifecycle management
+ TenantProvider tenantProvider = config.getOptionalComponent(TenantProvider.class)
+ .orElse(null);
+
+ // Register all repository types in the factory
+ for (TenantComponentRegistration> registration : registrations) {
+ registerTenantComponent(componentFactory, registration, tenantProvider);
+ }
+
+ // Create the TenantAwareProcessingContextResolverFactory
+ TenantAwareProcessingContextResolverFactory contextFactory =
+ new TenantAwareProcessingContextResolverFactory(componentFactory, tenantResolver);
+
+ // Return a MultiParameterResolverFactory containing both
+ return MultiParameterResolverFactory.ordered(componentFactory, contextFactory);
+ }
+ );
+ }
+
+ @SuppressWarnings("unchecked")
+ private TenantComponentRegistration createRegistration(Class repositoryType) {
+ TenantComponentFactory factory = TenantJpaRepositoryFactory.forRepository(repositoryType, emfBuilder, txBuilder);
+ return new TenantComponentRegistration<>(repositoryType, factory);
+ }
+
+ private