In Message-Based Systems, Who Owns the Contract?
When we start breaking a monolith into services, we trade function calls for message queues. It feels like decoupling. We're sending JSON over the wire, so surely, we're building independent systems, right?
Not necessarily.
The most common trap I've seen teams fall into isn't network failure or eventual consistency—it's contract coupling. If changing a class in Service A forces a recompile of Service B, you haven't decoupled anything; you've just made your method calls slower and harder to debug. We call this the Distributed Monolith.
To avoid this, we need to answer a subtle but critical question for every message we define: Who owns this contract?
The answer isn't always the same. It depends entirely on whether we are sending a Command or an Event.
The Distinction: Commands vs. Events
People often conflate these two concepts because they look similar in code—they're both just POCOs (Plain Old CLR Objects) serialized to JSON. But their intent—and therefore their ownership model—is fundamentally different.
Commands: "Do This"
A command is an imperative instruction. You are telling a specific service to perform a specific action.
- Intent: "I want you to change your state."
- Examples:
PlaceOrder,ShipItem,ChargeCreditCard.
// Implicit instruction to a specific receiver
public class ShipOrder
{
public Guid OrderId { get; set; }
public string ShippingMethod { get; set; } // "Express" or "Standard"
}
Events: "This Happened"
An event is a statement of fact about the past. It is a broadcast to anyone who might be interested, without knowledge of who those listeners are.
- Intent: "I have changed my state, and here is the proof."
- Examples:
OrderPlaced,ItemShipped,CreditCardCharged.
// Fact broadcast to unknown listeners
public class OrderShipped
{
public Guid OrderId { get; set; }
public DateTime ShippedAt { get; set; }
public string TrackingNumber { get; set; }
}
Application: Who Owns the Schema?
Ownership rules dictate who has the authority to change the schema and who must adapt to those changes.
1. The Receiver Owns the Command
If I want you to do something for me, I have to ask you in a language you understand.
- The Owner: The Receiver (Consumer).
- Why: The service performing the action knows what data it needs to do its job. If the
ShippingServicerequires aScanningCodeto ship an item, theOrderService(the sender) must provide it. - Coupling: High. The sender is coupled to the receiver's API. Ideally, commands should stay internal to a bounded context. If you send commands across system boundaries, you lock those systems together.
2. The Publisher Owns the Event
If I am telling you something happened to me, I define what that truth looks like.
- The Owner: The Publisher (Producer).
- Why: The publisher is the authority on its own domain state. If the
OrderServicesays an order was placed, it and only it defines what an "Order" looks like. - Coupling: Low. Subscribers listen to the concept of the event. They are coupled to the schema, but they don't tell the publisher how to behave.
The Sharing Dilemma: How to Distribute Contracts?
Once we accept that the Publisher owns the event contract, we hit a technical challenge: How do we share these C# classes between services?
The Trap: The "Common" Library
The path of least resistance is often to create a single MyCompany.Shared or MyCompany.Contracts project. We dump every DTO, formatting helper, and extension method into it, pack it as a NuGet, and reference it in every microservice.
The Result:
- Global Locking: If
Service Aneeds to change anOrderfield, we update the Common package. - Ripple Effect:
Service B,Service C, andService Zall see a package update. Even ifService Zonly cares aboutUserobjects, it might be forced to update to get a fix forOrder. - Ambiguity: Who owns
OrderPlacedin this big bucket? It's just a class in a massive namespace.
The "Shared Nothing" Approach (Copy-Paste)
To fix this, some architects swing to the other extreme. "Share nothing!" implies that if Service A emits an OrderPlaced event, Service B must manually define its own matching class to deserialize the JSON.
The Result:
- Maximum Autonomy: Service B is completely decoupled from Service A's build pipeline. It's truly "Consumer-Driven"—Service B only maps the fields it actually needs.
- Maintenance Pain: You lose compile-time safety. If A renames a field, B only finds out when the integration tests fail (or worse, in production).
The Sweet Spot: Owner-Specific Contract Packages
My preferred approach strikes a balance between autonomy and developer sanity.
Instead of one giant "Common" library, or zero libraries, we create small, focused, owner-specific NuGet packages.
How it works:
- Publisher Ownership: The
OrderServicesolution contains a project calledOrderService.Contracts. - Zero Dependencies: This project contains only POCOs. No logic, no external libraries, just public properties.
- Namespace Clarity: The classes live in namespaces like
OrderService.Contracts.Events. - Granular Consumption: If the
BillingServiceneeds to listen toOrderPlaced, it installs theOrderService.ContractsNuGet package. It does not inherit dependencies on theInventoryServiceorShippingService.
Why this works:
- Clear Authority: The namespace tells you exactly who owns the truth. If you see
OrderService.Contracts, you knowOrderServiceis the source of truth. - Testability: The owner can easily version and test their own contracts within their own build pipeline.
- Reduced Blast Radius: A change to the
Ordercontract only forces a recompile on services that actually consumeOrdermessages.
Summary
Coupling is the enemy of distributed systems. By recognizing that Events are the key to decoupling, and that Publishers own those events, we can structure our code to reflect that reality.
Don't create a "Common" dumping ground. Let your services publish their own "Contract" packages. It keeps boundaries clean, ownership clear, and allows your system to evolve one service at a time.
You May Also Like
The "Big Save" Problem: Why Task-Based UI is Event Sourcing’s Best Friend
Brad Jolicoeur - 02/16/2026
Profiling .NET 10 Applications: The 2026 Guide to Performance
Brad Jolicoeur - 02/14/2026
The Trap of Database Triggers in Event-Driven Architecture