diff --git a/README.md b/README.md
index 69eb6438..12ef143c 100644
--- a/README.md
+++ b/README.md
@@ -24,13 +24,15 @@ Down below is an exhaustive list of all the sample:
6. [Sequencing Policy](sequencing-policy/README.md) - Sample showing how to set up a custom `SequencingPolicy` to adjust
the event sequence for a `PooledStreamingEventProcessor`.
7. [Snapshots](snapshots/README.md) - Sample showing how to configure event-sourced entity snapshotting.
-8. [Stateful Event Handler](stateful-event-handler/README.md) - Sample showing a stateful event handler that can be
+8. [Spring Cloud Command Routing](spring-cloud-command-routing/README.md) - Sample showing how the Spring Cloud
+ connector routes commands between the instances of an application, without Axon Server.
+9. [Stateful Event Handler](stateful-event-handler/README.md) - Sample showing a stateful event handler that can be
used as a replacement for sagas.
-9. [Subscription Query - REST](subscription-query-rest/README.md) - Sample showing how to use Axon's subscription query
+10. [Subscription Query - REST](subscription-query-rest/README.md) - Sample showing how to use Axon's subscription query
cleanly in a REST-based controller.
-10. [Subscription Query - Streaming](subscription-query-streaming/README.md) - Sample showing how to use Axon's
+11. [Subscription Query - Streaming](subscription-query-streaming/README.md) - Sample showing how to use Axon's
subscription query cleanly in a streaming-based controller.
-11. [Workflow Saga](workflow-saga/README.md) - Sample showing a Saga-like process modelled with the AxoniQ Workflow
+12. [Workflow Saga](workflow-saga/README.md) - Sample showing a Saga-like process modelled with the AxoniQ Workflow
Engine.
## Topics now covered by the Axon Framework project itself
diff --git a/pom.xml b/pom.xml
index 4cfc106b..84b2b8c4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -21,6 +21,7 @@
serialization-avro
sequencing-policy
snapshots
+ spring-cloud-command-routing
stateful-event-handler
subscription-query-rest
subscription-query-streaming
diff --git a/spring-cloud-command-routing/README.md b/spring-cloud-command-routing/README.md
new file mode 100644
index 00000000..104037d5
--- /dev/null
+++ b/spring-cloud-command-routing/README.md
@@ -0,0 +1,61 @@
+# Spring Cloud Command Routing
+
+This sample shows how the Axoniq Framework Spring Cloud connector distributes commands between the instances of an
+application, without Axon Server in between.
+
+Every instance runs the same application. It registers with a Spring Cloud discovery service (Consul, here) and
+handles one command, `SayHello`, answering with the id of the instance that handled it:
+
+```
+Hello alice, from spring-cloud-command-routing-8082
+```
+
+Start several instances and send requests to any of them, and you can see how commands are routed:
+
+1. `SayHello` declares `@Command(routingKey = "name")`. The connector hashes the routing key onto a ring of all
+ instances that registered with discovery, so every greeting for the same name is handled by the same instance, no
+ matter which instance received the request.
+2. Different names are spread over the instances.
+3. When an instance stops, only the names it handled move to another instance. The others stay where they were.
+
+There is no configuration specific to Axon: adding `axoniq-springcloud` next to a Spring Cloud discovery
+implementation, such as `spring-cloud-starter-consul-discovery`, and a servlet web stack
+(`spring-boot-starter-web`), is enough. Instances call each other over HTTP, on the port they registered with.
+
+### Running the application
+
+1. Start Consul. A `docker-compose.yml` is provided for this:
+
+ ```bash
+ docker compose up -d
+ ```
+
+2. Start three instances of the application, each on its own port, in separate terminals:
+
+ ```bash
+ PORT=8081 ../mvnw spring-boot:run
+ PORT=8082 ../mvnw spring-boot:run
+ PORT=8083 ../mvnw spring-boot:run
+ ```
+
+ The Consul UI at [http://localhost:8500](http://localhost:8500) lists the instances as they register.
+
+3. Send greetings through different instances, using the `requests.http` file or `curl`:
+
+ ```bash
+ for port in 8081 8082 8083; do curl localhost:$port/hello/alice; echo; done
+ for name in alice bob carol dave erin; do curl localhost:8081/hello/$name; echo; done
+ ```
+
+ The first loop answers from the same instance three times. The second shows names spread over the instances.
+
+4. Stop one of the instances, wait a few seconds for discovery to notice, and send the same greetings again.
+
+### Spring Boot 3
+
+The sample builds against Spring Boot 4 and Spring Cloud 2025.1 by default. To run it against Spring Boot 3.5 and
+Spring Cloud 2025.0 instead, enable the `spring-boot-3` profile:
+
+```bash
+PORT=8081 ../mvnw -Pspring-boot-3 spring-boot:run
+```
diff --git a/spring-cloud-command-routing/docker-compose.yml b/spring-cloud-command-routing/docker-compose.yml
new file mode 100644
index 00000000..f4debe18
--- /dev/null
+++ b/spring-cloud-command-routing/docker-compose.yml
@@ -0,0 +1,5 @@
+services:
+ consul:
+ image: hashicorp/consul
+ ports:
+ - '8500:8500'
diff --git a/spring-cloud-command-routing/pom.xml b/spring-cloud-command-routing/pom.xml
new file mode 100644
index 00000000..43057b4e
--- /dev/null
+++ b/spring-cloud-command-routing/pom.xml
@@ -0,0 +1,82 @@
+
+
+ 4.0.0
+
+ io.axoniq
+ code-samples
+ 0.0.2-SNAPSHOT
+
+
+ spring-cloud-command-routing
+
+ Spring Cloud Command Routing
+
+ Sample application showing how the Axoniq Framework Spring Cloud connector routes commands between the
+ instances of an application, without Axon Server.
+
+
+
+
+ 5.4.0-SNAPSHOT
+
+ 2025.1.3
+
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-dependencies
+ ${spring-cloud.version}
+ pom
+ import
+
+
+
+
+
+
+
+ io.axoniq.framework
+ axoniq-spring-boot-starter
+
+
+ io.axoniq.framework
+ axoniq-springcloud
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.cloud
+ spring-cloud-starter-consul-discovery
+
+
+
+
+
+
+ spring-boot-3
+
+ 3.5.16
+ 2025.0.3
+
+
+
+
+
+ com.fasterxml.jackson
+ jackson-bom
+ 2.22.2
+ pom
+ import
+
+
+
+
+
+
diff --git a/spring-cloud-command-routing/requests.http b/spring-cloud-command-routing/requests.http
new file mode 100644
index 00000000..9eb3fb02
--- /dev/null
+++ b/spring-cloud-command-routing/requests.http
@@ -0,0 +1,11 @@
+### Greet alice through the first instance
+
+GET http://localhost:8081/hello/alice
+
+### Greet alice through the second instance: the same instance answers
+
+GET http://localhost:8082/hello/alice
+
+### Greet bob through the first instance: possibly another instance answers
+
+GET http://localhost:8081/hello/bob
diff --git a/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/HelloCommandHandler.java b/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/HelloCommandHandler.java
new file mode 100644
index 00000000..b6ffdfd5
--- /dev/null
+++ b/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/HelloCommandHandler.java
@@ -0,0 +1,26 @@
+package io.axoniq.springcloudcommandrouting;
+
+import org.axonframework.messaging.commandhandling.annotation.CommandHandler;
+import org.springframework.cloud.client.serviceregistry.Registration;
+import org.springframework.stereotype.Component;
+
+/**
+ * Answers {@link SayHello} with a greeting naming the instance that handled it.
+ *
+ * Every instance runs this handler. Which one handles a given command is decided by the connector's routing, so the
+ * instance id in the reply shows where the command went.
+ */
+@Component
+class HelloCommandHandler {
+
+ private final String instanceId;
+
+ HelloCommandHandler(Registration registration) {
+ this.instanceId = registration.getInstanceId();
+ }
+
+ @CommandHandler
+ String handle(SayHello command) {
+ return "Hello " + command.name() + ", from " + instanceId;
+ }
+}
diff --git a/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/HelloController.java b/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/HelloController.java
new file mode 100644
index 00000000..03f10782
--- /dev/null
+++ b/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/HelloController.java
@@ -0,0 +1,26 @@
+package io.axoniq.springcloudcommandrouting;
+
+import org.axonframework.messaging.commandhandling.gateway.CommandGateway;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Sends a {@link SayHello} command for every request, and replies with whatever the handling instance answered.
+ */
+@RestController
+class HelloController {
+
+ private final CommandGateway commandGateway;
+
+ HelloController(CommandGateway commandGateway) {
+ this.commandGateway = commandGateway;
+ }
+
+ @GetMapping("/hello/{name}")
+ CompletableFuture hello(@PathVariable String name) {
+ return commandGateway.send(new SayHello(name), String.class);
+ }
+}
diff --git a/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/SayHello.java b/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/SayHello.java
new file mode 100644
index 00000000..318c8468
--- /dev/null
+++ b/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/SayHello.java
@@ -0,0 +1,16 @@
+package io.axoniq.springcloudcommandrouting;
+
+import org.axonframework.messaging.commandhandling.annotation.Command;
+
+/**
+ * Asks to greet someone.
+ *
+ * The {@code routingKey} tells the Spring Cloud connector to route by {@code name}: every command for the same name is
+ * handled by the same instance, no matter which instance it was sent from.
+ *
+ * @param name the name of whom to greet, and the key the command is routed by
+ */
+@Command(routingKey = "name")
+public record SayHello(String name) {
+
+}
diff --git a/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/SpringCloudCommandRoutingApplication.java b/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/SpringCloudCommandRoutingApplication.java
new file mode 100644
index 00000000..0abd2758
--- /dev/null
+++ b/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/SpringCloudCommandRoutingApplication.java
@@ -0,0 +1,15 @@
+package io.axoniq.springcloudcommandrouting;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * Starts one instance of the application. Start several, each on its own port, to form a cluster.
+ */
+@SpringBootApplication
+public class SpringCloudCommandRoutingApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(SpringCloudCommandRoutingApplication.class, args);
+ }
+}
diff --git a/spring-cloud-command-routing/src/main/resources/application.properties b/spring-cloud-command-routing/src/main/resources/application.properties
new file mode 100644
index 00000000..7af8e29e
--- /dev/null
+++ b/spring-cloud-command-routing/src/main/resources/application.properties
@@ -0,0 +1,10 @@
+spring.application.name=spring-cloud-command-routing
+server.port=${PORT:8080}
+
+# Every instance registers with Consul under the same service name, and so joins the same cluster.
+# The instance id has to be unique per instance, and is what the replies name.
+spring.cloud.consul.host=localhost
+spring.cloud.consul.port=8500
+spring.cloud.consul.discovery.instance-id=${spring.application.name}-${server.port}
+# This sample has no actuator, so there is no health endpoint for Consul to check.
+spring.cloud.consul.discovery.register-health-check=false