Mert Özen Further With Every Line
Software Design SOLID and Design Principles — #02

Coupling and Cohesion: Two Measures of Good Design, Connascence and the Law of Demeter

Mert Özen Nov 7, 2026 15 min 3 views
Coupling and Cohesion: Two Measures of Good Design, Connascence and the Law of Demeter

Good design means weak links between parts (coupling) and strong unity inside each part (cohesion); connascence grades those links by kind, and the Law of Demeter limits how far objects reach into each other. Kinds of coupling and levels of cohesion, connascence of position in a six-parameter constructor, the TimeSpan(10) trap, and protecting a wallet balance with Tell, Don't Ask.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

A new customer record arrives in Bookstore. A shipping label will be printed and the order checked for being domestic; international shipments carry an extra fee. The code compiles with no warnings. What do the two lines print?

Customer ada = new("Ada", "Lovelace", "ada@example.com", "Ankara", "06100", "Turkey");

Console.WriteLine(ShippingLabel.For(ada));
Console.WriteLine(ShippingLabel.IsDomestic(ada));

public sealed class Customer(
    string firstName, string lastName, string email,
    string postalCode, string city, string country)
{
    public string FirstName { get; } = firstName;
    public string LastName { get; } = lastName;
    public string Email { get; } = email;
    public string PostalCode { get; } = postalCode;
    public string City { get; } = city;
    public string Country { get; } = country;
}

public static class ShippingLabel
{
    public static string For(Customer c) => $"{c.FirstName} {c.LastName}, {c.PostalCode} {c.City}, {c.Country}";

    // Turkish postal codes have 5 digits
    public static bool IsDomestic(Customer c) => c.Country == "Turkey" && c.PostalCode.Length == 5;
}

Output:

Ada Lovelace, Ankara 06100, Turkey
False

The city and the postal code swapped places on the label, and Ada's order was treated as international. The mistake is on the caller's side: the constructor expects postalCode, city, the caller passed city, postalCode. Both are string, so the compiler has nothing to object to. The person who wrote this call had one order in mind, the person who wrote the constructor had another; the only thing linking the two is the position of the parameters.

In this post we look at three ways to measure links like this. The first is the classic pair from the 1970s: coupling (how strong the link between two parts is) and cohesion (how much the things inside a part belong together). The second is connascence, proposed in 1992 by Meilir Page-Jones (an author and consultant known for his books on object-oriented design), which grades a link by its kind. The third is the Law of Demeter, which limits how far objects may reach into each other, together with its close relative Tell, Don't Ask. The rigidity and fragility we saw in the previous post always had these links at their root; now we give them names.

Coupling and Cohesion: Two Axes

Both concepts come from the "Structured Design" paper by Wayne Stevens, Glenford Myers and Larry Constantine (pioneers of the structured design approach), published in the Systems Journal of IBM (International Business Machines) in 1974; they spread further with Edward Yourdon and Constantine's 1979 book Structured Design. The definitions are short: coupling is how much one module depends on another; cohesion is how much the parts inside a module serve the same purpose. The classic formula for good design is summarized as "low coupling, high cohesion".

Since the two axes are independent, there are four combinations:

02-coupling-cohesion-en.png

The top left is where we want to be: a part focused on one job that exposes a narrow surface. The bottom right holds the god class everyone knows: a 2,400-line OrderService that handles orders, invoices and notifications; messy inside, with everybody depending on it. The other two corners are discussed less but are at least as common. The part in the top right is tidy inside but depends on its neighbours' internal details; the reflection example we will see shortly falls here. The part in the bottom left depends on nobody but holds things that have nothing to do with each other: the BookstoreUtils every project has. You also end up here when you split something into pieces to reduce coupling and destroy cohesion in the process; the link does not disappear, it just becomes invisible.

Kinds of Coupling: From Worst to Best

The classic classification orders coupling from strongest to weakest: content, common, external, control, stamp and data. Let's take one Bookstore example for each.

Content coupling is a module reaching directly into another module's internal details. In C# the most typical form is accessing a private field through reflection:

using System.Reflection;

Order order = new();
order.Add("978-0441013593");
order.Add("978-0141439518");

Console.WriteLine(OrderAudit.CountItems(order));

public sealed class Order
{
    // Renamed from _items to _lines last sprint
    private readonly List<string> _lines = [];

    public void Add(string isbn) => _lines.Add(isbn);
}

public static class OrderAudit
{
    // Reaching into another class's private field: content coupling
    public static string CountItems(Order order)
    {
        FieldInfo? field = typeof(Order).GetField("_items", BindingFlags.NonPublic | BindingFlags.Instance);
        return field?.GetValue(order) is List<string> items
            ? $"{items.Count} items"
            : "field '_items' not found";
    }
}
field '_items' not found

Whoever wrote Order was perfectly right to rename the field; it was a private detail. But OrderAudit depended on that detail and the compiler could not see it. This code doesn't even throw; it quietly returns a wrong answer. Internal members opened to a test project with InternalsVisibleTo and configurations that bind to private field names for serialization are softer versions of the same risk.

Common coupling is several modules sharing the same global, mutable data:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

decimal paid = Checkout.Pay(listPrice: 100m);
Console.WriteLine($"Paid on Monday:    {paid}");

CampaignJob.StartBlackFriday();

decimal refund = Returns.RefundFor(listPrice: 100m);
Console.WriteLine($"Refund on Friday:  {refund}");

// A single global value that everyone can read and write
public static class StoreSettings
{
    public static decimal DiscountRate = 0.10m;
}

public static class Checkout
{
    public static decimal Pay(decimal listPrice) => listPrice * (1 - StoreSettings.DiscountRate);
}

public static class CampaignJob
{
    public static void StartBlackFriday() => StoreSettings.DiscountRate = 0.40m;
}

public static class Returns
{
    // The refund uses today's discount, not the amount that was paid
    public static decimal RefundFor(decimal listPrice) => listPrice * (1 - StoreSettings.DiscountRate);
}
Paid on Monday:    90.00
Refund on Friday:  60.00

The customer paid 90 on Monday and got 60 back as a refund on Friday. Checkout, CampaignJob and Returns never call each other, but all three depend on the same static field and when one changes it, the other two behave differently. Links like this are invisible in code review, because the file containing the change doesn't mention the other two classes. The "Architectural principles" page on Microsoft Learn also describes mutable global state as the antithesis of encapsulation. External coupling is the outside-world version: two modules depending on the same file format, the same protocol or the same database table.

Control, stamp and data coupling are far more frequent in everyday code:

Customer ada = new(1, "Ada", "ada@example.com", "+905550000001");

// Control coupling: the caller picks the flow inside the callee
OrderMailer.Send(ada, orderId: 1001, isReminder: true);

// Stamp coupling: a whole Customer is passed, only Phone is used
SmsSender.SendTo(ada, "Your order has shipped.");

// Data coupling: only the data that is needed is passed
SmsSender.Send(ada.Phone, "Your order has shipped.");

public sealed record Customer(int Id, string Name, string Email, string Phone);

public static class OrderMailer
{
    public static void Send(Customer customer, int orderId, bool isReminder)
    {
        string subject = isReminder ? $"Reminder: order {orderId} is waiting" : $"Order {orderId} confirmed";
        Console.WriteLine($"[mail] {customer.Email}: {subject}");
    }
}

public static class SmsSender
{
    public static void SendTo(Customer customer, string text) => Send(customer.Phone, text);

    public static void Send(string phone, string text) => Console.WriteLine($"[sms] {phone}: {text}");
}
[mail] ada@example.com: Reminder: order 1001 is waiting
[sms] +905550000001: Your order has shipped.
[sms] +905550000001: Your order has shipped.

The isReminder parameter is the classic sign of control coupling: the caller knows which branch will run inside the called method and picks it. Tomorrow, when a third email type arrives, a bool won't be enough; it will turn into an enum and affect every caller. Often the fix is writing two separate methods. SendTo(Customer, ...) is stamp coupling: a whole Customer is handed to a method that only needs a phone number. That ties the method to the shape of Customer and forces you to build an unnecessary object in tests. The weakest and healthiest kind is data coupling, where only the data that is needed is passed as parameters. One caveat: stamp coupling is not always bad. Passing a meaningful record is often better than passing ten parameters one by one; the question is whether the receiver really needs the whole structure or just one piece of it.

Levels of Cohesion

Cohesion also has an ordering that comes from the same paper. It looks at what holds the methods of a class together:

LevelWhat holds the parts togetherBookstore example
CoincidentalNothing; they happen to be in the same placeBookstoreUtils: slug generation, VAT (value-added tax) calculation, Slack alert
LogicalThey fall into the same category; the caller picks with a flagExport(kind): CSV (Comma-Separated Values), PDF (Portable Document Format), Excel in one method
TemporalThey run at the same timeStartup.Init(): warm the cache, create the log folder, fetch exchange rates
ProceduralThey run in a particular orderValidate the cart, then check the coupon, then write a log
CommunicationalThey work on the same dataMethods computing an order's summary, total and shipping weight
SequentialThe output of one is the input of the nextParse a CSV line, validate it, turn it into a Book
FunctionalThey all serve one well-defined taskPostalCode: format check, comparison, printing

A good example of functional cohesion in .NET itself is System.IO.Path: Combine, GetExtension, GetFileName and GetTempPath all work on the same concept, a file path. The opposite example is the Helpers folder found in many code bases. The real cost of coincidental cohesion is this: every project that wants to use BookstoreUtils.Slugify also ends up depending on the Slack client and the VAT rules. If a part has low cohesion, the coupling of everyone who connects to it rises unintentionally; that is why the two axes are independent measures that still affect each other.

The kinds of coupling give a rough answer to "how bad is it?". In his 1992 paper in Communications of the ACM (the journal of the Association for Computing Machinery), Meilir Page-Jones proposed a finer vocabulary: two parts are connascent if changing one requires changing the other for the system to keep working correctly. The kinds of connascence roughly run from weak to strong as follows. Static kinds, visible by looking at the source code: name, type, meaning (convention), position, algorithm. Dynamic kinds, which only appear at run time: execution (order), timing, value, identity.

The bug at the top of the post is connascence of position: the caller and the constructor must agree on the order of the parameters. A six-parameter constructor carries a hidden six-item agreement. The direction of the fix is to turn strong connascence into a weaker kind. Here we take two steps: we move the postal code into its own type, turning connascence of position into connascence of type, and we use named arguments for the remaining strings, bringing them down to connascence of name:

Customer ada = new(
    FirstName: "Ada",
    LastName: "Lovelace",
    Email: "ada@example.com",
    Address: new Address(new PostalCode("06100"), City: "Ankara", Country: "Turkey"));

Console.WriteLine(ShippingLabel.For(ada));
Console.WriteLine(ShippingLabel.IsDomestic(ada));

public readonly record struct PostalCode
{
    public PostalCode(string value)
    {
        if (value.Length != 5 || !value.All(char.IsAsciiDigit))
            throw new ArgumentException($"'{value}' is not a valid postal code.", nameof(value));

        Value = value;
    }

    public string Value { get; }

    public override string ToString() => Value;
}

public sealed record Address(PostalCode PostalCode, string City, string Country);

public sealed record Customer(string FirstName, string LastName, string Email, Address Address);

public static class ShippingLabel
{
    public static string For(Customer c) =>
        $"{c.FirstName} {c.LastName}, {c.Address.PostalCode} {c.Address.City}, {c.Address.Country}";

    // The validity check now lives inside PostalCode
    public static bool IsDomestic(Customer c) => c.Address.Country == "Turkey";
}
Ada Lovelace, 06100 Ankara, Turkey
True

Now if someone passes the arguments to Address in the wrong order again:

Address: new Address("Ankara", new PostalCode("06100"), "Turkey"));
Program.cs(5,26): error CS1503: Argument 1: cannot convert from 'string' to 'PostalCode'
Program.cs(5,36): error CS1503: Argument 2: cannot convert from 'PostalCode' to 'string'

The mistake is now caught at build time, not by the customer. At the same time the format rule for postal codes moved to a single place; IsDomestic no longer has to know about "5 digits". That is also a way of reducing connascence of meaning: instead of several places knowing a convention like "a 5-digit string is a postal code", there is one type that knows it.

.NET itself has plenty of connascence examples. One of the sneakiest is the single-parameter constructor of TimeSpan:

TimeSpan timeout = new(10);
Console.WriteLine(timeout);

Console.WriteLine(new TimeSpan(0, 10, 0));
Console.WriteLine(TimeSpan.FromSeconds(10));
Console.WriteLine(TimeSpan.FromMinutes(10));
00:00:00.0000010
00:10:00
00:00:10
00:10:00

new TimeSpan(10) means ten ticks (one microsecond), not ten seconds. The meaning of the parameter depends entirely on a convention; the reader has to remember the long ticks overload. new TimeSpan(0, 10, 0) is connascence of position, relying on the hours, minutes, seconds order. TimeSpan.FromSeconds and FromMinutes move the meaning into the method name; that is why preferring the factory methods for timeouts and delays is a good habit.

The other two measures Page-Jones proposed matter as much as the kind. Locality: how close are the two connascent parts? Connascence of algorithm between two lines in the same method is acceptable; the same hash algorithm living once in the API (Application Programming Interface) and once in the Angular app is not. Degree: how many parts take part in the link? Calling a constructor from two places is not the same risk as calling it from two hundred. The rule is simple: keep strong connascence at short distances, and bring links that cross boundaries down to weaker kinds.

The Law of Demeter and Tell, Don't Ask

The Law of Demeter came out of the Demeter project at Northeastern University; Karl Lieberherr and Ian Holland published it in 1989 in the IEEE (Institute of Electrical and Electronics Engineers) magazine IEEE Software under the title "Assuring Good Style for Object-Oriented Programs". Its essence: a method should only talk to the members of its own object, the objects passed to it as parameters, the objects it creates and its object's direct fields. It is summarized as "don't talk to strangers". Look at this line on the payment screen:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Customer ada = new("Ada", new Wallet(balance: 20m));
Order order = new(1001, ada, total: 25.98m);

// Code on the payment screen: changes a field three dots away
order.Customer.Wallet.Balance -= order.Total;

Console.WriteLine($"Balance: {ada.Wallet.Balance}");

public sealed class Wallet(decimal balance)
{
    public decimal Balance { get; set; } = balance;
}

public sealed class Customer(string name, Wallet wallet)
{
    public string Name { get; } = name;
    public Wallet Wallet { get; } = wallet;
}

public sealed class Order(int id, Customer customer, decimal total)
{
    public int Id { get; } = id;
    public Customer Customer { get; } = customer;
    public decimal Total { get; } = total;
}
Balance: -5.98

The balance went negative. Nobody said a wallet could go negative, but the rule was not written anywhere; even if it were, this line could bypass it, because Balance has a public setter and the caller reaches three objects away and changes it directly. This line ties the payment screen to the internal structure of Order, Customer and Wallet at the same time. If the wallet moves out of the customer into an account service tomorrow, every place that uses this chain will change.

The fix is to move the behavior to the owner of the data. This is the idea Andy Hunt and Dave Thomas (authors of The Pragmatic Programmer) popularized as Tell, Don't Ask: don't ask an object for its state and make the decision outside; tell it what you want and let it decide.

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Customer ada = new("Ada", new Wallet(balance: 20m));
Order order = new(1001, ada, total: 25.98m);

PaymentResult result = order.PayWithWallet();
Console.WriteLine($"{result} | balance: {ada.WalletBalance}");

string[] titles = ["Dune", "Emma", "The Hobbit", "Dracula"];
// The chain stays on the same abstraction: every step returns a new IEnumerable
string shortTitles = string.Join(", ", titles.Where(t => t.Length <= 5).OrderBy(t => t).Select(t => t.ToUpperInvariant()));
Console.WriteLine(shortTitles);

public enum PaymentResult { Paid, InsufficientFunds }

public sealed class Wallet(decimal balance)
{
    public decimal Balance { get; private set; } = balance;

    public bool TryDebit(decimal amount)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
        if (amount > Balance)
            return false;

        Balance -= amount;
        return true;
    }
}

public sealed class Customer(string name, Wallet wallet)
{
    private readonly Wallet _wallet = wallet;

    public string Name { get; } = name;
    public decimal WalletBalance => _wallet.Balance;

    public PaymentResult Pay(decimal amount) =>
        _wallet.TryDebit(amount) ? PaymentResult.Paid : PaymentResult.InsufficientFunds;
}

public sealed class Order(int id, Customer customer, decimal total)
{
    public int Id { get; } = id;
    public decimal Total { get; } = total;

    // We tell the order to pay; we don't know where the wallet lives
    public PaymentResult PayWithWallet() => customer.Pay(Total);
}
InsufficientFunds | balance: 20
DUNE, EMMA

The balance rule now lives inside Wallet and cannot be bypassed; the setter is private. The payment screen only knows Order. If the place where the wallet lives changes, only Customer changes.

The last lines of the same program also show why some chains are not violations. The titles.Where(...).OrderBy(...).Select(...) chain has several dots too, but none of them navigates the internal structure of an object; each step takes the same abstraction (IEnumerable<string>) and returns a new one. Fluent APIs such as a StringBuilder.Append chain and builder.Services.AddScoped(...) in ASP.NET Core likewise return the same object or a result of the same kind. The Law of Demeter is not about counting dots; it is about keeping knowledge of structure from leaking. By the same logic, chained property access on plain data structures with no behavior, such as a DTO (Data Transfer Object) or a record, is fine; there is no behavior to hide there.

Martin Fowler's (author of Refactoring) note on Tell, Don't Ask is worth keeping in mind too: Fowler says he doesn't use the rule himself, sees it as a stepping stone towards co-locating behavior and data, and warns against the excess of trying to remove every getter. A report reading the totals of orders is perfectly legitimate; the problem is making the decision outside with the value you read and changing the object's state from the outside.

Common Pitfalls

Trying to bring coupling to zero. You can't build a system out of parts that never talk to each other; the goal is not to eliminate links but to put them in the right place with the right strength. "Decoupling" everything with events or string-keyed messages often turns a static link (one the compiler sees) into a dynamic link (one that only appears at run time); that is not an improvement but a step back.

Thinking that adding an interface reduces coupling. An interface called IOrderService that copies every public method of OrderService does not reduce coupling; the client still depends on the same wide surface. We will discuss how to do this better in the Interface Segregation post.

Treating the Law of Demeter as a "one dot rule". Turning every order.Customer.Name into a wrapper method fills Order with dozens of GetCustomerName, GetCustomerEmail methods and lowers its cohesion. The question to ask is whether the chain moves a behavioral decision out of its owner.

Looking for connascence only inside the code. The most expensive kinds usually cross boundaries: a JSON (JavaScript Object Notation) field name shared between an API and its client (name), the assumption that two services must run in a certain order (execution), the same validation rule written separately in two applications (algorithm). These sit where the compiler can't catch them; contract tests and clients generated from a single source help here.

A Small Experiment

★☆☆ Remove the isReminder parameter from OrderMailer.Send and split it into two methods. How did the callers change? How many places does adding a third email type (shipped) touch now?

★★☆ Fix the refund bug in the common coupling example by removing the global state: have Checkout.Pay return the applied discount rate together with a payment record, and have Returns use that record. Which kind of connascence did this change turn into which?

★★★ Find the longest constructor in your own project. Which parameters share the same type and sit next to each other? Reduce connascence of position with value objects and named arguments; then confirm which mistake the compiler now catches by writing a real wrong ordering.

In the next post we move to the first letter of SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion). Using an Employee class, we will see why the common "a class does one thing" reading of the Single Responsibility Principle is wrong, what the actor definition of Robert C. Martin (author of Clean Code and Clean Architecture) says, and what happens when two different teams share the same method.