Here’s a story that plays out in a lot of “microservices” estates. You’ve split the system into services precisely so each team can ship on its own timetable. Then someone adds a field to an event, and suddenly the producer, the shared library, and every consumer have to go out together, in order, in one coordinated release. The boxes on the diagram are separate. The deployments aren’t. That’s deployment coupling, and it usually sneaks in through the most sensible-looking door in the building: a shared contracts package.
What deployment coupling actually is
Two components are deployment-coupled when you can’t ship one without shipping the other. Not “shouldn’t” — can’t, without something breaking. It’s distinct from runtime coupling (A calls B at request time) and it’s sneakier, because everything looks decoupled right up until release day, when you discover the deploy order is load-bearing and a rollback of one service breaks two others.
The clearest cause is a shared type that both sides compile against. In a messaging system that type is your message contract — the shape a producer publishes and a consumer receives — and the natural instinct is to put it in a shared package both sides reference. That instinct is where the coupling is born.
The typed-per-event trap
Say you’re building an event-log service: other services emit domain events, and it records them.
The obvious, tidy, strongly-typed design is a class per event, all living in a shared
Events.Contracts package:
// Events.Contracts — referenced by every producer AND the consumer
public record OrderShipped(Guid OrderId, DateTime ShippedAt, string Carrier);
public record OrderRefunded(Guid OrderId, decimal Amount, string Reason);
public record CustomerEmailChanged(Guid CustomerId, string NewEmail);
// ...and one more every time the business learns a new verb
And a consumer that knows how to handle each one:
public Task Handle(OrderShipped e) => _log.Record("order.shipped", e);
public Task Handle(OrderRefunded e) => _log.Record("order.refunded", e);
public Task Handle(CustomerEmailChanged e) => _log.Record("customer.email_changed", e);
It looks like good, DRY, type-safe design. It’s also a deployment trap with three teeth.
The type name is the wire contract. Most .NET messaging libraries derive the exchange, topic
or queue name from the message type’s full name. So the namespace and class name in that shared
package aren’t just code — they’re infrastructure. Rename OrderShipped, move it to a tidier
namespace, and you haven’t refactored; you’ve changed a routing key and quietly severed the link
between a producer and its consumer. The compiler won’t say a word.
Every new event type drags the consumer along. Adding OrderCancelled means a new class in
the shared package, a new Handle overload in the consumer, and a new topic/subscription in the
broker. The producer can’t ship its new event until the consumer that understands it has already
shipped. One team’s feature now has a deploy dependency on another team’s service — for an event
the consumer does nothing special with beyond writing it down.
The shared package is a lockstep pressure point. Bump its version for one consumer and every other service is now a version behind, compiling against an older contract. Keep them all in step and you’ve recreated the coordinated big-bang release you split the monolith to escape — a distributed systems tax with none of the independence you were paying it for.
The fix is less abstraction, not more
The escape is to notice what the consumer actually needs. An event-log service doesn’t care that an order shipped versus refunded — it records that something happened, with a label and a blob. So the contract can collapse to a single, stable, generic envelope:
// The entire shared contract. It stops changing.
public record EventOccurred(string EventType, string Description, string PayloadJson);
// One handler. It never grows a new overload again.
public Task Handle(EventOccurred e) => _log.Record(e.EventType, e.Description, e.PayloadJson);
Now a producer inventing order.cancelled just sends an EventOccurred with that string in
EventType and its details in PayloadJson. The consumer doesn’t change. The package doesn’t
change. The broker topology doesn’t change. Nobody redeploys but the team that added the event.
The deployment coupling is gone because the contract stopped moving — the thing that used to
change on every feature (the specific event) moved out of the shared type and into the payload.
This is the premature-abstraction argument, wearing a deploy hat
If that trade feels familiar, it should. The typed-per-event package was a premature abstraction: a strongly-typed shape for every event, modelled up front, that looked like craftsmanship and turned into coupling. The generic envelope is the “leave it as a bit of duplication / keep it dumb” move — less structure, less type safety, and far less that has to change in lockstep.
And it comes with the same honest caveat, because the trade is real:
- You lose compile-time safety on the payload.
PayloadJsonis schema-on-read — the consumer trusts a string. For a service whose job is “record it”, that’s fine; validation lives with the producer that owns the meaning. For a consumer that must act differently per event, it isn’t — you’ve just moved aswitchfrom the type system to a runtime string, and a typo fails at 2am instead of at build. - Generic is not always right. When there are a handful of high-value events the consumer genuinely branches on — payments, state machines, anything where the shape is the behaviour — a typed contract earns its keep, and the coupling is a price worth paying. The mistake isn’t typed contracts; it’s reaching for them by reflex for events nobody branches on.
The question is the same one it always is: what are you optimising for? A strongly-typed contract optimises for the consumer understanding each event. A generic envelope optimises for producers evolving independently. An event-log sink wants the second. A payment orchestrator wants the first. Pick by what the consumer actually does, not by which feels tidier in the package.
A shared contract is a one-way door
Worth saying plainly, because it changes how much care the decision deserves: a message contract other services build on is a one-way door. The moment a second team compiles against your shared package, you no longer own the timetable for changing it — every edit is a negotiation and a coordinated deploy. So the contract you do share should be the smallest, most stable thing that does the job, and everything volatile should sit behind it, in a payload or an implementation detail, where you can still change your mind on a Tuesday.
The one-line version
Splitting a system into services buys independent deployability — and a shared contracts package quietly sells it back. Share the smallest, most stable contract you can; push everything that changes often into the payload; and remember that in a message-based system the type’s name is part of the wire, so a refactor can be a breaking change wearing a friendly diff. Keep the contract still, and the deployments come unstuck from each other again.