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.
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.