Coupling, Cohesion, and Boundaries

June 14, 2026

In this blog we will learn about some archiectural principles.

Coupling, Cohesion, and Boundaries

If SOLID is the grammar, coupling and cohesion are the spelling rules. They're the most concrete measures we have of "is this code well-organized."

Cohesion

Cohesion measures how related the things inside a unit are. A class with high cohesion does one well-defined thing; its methods and properties are all about that thing. A class with low cohesion is a junk drawer — methods that don't relate to each other, properties that some methods care about and others don't.

A UserProfileFormatter with methods formatDisplayName(), formatJoinDate(), formatBio() is highly cohesive. A Utilities class with formatDate(), validateEmail(), compressImage(), and roundToNearestPowerOfTwo() is the opposite — those methods have nothing to do with each other.

High cohesion is good. The things that change together live together. When the date format changes, you go to the formatter. When the email validation rule changes, it lives in EmailValidator, not buried in Utilities.

Coupling

Coupling measures how much one unit knows about another. Two tightly coupled classes can't be understood, tested, or changed independently — they're effectively one unit. Two loosely coupled classes interact through a small, stable interface.

Some kinds of coupling, in rough order from worst to best:

Content coupling. One module reads or writes another's internal data. The worst kind — every change to internals risks breaking the dependent module.

Common coupling. Two modules both share a global mutable state. Changes to the state can affect either, and you can't reason about either in isolation.

External coupling. Both modules depend on a specific external interface (e.g., a particular database schema). Changing the external thing affects both.

Control coupling. One module passes a flag to control the other's behavior — the caller knows about the callee's internal logic.

Stamp coupling. Modules pass complex data structures between them, where the receiver only uses some of the fields. Now they're coupled to the whole shape.

Data coupling. Modules pass simple values back and forth. The interface is narrow and explicit.

No coupling. Modules don't interact at all (rare and ideal where it applies).

Most architectural advice is, at heart, "reduce coupling between layers, increase cohesion within them."

A worked iOS example

Suppose your view controller does:

class FeedViewController: UIViewController {
    var posts: [Post] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        // network call directly inline
        let url = URL(string: "https://api.example.com/feed")!
        URLSession.shared.dataTask(with: url) { data, _, _ in
            guard let data = data else { return }
            self.posts = (try? JSONDecoder().decode([Post].self, from: data)) ?? []
            DispatchQueue.main.async {
                self.tableView.reloadData()
            }
        }.resume()
    }
}

What's coupled here?

  • FeedViewController is coupled to URLSession (specific network implementation).
  • It's coupled to JSON-as-the-encoding (specific decoding approach).
  • It's coupled to the API URL (specific endpoint detail).
  • It's coupled to [Post] as a model (which it now also has to maintain).
  • The network result handling (success, error, threading) is in the same place as the UI layout.

When the API changes URL, you edit a view controller. When you switch to a different decoding library, you edit a view controller. When you want to mock the network for tests, you have to mock URLSession. None of these should be the view controller's job.

The fix:

protocol FeedFetcher {
    func fetchFeed() async throws -> [Post]
}

@MainActor
final class FeedViewController: UIViewController {
    private let fetcher: FeedFetcher
    private var posts: [Post] = []

    init(fetcher: FeedFetcher) {
        self.fetcher = fetcher
        super.init(nibName: nil, bundle: nil)
    }
    required init?(coder: NSCoder) { fatalError() }

    override func viewDidLoad() {
        super.viewDidLoad()
        Task {
            do {
                self.posts = try await fetcher.fetchFeed()
                self.tableView.reloadData()
            } catch {
                self.showError(error)
            }
        }
    }
}

Now FeedViewController is coupled to the abstract concept of fetching a feed, not any particular implementation. The concrete FeedFetcher (using URLSession, GraphQL, or a stub) lives elsewhere. The view controller's only concern is what to do once the data arrives. Same principle: SRP applied through DIP, reducing coupling.

Boundaries

A boundary is a place in your code where coupling is intentionally minimized — typically by an interface (in Swift, a protocol) and a discipline that callers on one side don't reach across.

Boundaries are where architecture lives. The boundary between presentation and domain. Between domain and data. Between feature modules. A good boundary lets you change one side without changing the other; a bad boundary leaks details, and changes ripple.

Markers that you have a boundary:

  • An explicit protocol that one side implements and the other consumes.
  • Different teams or features owning the two sides.
  • A test suite that runs only one side, with a stub on the other.
  • A module boundary (separate Swift package, separate target).

The first two are conceptual; the second two are physical. The strongest boundaries are physical — the code on one side literally can't import the other side's internals because the build system won't let it. We'll cover this when we get to modularization.

Cohesion vs. coupling: balancing act

These metrics interact. Maximizing cohesion within a unit pushes related things together; minimizing coupling pushes unrelated things apart. The right unit boundaries are where both are optimal — things inside relate to each other strongly, things across boundaries relate weakly.

This is why "split into many small files" or "consolidate into fewer files" are both bad as universal advice. The right answer depends on what's related to what. A 1,000-line view controller that does ten things should be split. A 200-line struct that genuinely is one thing should not be split into ten 20-line files.

Detecting bad coupling in iOS code

Some smells:

Shotgun surgery. Adding a feature requires editing many files. The change is "spread thin" because the relevant logic is.

Divergent change. One file changes for many different reasons. The file is doing too many things.

Feature envy. A method in class A accesses class B's data more than its own. The method might belong on B.

Message chains. a.b.c.d.doThing(). The caller knows too much about the structure beneath a.

Speculative generality. Abstractions and parameters that exist for "future flexibility" but are never used. Often these are accidental coupling — the abstraction creates expectations that constrain how the code can grow.

Inappropriate intimacy. Two classes know each other's internals. Often a sign that they should be merged or that the boundary is wrong.

Module-level coupling and cohesion

The same principles apply at the module level. A UserProfile module that needs to import Network, Database, Analytics, FeatureFlags, Logging, and three other features is highly coupled. A UserProfile module that imports only Networking (a low-level utility) and a SharedKit (common types) is loosely coupled and easier to understand.

Modules with high internal cohesion (everything in UserProfile is about the user profile) and low external coupling are the gold standard.

What to internalize

Cohesion: things related to each other live together. Coupling: minimize what each unit knows about other units. Boundaries: explicit interfaces between cohesive units, with as little leakage as possible. Every architecture is, at its core, a strategy for placing boundaries and structuring the relationships across them. The architectures we'll cover are different answers to "where should the boundaries be, and how strict should they be?"