Context
In an event-driven microservices estate, services need to publish domain events when their state changes, and those events have to be reliable: never lost, never published for a change that was later rolled back. The standard answer is the transactional outbox pattern, which isn't hard to get right once. The catch was that every team was solving it on its own, some correctly and some subtly wrong.
This was my graduation project: design and build a reusable framework that turns reliable event publishing into a solved, drop-in concern instead of bespoke plumbing per service.
The problem
The obvious approach, write to the database and then publish to Kafka, has a dual-write failure mode: the transaction commits but the publish fails (or vice versa), and now the event stream disagrees with the source of truth. Each team solved this independently, which meant:
- Repeated effort. Outbox tables, polling/relay logic, serialization, retry and ordering concerns, re-implemented service by service.
- Inconsistent correctness. Subtle differences in how each team handled failure meant the reliability guarantee wasn't actually uniform.
- Cognitive load in the wrong place. Teams were spending design energy on infrastructure plumbing instead of their actual domain.
Options considered
Application-level outbox relay
Each service writes to an outbox table in the same transaction as its business change, and a relay process reads the table and publishes to Kafka. Correct, but every service still owns the relay, the polling, and the failure handling, so the duplication problem stays.
Publish directly, accept eventual reconciliation
Skip the outbox and reconcile drift after the fact. Rejected: reconciliation is harder to reason about than prevention, and it weakens the very guarantee the framework exists to provide.
Change-Data-Capture via Debezium, abstracted into a framework
Let the database's commit log be the source of events: Debezium captures committed changes from the outbox and streams them to Kafka, so a change is published if and only if it committed. Wrap the outbox contract, serialization, and topic conventions in a framework teams adopt rather than rebuild.
The decision, and why
I built a framework, not a one-size publisher. Two design choices carried it:
- Template Method over the Kafka + Debezium plumbing. The mechanical parts every team would otherwise rebuild (the CDC pipeline, serialization, topic conventions, retry and ordering) are fixed in a base template, leaving only the domain-specific hooks for each service to fill in.
- A Strategy for the delivery guarantee. Reliability isn't free, and not every service needs the strongest guarantee, so the framework exposes a strategy. A team picks straightforward transactional publishing where that's enough, or the full outbox + CDC path where the dual-write hole has to be closed: same API, different guarantee.
It's packaged for Kotlin / Spring Boot and designed with DDD boundaries so events map cleanly to domain concepts. The payoffs:
- No dual-write hole. On the outbox + CDC path, tying publication to the commit log removes the failure mode entirely: there's no window where the DB and the stream can disagree.
- Adopt, don't rebuild. Teams get reliable publishing as a dependency and a small contract, not a plumbing project.
- Consistency across the estate. One implementation of the hard part means one place to reason about and improve the reliability guarantee.
This one is cleanly mine: sole designer and author, end to end, as my graduation project, graded 8.5. The developer-year projection below was mine too, and I reviewed the method and the numbers with the solution architect and senior engineers so the estimate had more than my own optimism behind it.
Outcome
Reliable event publishing became a drop-in concern. Instead of each team designing, building, and debugging its own outbox-and-relay, they take the framework as a dependency and a small contract.
The headline figure is a projection, and worth showing as a calculation rather than a round number. Building the publishing-and-outbox capability from scratch (just that capability, not the whole service) runs roughly 8 weeks per service. With the framework it drops to about 2, so on the order of 6 developer-weeks saved for each service that adopts it. Summed across the services that would otherwise each rebuild it, that is a projected ~16 of an estimated ~24 developer-years. It is a projection, not a banked result: the honest claim is the per-service saving and how it was derived, which I checked with the solution architect and senior engineers, not a measured number after the fact.
Reflection
The shift that interested me was one of framing. The valuable output wasn't the code that publishes one event; it was removing a decision from every future service. Designing for adoption (a small contract, sensible defaults, DDD-aligned boundaries) mattered more than any single technical trick. This is the project that pointed me most clearly toward platform and solution-architecture work.