I was the third engineer at Ethos, so I had seen the system grow from a monolith into team-owned microservices as the engineering org scaled past roughly 50 people. But service-to-service communication was still ad hoc: direct calls, custom payloads, and team-specific retry logic made the system feel less like independent services and more like a distributed monolith.
I wrote the design proposal, compared options, presented to engineering leads, got CTO approval, and built the shared mechanics teams would depend on. Two services had already caused recurring incidents when producer and consumer payloads drifted. The decision criteria were build cost, maintenance cost, scale, reversibility, ownership, and whether product teams would actually use the result.
Turned prior incidents and operational pain into explicit requirements.
Shared mechanics lived in one place, instead of being reimplemented by each service.
Defined the ownership split: product teams kept their business logic, while the platform owned the delivery substrate.
A DB write and an emitted event cannot disagree after a crash.
One domain event should feed many consumers without the producer knowing each one.
Teams need event IDs, offsets, partitions, dashboards, and a way to answer where delivery stopped.
Every option got scored against scale, cost, reversibility, and ownership.
Network partitions and crashes cannot create a committed entity with no event.
Without a schema contract, the bus just moves breaking changes faster.
The middle can be complex. The producer and consumer APIs cannot be.
We needed event production to be easy to adopt, high-throughput, ordered where needed, and strongly consistent. Since every service already used Postgres, and every engineer understood database transactions, we decided services would produce events through Postgres.
Postgres gave producers a transaction boundary they already understood.
Product code records the event. CDC handles broker publishing asynchronously.
Ordering keys let the platform preserve sequence where business workflows needed it.
The naive approach was: write the business row, then publish an event. If the service crashed between those steps, the database was correct locally, but downstream services never learned that anything changed.
The fix was to write the business row and the outbox row in the same Postgres transaction. If the transaction committed, both existed. If it rolled back, neither existed. A CDC monitor could then publish durable outbox rows to the event stream.
delivered_at tracks publisher progress, not exactly-once delivery.partition_key preserves order for related events.
The producer-facing API created a normal Postgres row. The platform-owned monitor tailed committed rows, validated payloads, published them, and recorded progress. A crash after publish but before completion bookkeeping could produce a duplicate. Product teams therefore built handlers that could safely process the same event again.
CREATE TABLE outbox_events (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
name text NOT NULL,
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
partition_key text,
payload jsonb NOT NULL,
source text NOT NULL,
failure_strategy text CHECK (failure_strategy IN ('retry', 'drop', 'dlq')),
delivered_at timestamp with time zone
);
We needed producers and consumers to agree on event shape, versioning, and compatibility before messages reached production. Each producer owned the schema for the events it emitted.
Before schemas, the contract lived in code, docs, Slack threads, and assumptions between teams. Two services repeatedly caused incidents when their payload expectations drifted. With Avro and Schema Registry, the event shape became a versioned artifact. Producers owned the schema, consumers could validate compatibility, and the recurring drift failure dropped sharply.
Kafka fit the scaling requirements: fanout, partitions, replay, offsets, and better debugging. But making every product team write Kafka consumers would have moved complexity to the worst place.
Familiar, but not the best long-term fit for ordering, replay, and broker-level debugging.
Most teams chose this path: less flexible than direct Kafka, but easier to operate correctly.
Good for specialized teams. Too much offset, retry, and client responsibility as a default.
input:
kafka:
addresses: [ ... ]
topics: [ policy_events ]
consumer_group: policy_service
pipeline:
processors:
- for_each:
- mapping: |
root.event_id = this.id
root.name = this.name
root.payload = this.payload
output:
http_client:
url: http://localhost:8080/events
verb: POST
The platform needed more than a broker and a dispatcher. Teams also needed a clear path for creating topics and making the few choices that really mattered.
The first version used a custom WAL monitor because I did not yet know about Debezium. It proved the outbox design, but it also made replication behavior platform code we had to own. Moving toward Debezium kept the producer and consumer contracts intact while replacing custom CDC machinery with a purpose-built tool.
I built the initial replication and publishing path around committed outbox rows.
The platform team inherited WAL behavior, replication health, and recovery logic.
The product-facing outbox API stayed stable while the publishing implementation matured.
Creating a policy meant the producer service owned the full orchestration: call downstream services, wait for responses, and save local state. Every new consumer or dependency made that producer more coupled.
Before the bus, Service A had to know which services cared about a policy change, how to call them, and what to do when one dependency failed. The event bus changed that ownership model: Service A emitted a durable business fact, and each consumer owned its reaction.
Owns downstream calls, retries, and response contracts.
Owns the state change and emitted event.
Owns its handler, failure policy, and operational behavior.
Approximately 5–10 services adopted the platform across roughly 10–20 topics. Kafka partitions and offsets improved replay and debugging. The dispatcher let most teams keep a normal HTTP handler while specialized consumers retained direct Kafka access.
A stalled CDC consumer can hold a replication slot open and grow WAL until the database is at risk. That makes replication lag a product reliability issue, not just an infra metric.
The normal delivery contract was at least once. Product teams had to make handlers safe for duplicates and select a failure policy for each path. Infinite retry preserved partition order by blocking later events. Other consumers could explicitly drop a failed event or move it to a DLQ.
Replication slot health, WAL growth, and publisher lag belonged to the platform.
A repeated event could not create an incorrect second business operation.
Teams chose whether a poison event blocked its partition, moved aside, or was discarded.
Developer experience should now be designed more for agents than humans. In 2021, I built a system that any product engineer could use safely without becoming an infrastructure expert. In 2026, I would give an agent the context and guardrails to help a strong engineer own more of the system.
I migrated mainly to reduce bus-factor risk: the high-throughput WAL processing and Kafka queueing code was complex and deeply technical. My Go implementation also outperformed Debezium in our workload. Today, an agent could quickly explain it to a strong engineer, making the custom system maintainable without me available for questions.
In 2021, asking the average product engineer to learn Kafka well enough to build a safe consumer was too large a lift, so Benthos delivered events to HTTP endpoints. In 2026, I would keep that sidecar as the default but use a skill to teach MSK IAM auth and scaffold a direct consumer when a team wants more control.
In 2021, engineers read my Notion guide, then worked with me to choose partitions and other topic requirements; I applied the Terraform module. In 2026, a Slack agent should run that interview, open the Terraform PR, send it to the engineer, and merge after approval. I only join for edits or deletes.
With access to Datadog and the codebase, an agent knows which monitors and code paths map to each part of the bus. It can correlate WAL growth, CDC and consumer lag, DLQs, and recent deploys—then guide the owning engineer to the likely failure.
When a schema changes, an agent can find every repository and service that produces or consumes it, check compatibility, propose the coordinated code changes, and sequence the rollout.
State and event commit together. CDC takes the complexity after commit.
Most teams consume over HTTP through a dispatcher. The platform owns broker complexity.
Events become shared APIs. Schema compatibility is part of the platform.