Back to Software Engineering & Delivery Mastery Series

Part 7: Modularity, Coupling & Cohesion

May 13, 2026 Wasil Zafar 36 min read

Modularity is not a luxury — it is the difference between software that evolves gracefully and software that collapses under its own weight. This article dissects the three forces that govern module design: modularity itself, coupling between modules, and cohesion within them.

Table of Contents

  1. Introduction
  2. What Is Modularity?
  3. Coupling
  4. Cohesion
  5. The Coupling-Cohesion Tradeoff
  6. Information Hiding & Encapsulation
  7. Measuring Modularity
  8. Real-World Impact
  9. Common Anti-Patterns
  10. Exercises
  11. Conclusion & Next Steps

Introduction

Every software system that survives long enough faces the same crisis: changes that should be simple become impossibly hard. A one-line fix requires modifying twelve files. Adding a feature to one service breaks three others. Deploying a change requires coordinating five teams. The system has become a Big Ball of Mud — and the root cause is almost always poor modularity.

Modularity — the decomposition of a system into independent, well-bounded units — is not just a nice-to-have design principle. It is the foundation upon which all maintainable, testable, and evolvable software is built. Without modularity, you cannot have independent deployments, parallel teams, or safe refactoring.

Two forces govern how well modules work: coupling (how much modules depend on each other) and cohesion (how focused a module's internal responsibilities are). Together with modularity, these three concepts form the holy trinity of software design.

Key Insight: The goal of modular design is simple — change one thing without breaking everything else. Every principle in this article serves that single objective. If your modules allow isolated change, you have good modularity. If every change ripples across the system, you do not.

David Parnas and the 1972 Paper

In 1972, David Parnas published "On the Criteria To Be Used in Decomposing Systems into Modules" — arguably the most influential paper in software engineering. Parnas demonstrated that the criteria you use to divide a system into modules matters more than the division itself.

He compared two decompositions of the same system:

  • Decomposition 1 (flowchart-based): Modules correspond to processing steps — input, process, output. Each step is a module.
  • Decomposition 2 (information-hiding): Modules hide design decisions likely to change — file format, sorting algorithm, output device.

Both decompositions produced the same program. But Decomposition 2 was dramatically easier to modify because each likely change was contained within a single module. This insight — that modules should hide volatile decisions — is the foundation of everything we discuss in this article.

Parnas's Criterion: "We propose that one begins with a list of difficult design decisions or design decisions which are likely to change. Each module is then designed to hide such a decision from the others." This is information hiding — the most important principle in module design.

What Is Modularity?

Modularity is the degree to which a system is composed of discrete, independent units (modules) that can be understood, developed, tested, and modified in isolation. A highly modular system is like a set of LEGO bricks — each piece has a clear shape and interface, and you can rearrange, replace, or remove pieces without destroying the whole structure.

The goals of modularity:

  • Comprehensibility: A developer can understand one module without understanding the entire system
  • Independent development: Different teams can work on different modules simultaneously without conflicts
  • Independent testing: Each module can be tested in isolation with mocked dependencies
  • Independent deployment: A change to one module does not require redeploying others
  • Replaceability: A module can be swapped for an alternative implementation without affecting consumers

Types of Modules

The concept of "module" manifests differently at different scales:

Scale Module Unit Interface Mechanism Example
Code Class, function, file Method signatures, imports UserRepository class
Package Library, package, namespace Exported API surface com.example.auth package
Component Deployable unit, JAR, DLL Binary interface contracts auth-service.jar
Service Independently running process REST API, gRPC, events User Authentication Service
System Bounded context, platform Published integration events Identity Platform

The principles of modularity apply at every scale. Whether you are designing classes within a file or services within a platform, the same forces of coupling and cohesion determine the quality of your decomposition.

Coupling

Coupling measures the degree of interdependence between modules. High coupling means modules are tightly connected — a change in one requires changes in others. Low coupling means modules are independent — they can evolve separately.

Why coupling matters for delivery:

  • High coupling → changes propagate across module boundaries → larger blast radius
  • High coupling → modules cannot be deployed independently → coordinated releases required
  • High coupling → modules cannot be tested in isolation → slow, fragile test suites
  • High coupling → teams block each other → reduced development velocity

Types of Coupling (Worst to Best)

Larry Constantine and Edward Yourdon identified a spectrum of coupling types, ordered from most harmful to least harmful:

Coupling Spectrum — Worst to Best
flowchart LR
    A[Content
Coupling] --> B[Common
Coupling] B --> C[Control
Coupling] C --> D[Stamp
Coupling] D --> E[Data
Coupling] E --> F[Message
Coupling] style A fill:#BF092F,color:#fff style B fill:#BF092F,color:#fff style C fill:#16476A,color:#fff style D fill:#16476A,color:#fff style E fill:#3B9797,color:#fff style F fill:#3B9797,color:#fff

Content Coupling (Worst)

Module A directly accesses the internal data or implementation of Module B. If B changes anything internally, A breaks.

// TERRIBLE: Module A reaches into Module B's internals
class OrderService {
    calculateTotal(order) {
        // Directly accessing UserService's internal cache structure!
        const discount = userService._internalCache.users[order.userId].discountTier;
        return order.subtotal * (1 - discount);
    }
}

Common Coupling

Multiple modules share the same global data (global variables, shared database tables without contracts). Any module can modify the shared state, making behaviour unpredictable.

// BAD: Both modules read/write the same global state
let globalConfig = { taxRate: 0.2, currency: 'GBP' };

class OrderService {
    calculateTax(amount) {
        return amount * globalConfig.taxRate; // reads global
    }
}

class AdminService {
    updateTaxRate(newRate) {
        globalConfig.taxRate = newRate; // mutates global — affects OrderService!
    }
}

Control Coupling

Module A passes a control flag that tells Module B how to behave. This means A knows about B's internal logic branching.

// MEDIOCRE: Caller controls internal behaviour via flag
function formatReport(data, outputType) {
    if (outputType === 'pdf') {
        return generatePDF(data);
    } else if (outputType === 'csv') {
        return generateCSV(data);
    } else if (outputType === 'html') {
        return generateHTML(data);
    }
}

// Better: Use polymorphism — separate formatters
const formatters = { pdf: new PDFFormatter(), csv: new CSVFormatter() };
formatters[outputType].format(data);

Stamp Coupling

Module A passes a data structure to Module B, but B only uses part of it. B is coupled to the shape of the entire structure even though it only needs a subset.

// ACCEPTABLE BUT IMPROVABLE: Passing entire user object when only email needed
function sendWelcomeEmail(user) {
    // Only uses user.email and user.firstName — but coupled to entire User shape
    mailer.send(user.email, `Welcome, ${user.firstName}!`);
}

// Better: Pass only what's needed (data coupling)
function sendWelcomeEmail(email, firstName) {
    mailer.send(email, `Welcome, ${firstName}!`);
}

Data Coupling (Good)

Modules communicate by passing only the specific data needed — primitive values or simple data structures. Each module receives exactly what it needs, nothing more.

// GOOD: Only necessary data passed
function calculateShippingCost(weight, distance, isExpress) {
    const baseRate = weight * 0.5;
    const distanceRate = distance * 0.1;
    return isExpress ? (baseRate + distanceRate) * 1.5 : baseRate + distanceRate;
}

Message Coupling (Best)

Modules communicate via messages (events, commands) through an intermediary. Neither module knows the other exists. Maximum decoupling.

// EXCELLENT: Event-based communication — modules are completely independent
// OrderService publishes an event — doesn't know who consumes it
eventBus.publish('OrderPlaced', { orderId: '123', userId: 'abc', total: 59.99 });

// EmailService subscribes — doesn't know who published it
eventBus.subscribe('OrderPlaced', (event) => {
    sendConfirmationEmail(event.userId, event.orderId);
});

Coupling and Software Delivery

The coupling level directly determines your delivery capabilities:

Coupling Level Deployment Testing Team Structure
Content/Common Must deploy everything together Full integration tests required Teams constantly blocking each other
Control/Stamp Mostly together, some independence Mocking possible but brittle Some coordination needed
Data/Message Independent deployment per module Full isolation testing possible Autonomous teams, parallel work

Cohesion

Cohesion measures how strongly the elements within a module belong together. High cohesion means every function, class, and data structure in the module serves a single, focused purpose. Low cohesion means the module is a grab-bag of unrelated responsibilities.

The relationship: High cohesion within modules naturally leads to low coupling between them. If each module has one clear responsibility, it has fewer reasons to depend on other modules.

Types of Cohesion (Worst to Best)

Type Definition Example Quality
Coincidental Elements grouped arbitrarily — no logical relationship utils.js with formatDate, validateEmail, parseCSV, resizeImage Worst
Logical Elements perform similar operations but on different data/contexts InputHandler that handles mouse, keyboard, gamepad, touch Poor
Temporal Elements executed at the same time (startup, shutdown, cleanup) AppInitializer that loads config, connects DB, starts cache, opens logs Weak
Procedural Elements follow a sequence of steps in a process CheckoutProcess: validate cart → calculate total → charge card → send email Moderate
Communicational Elements operate on the same data or input CustomerReport: generate summary, calculate lifetime value, find churn risk — all from customer data Good
Sequential Output of one element is input to the next DataPipeline: read raw → validate → transform → enrich (each step feeds the next) Strong
Functional Every element contributes to a single well-defined task PasswordHasher: hash(), verify(), generateSalt() — all focused on password hashing Best
Analysis

The "Utils" Anti-Pattern — Coincidental Cohesion in the Wild

Every codebase has one: utils.js, helpers.py, Common.cs. These files grow endlessly because they have no clear boundary — anything that "doesn't fit elsewhere" gets dumped in. The result is a module with coincidental cohesion — its contents share no logical relationship. The fix is disciplined extraction: formatDate belongs in a DateFormatter module, validateEmail belongs in an EmailValidator module. Each extracted module achieves functional cohesion. The "utils" file should be an alarm bell — it means your module boundaries are wrong.

Anti-Pattern Cohesion Refactoring

The Coupling-Cohesion Tradeoff

The fundamental design tradeoff is: high cohesion + low coupling = maintainable system. These two forces are inversely related at the system level — improving one tends to improve the other, and degrading one tends to degrade the other.

Good vs Poor Module Boundaries
flowchart TD
    subgraph Good["✓ Good Boundaries (High Cohesion, Low Coupling)"]
        direction LR
        A1[User Module
register, login,
resetPassword] ---|"API call"| B1[Order Module
create, cancel,
calculateTotal] B1 ---|"event"| C1[Notification Module
email, sms,
push] end subgraph Bad["✗ Poor Boundaries (Low Cohesion, High Coupling)"] direction LR A2[Module A
register, createOrder,
sendEmail] ---|"shared DB"| B2[Module B
login, cancelOrder,
sendSMS] B2 ---|"global var"| C2[Module C
resetPassword,
calculateTotal, push] end

The virtuous cycle:

  1. High cohesion → each module has one reason to change
  2. One reason to change → fewer connections to other modules needed
  3. Fewer connections → low coupling
  4. Low coupling → changes are isolated
  5. Isolated changes → faster, safer delivery

The vicious cycle:

  1. Low cohesion → module has many reasons to change
  2. Many reasons to change → more connections to other modules
  3. More connections → high coupling
  4. High coupling → changes cascade across the system
  5. Cascading changes → slow, risky delivery
Warning — The Distributed Monolith: It is possible to have microservices with high coupling. If your services share a database, depend on each other's internal data structures, or require coordinated deployments, you have a distributed monolith — all the complexity of microservices with none of the benefits. This is worse than a regular monolith because debugging network calls is harder than debugging function calls.

Information Hiding & Encapsulation

Information hiding is the mechanism by which modularity is achieved. It states: each module should hide a design decision behind a stable interface. The decision might be:

  • Which algorithm is used (sorting, compression, encryption)
  • Which data format is used internally (JSON, binary, protobuf)
  • Which external service is called (AWS S3, Azure Blob, local filesystem)
  • How data is stored (PostgreSQL, MongoDB, in-memory cache)
  • Which third-party library implements a capability

Encapsulation is the language-level technique for enforcing information hiding — access modifiers (private, protected, internal), module exports, package visibility.

Public Interfaces vs Private Implementation

// The public interface (what consumers depend on) — STABLE
class PaymentGateway {
    /**
     * Charge a customer. Returns a receipt or throws PaymentError.
     * @param {string} customerId
     * @param {number} amountCents
     * @param {string} currency
     * @returns {Promise<Receipt>}
     */
    async charge(customerId, amountCents, currency) {
        // Private implementation — can change without affecting consumers
        const provider = this._selectProvider(currency);
        const token = await this._tokenize(customerId);
        return provider.processPayment(token, amountCents, currency);
    }

    // PRIVATE: Implementation detail — hidden from consumers
    _selectProvider(currency) {
        if (currency === 'GBP') return this.stripeProvider;
        if (currency === 'INR') return this.razorpayProvider;
        return this.stripeProvider; // default
    }

    // PRIVATE: Can switch from tokenization to direct charge without breaking API
    _tokenize(customerId) {
        return this.vault.getToken(customerId);
    }
}

Consumers of PaymentGateway depend only on the charge() method signature. The internal provider selection logic, tokenization strategy, and vault implementation can all change freely. This is information hiding in action.

Connection to microservices: Each microservice is essentially a module with information hiding enforced at the network boundary. The API contract (REST endpoints, gRPC protobuf definitions) is the public interface. Everything behind that API is a private implementation detail — database choice, language, framework, deployment strategy.

Measuring Modularity

While modularity is partly subjective, Robert C. Martin proposed quantitative metrics that provide signals about module health:

Afferent and Efferent Coupling

  • Afferent Coupling (Ca): Number of modules that depend on this module (incoming dependencies). High Ca = many consumers = module is important but hard to change.
  • Efferent Coupling (Ce): Number of modules that this module depends on (outgoing dependencies). High Ce = module depends on many things = fragile, breaks when others change.

Instability Metric

Instability (I) = Ce / (Ca + Ce)

  • I = 0: Maximally stable. Many modules depend on it, it depends on nothing. Hard to change but reliable. Example: String class in a standard library.
  • I = 1: Maximally unstable. Depends on many modules but no modules depend on it. Easy to change. Example: a top-level application controller.

The Stable Dependencies Principle: Depend in the direction of stability. Unstable modules should depend on stable modules, never the reverse. If a stable module (I≈0) depends on an unstable module (I≈1), changes to the unstable module will cascade into something that many other modules depend on.

Abstractness and Distance from Main Sequence

Abstractness (A) = ratio of abstract classes/interfaces to total classes in a module.

  • A = 0: Concrete module — all implementations, no abstractions
  • A = 1: Abstract module — all interfaces, no implementations

The Main Sequence is the ideal line where A + I = 1. Modules should lie near this line:

  • Zone of Pain (A≈0, I≈0): Concrete and stable — hard to change but many depend on it. Rigid.
  • Zone of Uselessness (A≈1, I≈1): Abstract and unstable — nobody uses these abstractions. Dead code.
Practical Application: Tools like jdepend (Java), NDepend (.NET), and pydeps (Python) can calculate these metrics automatically. Use them in CI pipelines to detect coupling drift — when modules gradually become more coupled over time without anyone noticing.

Real-World Impact

Modularity is not an abstract academic concept — it directly determines practical outcomes:

Testing (Mock Boundaries)

Well-defined module boundaries create natural seams for testing. You mock at the module interface. If Module A calls Module B through a clean interface, you can replace B with a test double. If modules are tightly coupled (content coupling), mocking becomes impossible or extremely brittle.

Deployment (Deploy One Service)

Each well-bounded module can potentially become an independent deployment unit. In a modular monolith, modules are deployed together but could be split. In microservices, each service is deployed independently. The modularity quality determines whether splitting is feasible.

Team Structure (Conway's Law)

Melvin Conway observed in 1967: "Organisations which design systems are constrained to produce designs which are copies of the communication structures of these organisations." This works in reverse too — if you want independent teams, you need independent modules. Team boundaries should align with module boundaries.

Case Study

Spotify's Squad Model and Module Boundaries

Spotify famously organised engineering into autonomous Squads — small cross-functional teams (6-12 people) each owning a specific feature area. This only worked because their architecture was modular enough that each squad could own a service or set of components without constant coordination with other squads. When they attempted this before their "Big Rewrite" (moving from a monolith to services), squads constantly stepped on each other because module boundaries didn't align with team boundaries. The lesson: you cannot have autonomous teams without autonomous modules. Conway's Law is a two-way street.

Conway's Law Team Topology Autonomy

Common Anti-Patterns

God Class

A single class that knows too much and does too much. It has dozens of methods, hundreds of fields, and is involved in every operation. It has zero cohesion and maximum coupling because everything depends on it.

// ANTI-PATTERN: God Class
class ApplicationManager {
    constructor() {
        this.users = [];
        this.orders = [];
        this.products = [];
        this.emailQueue = [];
        this.auditLog = [];
        this.cache = {};
        this.config = {};
    }

    createUser(data) { /* ... */ }
    deleteUser(id) { /* ... */ }
    createOrder(userId, items) { /* ... */ }
    calculateShipping(order) { /* ... */ }
    sendEmail(to, subject, body) { /* ... */ }
    updateInventory(productId, qty) { /* ... */ }
    generateReport(type) { /* ... */ }
    clearCache() { /* ... */ }
    // ... 50 more methods
}

Circular Dependencies

Module A depends on Module B, and Module B depends on Module A. This creates a cycle that makes both modules impossible to understand, test, or deploy independently.

// ANTI-PATTERN: Circular dependency
// user-service.js
import { OrderService } from './order-service';
class UserService {
    getUser(id) { /* ... */ }
    getUserOrders(id) { return OrderService.getOrdersForUser(id); }
}

// order-service.js
import { UserService } from './user-service'; // CIRCULAR!
class OrderService {
    getOrdersForUser(userId) { /* ... */ }
    getOrderWithUser(orderId) {
        const order = this.getOrder(orderId);
        order.user = UserService.getUser(order.userId); // depends on UserService
        return order;
    }
}

Fix: Extract the shared concept into a third module, or use dependency inversion (depend on an interface, not a concrete implementation).

Leaky Abstractions

Joel Spolsky's Law of Leaky Abstractions: "All non-trivial abstractions, to some degree, are leaky." An abstraction leaks when implementation details bleed through the interface, forcing consumers to know about internals they should not need to understand.

// LEAKY: The "abstract" storage interface leaks S3 implementation details
class StorageService {
    async uploadFile(file, options) {
        // Consumer must know about S3-specific multipart threshold!
        if (file.size > options.s3MultipartThreshold) {
            return this.s3.createMultipartUpload(file, options.s3PartSize);
        }
        return this.s3.putObject(file);
    }
}

// NON-LEAKY: Implementation details hidden
class StorageService {
    async uploadFile(file) {
        // Internally decides strategy — consumer doesn't care how
        return this.provider.upload(file);
    }
}

Spaghetti Code

Code with no discernible module structure — functions call other functions across files arbitrarily, global state is modified everywhere, and the only way to understand the system is to trace execution paths manually through dozens of files. This is the absence of modularity.

Exercises

Exercise 1 — Coupling Identification: Take a utils.js or helpers.py file from any project (yours or open-source). List every function in it. For each function, identify (a) what module it logically belongs to, and (b) what type of cohesion the original file has. Propose a refactoring that achieves functional cohesion for each extracted module.
Exercise 2 — Coupling Type Classification: For each of the following interactions, identify the coupling type and suggest how to reduce it to data or message coupling: (a) Service A reads Service B's database directly. (b) A function accepts a boolean flag that switches between two completely different behaviours. (c) Two modules share a global configuration object. (d) Module A imports Module B's internal helper function (not part of its public API).
Exercise 3 — Module Boundary Design: You are building an e-commerce platform with these capabilities: user registration, product catalog, shopping cart, order processing, payment, shipping, email notifications, and analytics. Draw module boundaries that achieve (a) high cohesion within each module, (b) low coupling between modules, and (c) alignment with potential team boundaries. Identify which coupling type exists between each pair of communicating modules.
Exercise 4 — Metrics Calculation: Given this dependency graph: Module A depends on B and C. Module B depends on C and D. Module C depends on nothing. Module D depends on C. Calculate (a) Ca and Ce for each module, (b) Instability (I) for each module, (c) whether the Stable Dependencies Principle is satisfied. If violated, propose a refactoring to fix it.

Conclusion & Next Steps

Modularity, coupling, and cohesion are not abstract theory — they are the practical forces that determine whether your software is maintainable. Every design decision you make either improves or degrades these properties. The goal is always the same: high cohesion within modules (each module does one thing well) and low coupling between modules (modules can change independently).

The principles are universal — they apply whether you are designing classes in a single file, packages in a monolith, or services in a distributed system. Parnas's information hiding principle from 1972 is as relevant today as it was fifty years ago because the fundamental challenge has not changed: managing the complexity of change in large systems.

In the next article, we move from principles to practice — examining the build-vs-buy decision, implementation strategies, and how to translate architectural choices into working code that teams can actually deliver.

Next in the Series

In Part 8: Implementation — Buy vs Build & Making It Real, we explore how to translate architectural decisions into working code — the buy-vs-build decision framework, technical debt management, and implementation strategies that keep delivery velocity high.