Migrating RabbitMQ to Kafka in Spring Boot without touching a single call site
Swapping the message broker under a 65-module Kotlin modular monolith. Six versioned topics, KRaft, an idempotent producer with acks=all, and why every publish call site stayed exactly as it was.
The platform ran on RabbitMQ. It needed to run on Kafka. Retention, replay, and ordering per key were the reasons, and none of them are interesting. What is interesting is that when the migration was done, not one piece of business code had changed.
That wasn't luck. It was a decision made two years earlier, by someone who had no idea Kafka was coming.
The decision that paid
Every module in the monolith publishes through a port. Not a RabbitTemplate, not a KafkaTemplate, not an annotation from a broker library. An interface that the domain owns, with a method that takes a domain event and returns nothing.
The domain module declares what it means to publish. An adapter module, sitting outside the domain, decides what publishing actually does. That's the whole of hexagonal architecture and it is usually explained with a diagram that helps nobody. Here is the version that matters: the thing that changes and the thing that stays still must live in different modules, and the dependency must point from the changing one to the still one.
Broker choice is a thing that changes. It changed.
What actually moved
The adapter. One module. It went from a RabbitMQ implementation to a Kafka one. The interface it satisfies did not move, so nothing that calls it moved either.
Six topics, versioned in the name, because a topic name is a contract and contracts get versions. KRaft rather than ZooKeeper, because a new cluster in 2026 has no reason to carry a second distributed system just for metadata.
The producer is idempotent, with acks=all. Both of those are one line of config and both of them are the difference between "we deliver events" and "we deliver events, once, even when a broker restarts mid-publish". A producer retry without idempotency is a duplicate with extra steps.
The part nobody warns you about
Kafka does not give you ordering. It gives you ordering within a partition, which is a much smaller promise, and the partition is chosen by the key.
So the key is the design. Not the payload, not the topic, the key. Events that must not overtake each other need the same key. Events that can be processed independently should not share one, or you have built a queue with more moving parts.
The mistake I have watched people make, more than once, is to key on something convenient rather than something meaningful: a request id, a timestamp, a random UUID. Every event lands on a random partition, ordering is gone, and the bug surfaces weeks later as two updates applied backwards.
Key on the aggregate the event is about. That is almost always the right answer.
What the abstraction did not save me from
Plenty.
Consumer semantics are different. RabbitMQ hands you a message and waits for an ack; Kafka hands you a position in a log and you decide what committed means. At-least-once is the honest default, which means every consumer needs to be idempotent, which means the consumer side is where the real work was.
Retention is different. A queue drains. A topic does not. Anything that assumed "the message is gone once handled" had to be re-read.
And the operational surface is bigger. A broker you run is a broker you monitor.
The port abstraction bought exactly one thing: it kept the blast radius inside one module instead of spread across every place that publishes. That is not everything. It is the difference between a migration you can finish and one you abandon halfway.
The rule
An abstraction earns its keep the day you replace what is behind it. Until then it is a cost you are paying on speculation.
The port had been there two years, doing nothing visible, adding an interface between code and library. It looked like ceremony. Then a broker changed, and it paid for every one of those two years in a single sprint.
That is the honest case for architecture discipline. Not that it makes today's code better. It usually makes today's code slightly more tedious. It makes the change you have not thought of yet survivable.
You can see the shape it produced in the DanceClub case study: a Kotlin and Spring Boot modular monolith, 65 Gradle modules, boundaries enforced at build time rather than in review comments, currently being decomposed service by service. The decomposition is only possible for the same reason the broker swap was.
Get the next one in your inbox.
No schedule, no spam. One email when I publish something new.
If this resonated, you might like the manifesto or the archive.
Edvard Grei · edvone.dev