📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What does low coupling mean, what kinds of coupling exist in .NET systems, and how do you reduce the harmful ones?

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.

Senior Architecture Advanced architecture coupling design-principles microservices

🎯 Key points

  • Coupling is how much one module must know about or change with another
  • Some coupling is necessary; aim to make it explicit, narrow and stable
  • Code-level kinds: content, common (global state), control (flags), stamp, data
  • System-level kinds: temporal, deployment, shared database, schema
  • Reduce with narrow interfaces, events, published contracts and owned data
  • Measure with afferent/efferent coupling and change history

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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


1. Code-level coupling (worst → best)

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

2. System-level coupling in .NET systems

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

3. Techniques to reduce harmful coupling

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

4. When not to decouple

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

Production example

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.

🔍 What the interviewer is checking

Checks architectural thinking across code and system levels.

Green flags

  • Distinguishes code-level and system-level coupling.
  • Names temporal, database and deployment coupling.
  • Knows coupling cannot be zero and should be stable and explicit.
  • Mentions outbox, contracts or architecture tests.

Red flags

  • "Microservices automatically give low coupling."
  • Treats interfaces alone as decoupling.

📚 Learn in depth

1. Origins and vocabulary

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)

2. Afferent and efferent coupling

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

3. Connascence: a finer-grained lens

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.

4. Synchronous chains and availability

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.

5. Shared database → owned data, incrementally

  1. Inventory every reader/writer and the columns used.
  2. Pick an owner; route writes through the owner's API.
  3. Publish events (outbox) for changes others care about.
  4. Consumers build local read models; remove their direct reads.
  5. Lock down permissions per schema.

Each step is independently deployable.

6. Coupling and cohesion together

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

7. Try it yourself

  1. Visualise your project references (Visual Studio dependency diagram, NDepend or dotnet list reference).
  2. Compute Ca/Ce for a few projects and check that high-Ca ones are stable and tested.
  3. Replace one boolean control flag with two well-named methods.
  4. Find positional tuples crossing module boundaries and replace them with records.
  5. Map who reads and writes your most-shared table, and sketch an ownership plan.

💻 Code

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

⚠ Common mistakes

1. Sharing a database between services

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.

2. Replacing every call with messaging

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.

3. Boolean control flags

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.

4. Sharing domain libraries across services

What happens: a common domain NuGet package. Why it is wrong: one update forces coordinated redeploys. Do instead: share contracts and versioned schemas only.

5. Aiming for zero coupling

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.

6. Thinking interfaces alone decouple

What happens: ICustomerService with 30 members mirroring the class. Why it is wrong: the consumer still knows everything. Do instead: narrow, consumer-owned interfaces.

7. Static mutable state

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.

8. Publishing events without an outbox

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

9. Strict deserialisation across boundaries

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.

10. Synchronous chains for non-critical work

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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate links low coupling to keeping related code together Why: Probes cohesion
    How to answer

    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.

  2. When: Candidate depends on consumer-owned abstractions Why: Checks dependency inversion
    How to answer

    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.

  3. When: Candidate prefers narrow interfaces Why: Links to interface segregation
    How to answer

    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.

🛠 Real-world problems: how you handle them

Four services sharing one Orders table
Situation
[Four] services read and wrote the same `Orders` table in a [retail platform].
Signals
Coordinated [2 am] releases for schema changes; an outage when one service was missed.
Investigation
Mapped every reader and writer of the table and the columns each used.
Resolution
Order service became the owner; others consumed `OrderPlaced`/`OrderShipped` events via an outbox and kept their own read models.
Prevention
Database permissions restrict each service to its own schema; contract tests on events.

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.

↩ Usually asked after

More in Additional Principles

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me