Coupling as the degree of dependency between modules; types from content to data coupling; temporal, deployment and database coupling in distributed systems; techniques to reduce it.
Coupling is the degree to which one module depends on another: how much it must know about it, and how likely it is to change when the other changes. Some coupling is unavoidable; the goal is coupling that is explicit, narrow and stable. In code the harmful kinds are reaching into another type's internals, shared mutable global state and passing control flags. In distributed .NET systems the expensive kinds are temporal coupling (synchronous call chains), shared databases and lock-step deployments. I reduce them with narrow interfaces, published contracts, asynchronous messaging where it fits, and each service owning its data.
"Coupling is the degree to which one module depends on another: how much it has to know about it, and how likely it is to change or break when the other one changes. Zero coupling isn't the goal, because modules have to collaborate. The goal is coupling that's explicit, narrow and stable. At code level, the harmful kinds are reaching into another type's internals, shared mutable global state, and control flags where the caller dictates the callee's branches. Passing just the data needed is the healthy end. At system level in .NET, the expensive kinds are temporal coupling (synchronous call chains where A fails when B is down), shared databases (the schema becomes an unowned contract), deployment coupling (services that must release together), and shared domain libraries that force everyone to upgrade at once. I reduce them with narrow consumer-owned interfaces, published and versioned contracts, events through an outbox where the caller doesn't need an immediate answer, each service owning its data, tolerant readers, and architecture tests that keep module boundaries honest."
| Kind | Example | Fix |
|---|---|---|
| Content | Reflection setting another class's private field; relying on internal behaviour | Use the public API; ask for one |
| Common | Static mutable AppState read/written everywhere |
Injected services with clear lifetimes |
| External | Two modules parsing the same file format independently | One owner of the format |
| Control | Process(order, isRefund: true, skipEmail: false) |
Separate, well-named operations |
| Stamp | Passing a whole Customer when only the email is needed |
Pass what's needed (or a narrow interface) |
| Data | Passing exactly the values required | ✅ the healthy end |
| Kind | Symptom | Mitigation |
|---|---|---|
| Temporal | Service A fails when B is down or slow | Events via outbox, queues, timeouts + fallbacks, caching |
| Shared database | A schema change breaks three services | Each service owns its data; expose APIs/events |
| Deployment | Services must be released together | Versioned contracts, additive changes, expand/contract |
| Schema/contract | A renamed field breaks consumers | Contract tests, tolerant readers, versioning |
| Shared library | One NuGet update forces every service to redeploy | Share contracts, not domain logic |
| Location | Hard-coded hostnames/ports | Configuration, service discovery |
| Platform | Code full of vendor SDK calls | Ports and adapters at the boundary |
| Technique | Reduces |
|---|---|
| Narrow, consumer-owned interfaces (ISP + DIP) | Knowledge of implementation details |
| Events for reactions ("order placed") | Temporal coupling, fan-out dependencies |
| Transactional outbox | Lost events when publishing after a DB write |
| Owned data per service/module | Schema coupling |
| Tolerant reader (ignore unknown fields) | Producer/consumer lock-step changes |
| Expand/contract migrations | Deployment coupling for schema changes |
| Architecture tests (NetArchTest) in modular monoliths | Boundary erosion |
| Named records instead of positional tuples across boundaries | Connascence of position |
| Situation | Keep coupling |
|---|---|
| Caller genuinely needs an immediate answer | A synchronous call with timeout is honest |
| Code inside one cohesive module | Direct calls are fine |
| Stable framework/BCL types | Depending on them is cheap |
| Very small systems | Messaging infrastructure may cost more than it saves |
Replacing every call with messaging trades visible coupling for hidden coupling (event schemas, ordering, eventual consistency).
In our [retail platform], [four] services read and wrote the same Orders table. Every column change required a coordinated release of all four at [2 am], and once a missed service caused an outage. I mapped each reader and writer and the columns they used, then made the order service the owner. It publishes OrderPlaced and OrderShipped events through a transactional outbox, and the other services consume them to build their own read models. Database permissions now restrict each service to its own schema, and contract tests guard the event shapes. Schema changes became single-service releases, and an outage in the order service no longer stopped the others from serving reads.
Checks architectural thinking across code and system levels.
Green flags
Red flags
| Concept | Source |
|---|---|
| Coupling types (content → data) | Constantine & Yourdon, Structured Design |
| Afferent/efferent coupling, instability | Robert C. Martin, package principles |
| Connascence | Meilir Page-Jones; popularised by Jim Weirich |
| Temporal/deployment coupling in distributed systems | Microservices literature (Newman, Richardson) |
| Metric | Meaning | Implication |
|---|---|---|
| Ca (afferent) | How many modules depend on this one | High Ca → changes are risky; keep it stable and well tested |
| Ce (efferent) | How many this one depends on | High Ce → breaks often when dependencies change |
| Instability I = Ce / (Ca + Ce) | 0 = stable, 1 = unstable | Stable modules should be abstract; volatile ones concrete (Stable Dependencies Principle) |
A domain core should be stable (many depend on it, it depends on little). Adapters should be unstable (they depend on vendors and change often).
| Connascence of | Example | Strength |
|---|---|---|
| Name | Calling SendReceipt() |
Weak (fine across boundaries) |
| Type | Passing a Guid where a Guid is expected |
Weak |
| Meaning | status == 3 means "shipped" |
Medium: use enums/constants |
| Position | (string, string, decimal) tuple order |
Medium: use named records |
| Algorithm | Both sides must hash the same way | Strong: one owner |
| Timing / execution order | B must run after A completes | Strong: keep inside one module |
Rule: weaker connascence across boundaries, stronger only within a module.
A → B → C, each 99.9% available
best-case availability of A's request ≈ 0.999 × 0.999 × 0.999 ≈ 99.7%
plus latency adds up, and a slow C ties up threads/connections in A and B
Events, caching and making non-critical calls asynchronous break the chain; timeouts and circuit breakers contain damage where synchronous calls remain.
Each step is independently deployable.
High cohesion inside modules makes low coupling between them possible: when a feature's code and data live together, fewer calls and shared tables cross boundaries. Splitting a system along the wrong lines produces services that are both incohesive and tightly coupled ("distributed monolith").
dotnet list reference).// Control coupling: the caller dictates internal flow with flags.
public sealed class NotifierWithFlags
{
public string Notify(string email, bool isRefund, bool skipEmail) =>
skipEmail ? "skipped" : isRefund ? $"Refund email to {email}" : $"Receipt to {email}";
}
// Lower coupling: explicit operations and data coupling (only what is needed).
public sealed class Notifier
{
public string SendReceipt(string email) => $"Receipt to {email}";
public string SendRefundConfirmation(string email) => $"Refund email to {email}";
}
// Temporal decoupling: publish an event instead of calling other modules directly.
public sealed record OrderPlaced(Guid OrderId, string CustomerEmail, decimal Total);
public interface IEventPublisher
{
Task PublishAsync<T>(T @event, CancellationToken ct = default);
}
public sealed class InMemoryPublisher : IEventPublisher
{
public List<object> Published { get; } = [];
public Task PublishAsync<T>(T @event, CancellationToken ct = default)
{
Published.Add(@event!);
return Task.CompletedTask;
}
}
public sealed class PlaceOrder(IEventPublisher events)
{
// The order module does not know who sends emails, updates loyalty points or ships.
public Task HandleAsync(Guid id, string email, decimal total, CancellationToken ct) =>
events.PublishAsync(new OrderPlaced(id, email, total), ct);
}
public static class Demo
{
public static async Task Main()
{
var publisher = new InMemoryPublisher();
await new PlaceOrder(publisher).HandleAsync(Guid.NewGuid(), "[email protected]", 99m, default);
Console.WriteLine(publisher.Published.Count);
Console.WriteLine(new Notifier().SendReceipt("[email protected]"));
}
}
In production, publish through a transactional outbox so the event is not lost if the broker is down.
What happens: several services read/write the same tables. Why it is wrong: the schema is an unowned contract; changes need lock-step releases. Do instead: one owner; others use its API or events.
What happens: async events for everything "to decouple". Why it is wrong: hidden coupling via schemas and ordering; harder debugging; eventual consistency everywhere. Do instead: events where the caller doesn't need an immediate answer.
What happens: Notify(email, isRefund, skipEmail).
Why it is wrong: the caller must know the callee's internal branches.
Do instead: separate operations with clear names.
What happens: a common domain NuGet package. Why it is wrong: one update forces coordinated redeploys. Do instead: share contracts and versioned schemas only.
What happens: layers of indirection to avoid any direct dependency. Why it is wrong: modules must collaborate; you add cost without benefit. Do instead: make coupling explicit, narrow and stable.
What happens: ICustomerService with 30 members mirroring the class.
Why it is wrong: the consumer still knows everything.
Do instead: narrow, consumer-owned interfaces.
What happens: public static class AppState { public static User Current; }.
Why it is wrong: common coupling; races; untestable.
Do instead: injected services with defined lifetimes.
What happens: save to DB, then publish; the publish fails. Why it is wrong: consumers miss changes; data diverges. Do instead: transactional outbox (or change data capture).
What happens: consumers fail on unknown fields. Why it is wrong: producers can't add data without breaking consumers. Do instead: tolerant readers; additive contract changes.
What happens: checkout calls loyalty, email and analytics synchronously. Why it is wrong: availability multiplies down and latency adds up. Do instead: events for side effects; keep only essential calls synchronous with timeouts.
I think of coupling as a cost I choose to pay, and I want to pay it in the cheapest currency: a stable, versioned contract rather than shared tables or timing assumptions.
| Coupling found | Preferred replacement |
|---|---|
| Shared database | Owned data + API or events |
| Synchronous chain for non-urgent work | Event via outbox |
| Shared domain NuGet | Contract package or OpenAPI client |
| Static mutable state | Injected service with clear lifetime |
| Flag parameters | Separate methods |
| Positional tuples across boundaries | Named records |
Cohesion is how strongly the parts of a module belong together. A highly cohesive class has methods that work on the same data towards one purpose, so a change to that concept happens in one place. Low cohesion shows up as Utils or Manager classes with unrelated methods, fields that only one or two methods use, and classes that change for many different reasons. I improve it by grouping code around domain concepts and features, moving methods to the data they use, and splitting classes whose fields form separate clusters.
DIP says high-level policy should not depend on low-level details; both should depend on abstractions, and the abstraction belongs to the high-level side. For example, the domain or application layer defines IPaymentGateway, and the infrastructure project implements it with Stripe; the arrow of source dependency points inwards. Dependency injection is a technique for supplying those implementations (constructor injection), and the .NET DI container is a tool that automates it. You can use a container without inverting anything (if interfaces live next to their implementations in infrastructure), and you can invert dependencies without a container.
ISP says clients should not be forced to depend on methods they do not use. A fat interface such as IOrderService with 25 members couples every consumer to all of them, makes test doubles painful and pushes implementers to throw NotImplementedException. Split interfaces by what clients need (role interfaces): IOrderReader for queries, IOrderWriter for commands, IClock instead of a giant IUtilities. The BCL does this well (IReadOnlyCollection<T> versus ICollection<T>, IAsyncDisposable). Do not go to the extreme of one interface per method; group members that the same client uses together.
How to tell it (STAR)
Four of our services shared one orders table, so a column rename needed a coordinated [2 am] release, and once we missed one and went down. I made the order service the owner and had it publish events through an outbox, and the other services built their own read models. Schema changes became a single-service release.
Last updated 31 Oct 2026.