Let your modules talk by events, not by calls

One part of your system needs to know what another part decided, so it asks. That call is a decision about deployment, scaling and availability that nobody in the room made.

Your availability screen needs to know which desks are taken. The bookings live over there, so you call over there and ask. It takes a minute to write and it is obviously correct.

It is also the moment your two components became one. Availability now cannot answer while bookings is down, cannot be deployed separately, and cannot be scaled for the read-heavy load it actually has. Nobody decided any of that, and nobody will notice until the day one of them matters.

A query into the write side against an event out of it Above, the read side calls into the write side to answer a question, so it cannot answer while the write side is down. Below, the write side publishes an event and the read side keeps its own copy, so neither calls the other. a query read side write side cannot answer while the other is down an event read side write side keeps its own copy, and neither one calls the other
The direction of the arrow is the whole decision. One of these can be split into two deployables later and one cannot. Illustrative — the shape of the argument, not measured data.

Turn the arrow around

The alternative is that the write side announces what it did and the read side keeps its own answer.

// booking, when it succeeds
events.publish(new DeskBooked(booking.id(), deskId, date, clock.instant()));

// availability, which never calls booking
public void on(DeskBooked event) {
    occupied.occupy(event.deskId(), event.date());
}

Neither one calls the other now. The coupling is a data shape rather than a method signature, and a data shape is the thing that survives being put on a queue when you eventually need to.

Three decisions inside that, which are easy to get wrong

Publish facts, not aggregates. The event carries an id, a desk and a date. It does not carry the Booking. Pass the whole object and every field of an internal type becomes part of the contract between components, and the first rename is a breaking change nobody expected to be making.

Give the events a home other things may read. In my own service the rule is that availability may import booking.domain.event and nothing else in booking, and an architecture test says so. Without a named place, “published” and “internal” are the same package and the boundary lasts until somebody is in a hurry.

Make the handler idempotent, before you need it. In process an event arrives once. The day it comes from a broker it arrives at least once, sometimes twice, and a handler that increments rather than sets will drift. Write it so replaying changes nothing, and write the test that proves it while it still looks like paranoia.

The test that has to exist

Here is the failure that this design introduces, and it looks like nothing.

“Booking publishes an event” is testable. “The projection reacts to an event” is testable. Both can be green while nothing is subscribed, and then the read model is simply never updated. No error, no exception, no failing test. A desk that was taken an hour ago sits there looking free.

So one test boots the whole thing, books through the real use case, and asks the read side:

this.bookDesk.book(A01, TODAY, "ada@example.com");

assertThat(availabilityOf(A01)).isFalse();

It is the only test in that suite that needs a container, and it is the only one that would notice a missing subscription. Every design that decouples two things needs one test that puts them back together.

What it costs, said plainly

The read model becomes eventually consistent. In process the window is microseconds, and it is not zero, and you should say so out loud rather than discover it during an incident.

You also now have a delivery problem you did not have before: the write commits, then the event is published, and a crash in between loses it. That is solvable, and it is a real cost of this shape rather than a footnote.

The thing you get back is that the two halves can be separated later without a rewrite, because the only thing between them is a message. That is the same trade as putting the decidable half in a script: choose the shape that keeps a later decision open, and pay a small, visible cost now.


If two parts of your system are welded together by a call nobody remembers making, that’s the work I do.


Working through this in your own team?

I help engineering teams adopt AI coding assistants without giving up engineering rigour, and design the architecture underneath. If that's on your plate, let's talk.