When we build software, the difference between a system that evolves easily and one that becomes a burden rarely lies in the specific algorithm we write. It lies in how we organize the pieces and how they depend on one another. Coupling and cohesion are the two most important conceptual metrics for reasoning about that organization: they tell us how entangled our modules are and how focused each piece is on a single task. Mastering these concepts is the first step toward making conscious architectural decisions instead of accumulating accidental complexity. In this lesson we will study them in depth, see how they manifest in real Java code and how to refactor to improve them, ending with a fundamental practical rule: the Law of Demeter.
Contents
- Coupling: what it is and why it matters
- Types of coupling (from worst to best)
- Cohesion: high versus low
- The relationship between coupling and cohesion
- Separation of Concerns (SoC)
- Guided refactoring: from high coupling to low coupling
- The Law of Demeter (principle of least knowledge)
- Common mistakes and tips
- Exercises
- Conclusion
- Coupling: what it is and why it matters
Coupling measures the degree of interdependence between two modules: how much one module needs to know about the internal details of another in order to function. When two components are tightly coupled, a change in one forces a change in the other.
Key concepts:
- Low coupling (desirable): modules communicate through stable, minimal interfaces. An internal change in one module does not affect the others.
- High coupling (problematic): modules depend on the internal details, concrete types, or data structures of other modules.
- Coupling can never be zero: if modules did not communicate, they would not form a system. The goal is to minimize it and make it explicit.
Consequences of high coupling:
- Fragility: localized changes cause cascading failures in unexpected places.
- Rigidity: it is hard to modify the system because every change drags others along with it.
- Low reusability: you cannot extract a module without dragging its dependencies along.
- Difficulty testing: you cannot test a module in isolation.
- Types of coupling (from worst to best)
Historically (Stevens, Myers, and Constantine, 1974) coupling is classified along a scale. This table orders the types from the most harmful to the healthiest:
| Type | Description | Rating |
|---|---|---|
| Content | One module modifies or depends on another's internal data | Very bad |
| Common | Several modules share mutable global state | Bad |
| External | Dependence on an externally imposed format or protocol | Bad |
| Control | One module controls another's flow by passing it flags | Fair |
| Stamp | A whole structure is passed when only part of it is needed | Improvable |
| Data | Only the strictly necessary data is passed | Good |
| Message | Communication only through messages/interfaces with no internal parameters | Optimal |
Example of control coupling (one of the most frequent and avoidable):
// BAD: the caller controls the internal flow through a flag
public class ReportGenerator {
public String generate(Data data, boolean isPdf) {
if (isPdf) {
return generatePdf(data);
} else {
return generateHtml(data);
}
}
}Explanation of the problem: the boolean isPdf parameter is a control flag. The calling code has to know the method's internal logic to understand what each value does. Moreover, each new format (CSV, XML) forces you to add parameters or branches, breaking the existing method.
// GOOD: data/message coupling through polymorphism
public interface ReportGenerator {
String generate(Data data);
}
public class PdfGenerator implements ReportGenerator {
public String generate(Data data) { /* ... */ return "pdf"; }
}
public class HtmlGenerator implements ReportGenerator {
public String generate(Data data) { /* ... */ return "html"; }
}Explanation of the improvement: now the caller picks a concrete implementation and uses it through the ReportGenerator interface. It knows nothing about the internal details. Adding a new format means creating a new class, without touching the existing ones.
- Cohesion: high versus low
Cohesion measures how related and focused the elements within a single module are. A class with high cohesion does one thing well; one with low cohesion mixes disparate responsibilities.
Types of cohesion (from worst to best):
| Type | What it groups | Rating |
|---|---|---|
| Coincidental | Unrelated elements grouped by chance | Very bad |
| Logical | Tasks that are similar by category but different in purpose | Bad |
| Temporal | Things that run at the same moment (e.g., startup) | Fair |
| Procedural | Steps of a procedure, in a certain order | Improvable |
| Communicational | Operations on the same data | Good |
| Functional | Everything contributes to a single, well-defined task | Optimal |
Example of low cohesion:
// BAD: a class that does everything (a "God" class)
public class Utilities {
public void saveUser(User u) { /* database access */ }
public String formatDate(Date d) { /* formatting */ return ""; }
public void sendEmail(String recipient) { /* SMTP */ }
public double calculateTax(double base) { return base * 0.21; }
}Explanation of the problem: this class groups database access, formatting, email sending, and tax calculation. There is no relationship among these tasks: this is coincidental cohesion. Any developer who needs just one of these functions ends up coupled to the whole class.
// GOOD: each class has a single responsibility (functional cohesion)
public class UserRepository {
public void save(User u) { /* database access */ }
}
public class EmailService {
public void send(String recipient, String body) { /* SMTP */ }
}
public class TaxCalculator {
public double calculate(double base) { return base * 0.21; }
}
- The relationship between coupling and cohesion
These two metrics tend to move in opposite directions and constitute the guiding principle of good modular design:
Goal: high internal cohesion and low external coupling.
graph LR
subgraph "Poor design"
A1[Module A] <--> B1[Module B]
A1 <--> C1[Module C]
B1 <--> C1
end
subgraph "Good design"
A2[Module A] --> I[Interface]
B2[Module B] --> I
C2[Module C] --> I
endExplanation of the diagram: on the left, all modules know one another directly (a dense web = high coupling). On the right, modules depend only on a stable interface: dependencies are few and directed. If a module changes internally, as long as it respects the interface, the others never notice.
- Separation of Concerns (SoC)
Separation of Concerns is the principle that underpins both high cohesion and low coupling: each part of the system should deal with a single concern and nothing more.
Typical concerns worth separating:
- Presentation (user interface, response serialization).
- Business logic (domain rules).
- Persistence (data access).
- Cross-cutting concerns (logging, security, transactions).
A classic SoC pattern is the layered architecture:
// Presentation layer: it only orchestrates and translates HTTP <-> domain
@RestController
public class OrderController {
private final OrderService service;
public OrderController(OrderService service) { this.service = service; }
@PostMapping("/orders")
public OrderResponse create(@RequestBody OrderRequest request) {
Order order = service.createOrder(request.getItems());
return OrderResponse.from(order);
}
}
// Business layer: domain rules, knowing nothing about HTTP or SQL
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) { this.repository = repository; }
public Order createOrder(List<Item> items) {
Order order = new Order(items);
order.validate();
return repository.save(order);
}
}Explanation: the OrderController knows only the web protocol and delegates immediately. The OrderService contains the rules and does not know whether it is invoked by a REST controller, a message queue, or a test. Each layer can evolve and be tested independently.
- Guided refactoring: from high coupling to low coupling
Let's walk through a complete refactoring. We start from a class that creates its own dependencies internally:
// BEFORE: strong coupling to concrete implementations
public class NotificationService {
private final SmtpClient smtp = new SmtpClient("smtp.company.com");
public void notify(String user, String message) {
String email = new MySqlUserRepository().findEmail(user);
smtp.send(email, message);
}
}Problems detected:
- It creates
SmtpClientandMySqlUserRepositorywithnew: impossible to replace them with test doubles. - It depends on concrete classes, not on abstractions.
- It cannot be tested without a real SMTP server and a real database.
// AFTER: dependency injection against interfaces
public interface EmailGateway {
void send(String recipient, String body);
}
public interface UserRepository {
String findEmail(String user);
}
public class NotificationService {
private final EmailGateway email;
private final UserRepository users;
// Dependencies are received from outside (inversion of control)
public NotificationService(EmailGateway email, UserRepository users) {
this.email = email;
this.users = users;
}
public void notify(String user, String message) {
String recipient = users.findEmail(user);
email.send(recipient, message);
}
}Step-by-step explanation of the improvement:
- We define interfaces (
EmailGateway,UserRepository) that describe what is needed, not how it is implemented. - The service receives its collaborators through the constructor (dependency injection). It no longer uses
new. - In production we will inject the real implementations; in tests, lightweight doubles. This reduces coupling to its message form and enables isolated testing.
- The Law of Demeter (principle of least knowledge)
The Law of Demeter is a heuristic rule that limits coupling: "talk only to your close friends, not to strangers." A method of an object should only invoke methods of:
- the object itself (
this), - objects it receives as parameters,
- objects it creates itself,
- its direct attributes.
The most visible symptom of a violation is the chaining of calls (a.getB().getC().do()), also known as a "train wreck."
// VIOLATES the Law of Demeter: we navigate the internal structure of other objects
public class DiscountCalculator {
public double calculate(Order order) {
String country = order.getCustomer().getAddress().getCountry();
if (country.equals("ES")) return 0.10;
return 0.0;
}
}Explanation of the problem: DiscountCalculator knows the internal structure of Order, Customer, and Address. If any of those classes changes its structure, this method breaks. The coupling is transitive and hidden.
// COMPLIES with the Law of Demeter: each object exposes what is needed
public class Order {
private final Customer customer;
public boolean isDomestic() { return customer.isDomestic(); }
}
public class Customer {
private final Address address;
public boolean isDomestic() { return address.isInCountry("ES"); }
}
public class DiscountCalculator {
public double calculate(Order order) {
return order.isDomestic() ? 0.10 : 0.0;
}
}Explanation of the improvement: instead of requesting data and deciding outside ("ask"), we tell the object to answer the business question that concerns it ("tell"). Each class hides its internal structure. This is known as the "Tell, Don't Ask" principle and is the practical face of the Law of Demeter.
An important nuance: the Law of Demeter applies to objects with domain behavior. It should not be applied dogmatically to fluent APIs or builders (new Builder().with(x).with(y).build()), where chaining is intentional and each method returns the same type.
Common Mistakes and Tips
- Confusing few lines with high cohesion. A small class can still be incoherent if it mixes concerns. Cohesion is semantic, not a matter of size.
- Creating interfaces for everything "just in case." An unnecessary abstraction is accidental complexity. Introduce interfaces when there is a real variation or a testing boundary.
- Hiding coupling in global state or singletons. Common coupling (shared mutable state) is among the hardest to debug; prefer passing explicit dependencies.
- Applying the Law of Demeter to pure data structures (DTOs). Accessing the fields of a DTO with no logic does not violate it; the law protects objects with behavior.
- Tip: when faced with a change, observe how many files you have to touch. If it's many for a conceptually small change, you have a coupling or cohesion problem.
- Tip: use the "Tell, Don't Ask" rule as a quick detector of Demeter violations.
Exercises
Exercise 1. Identify the type of coupling and refactor:
public class Calculator {
public double operate(double a, double b, int type) {
if (type == 1) return a + b;
if (type == 2) return a - b;
if (type == 3) return a * b;
return 0;
}
}Exercise 2. The following class has low cohesion. Split it into classes with a single responsibility:
public class StoreManager {
public void processPayment(double amount) { /* ... */ }
public void updateInventory(String product, int quantity) { /* ... */ }
public void writeLog(String message) { /* ... */ }
}Exercise 3. Rewrite this method so that it complies with the Law of Demeter:
public double priceWithShipping(Invoice invoice) {
return invoice.getOrder().getTotal() + invoice.getOrder().getShipping().getCost();
}Solutions
Solution 1. It is control coupling (the int type drives the flow). We refactor to polymorphism:
public interface Operation {
double apply(double a, double b);
}
public class Addition implements Operation {
public double apply(double a, double b) { return a + b; }
}
public class Subtraction implements Operation {
public double apply(double a, double b) { return a - b; }
}
public class Multiplication implements Operation {
public double apply(double a, double b) { return a * b; }
}
// Usage: Operation op = new Addition(); double r = op.apply(2, 3);Now adding operations does not require modifying existing code, and the flags disappear.
Solution 2. We split into three classes with functional cohesion:
public class PaymentService {
public void processPayment(double amount) { /* ... */ }
}
public class InventoryService {
public void update(String product, int quantity) { /* ... */ }
}
public class LoggingService {
public void log(String message) { /* ... */ }
}Each service can evolve, be tested, and be reused independently.
Solution 3. We encapsulate the calculation inside the objects that own the data:
public class Order {
private double total;
private Shipping shipping;
public double totalPriceWithShipping() { return total + shipping.getCost(); }
}
public double priceWithShipping(Invoice invoice) {
return invoice.totalPriceWithShipping(); // Invoice delegates to its Order
}The caller no longer navigates the internal structure; it directly asks for the business value.
Conclusion
In this lesson we have seen that good design comes down, to a large extent, to a single idea: maximize cohesion and minimize coupling. We classified the types of coupling and cohesion, understood that separation of concerns is the principle that enables them, and practiced real refactorings using interfaces and dependency injection. Finally, the Law of Demeter gave us an operational rule for detecting hidden dependencies. These concepts are the foundation on which the SOLID principles are built, which we will study in the next lesson and which are nothing more than concrete, actionable formulations of these same ideas.
Application Architecture Course
Module 1: Fundamentals of Application Architecture
- What Is Application Architecture?
- The Role of the Software Architect
- Quality Attributes and Non-Functional Requirements
- Architectural Decisions and Trade-offs
- Architecture Documentation: Views and the C4 Model
Module 2: Design Principles and Tactics
- Coupling, Cohesion and Separation of Concerns
- SOLID Principles Applied to Architecture
- DRY, KISS, YAGNI and Other Design Principles
- Architectural Tactics for Quality Attributes
- Managing Technical Debt
Module 3: Architectural Styles and Patterns
- Monolithic Architecture
- Layered Architecture (N-Tier)
- Client-Server Architecture
- Hexagonal Architecture (Ports and Adapters)
- Clean and Onion Architecture
Module 4: Distributed Architectures and Microservices
- Introduction to Distributed Systems
- Microservices Architecture
- Service Decomposition and Bounded Contexts
- API Gateway, Service Discovery and Inter-Service Communication
- Resilience Patterns: Circuit Breaker, Retry and Bulkhead
- The CAP Theorem and Data Consistency
Module 5: Event-Driven Architectures and Messaging
- Fundamentals of Event-Driven Architecture
- Asynchronous Messaging: Queues and Brokers
- Event Patterns: Event Sourcing and CQRS
- Managing Distributed Transactions: The Saga Pattern
- Real-Time Data Streaming
Module 6: Domain-Driven Design (DDD)
- Core DDD Concepts
- Strategic Design: Bounded Contexts and Ubiquitous Language
- Tactical Design: Entities, Aggregates and Repositories
- Context Mapping
Module 7: Data and Persistence
- Persistence Strategies: SQL vs NoSQL
- Data Access Patterns: Repository, Unit of Work and DAO
- Database per Service and Distributed Data Management
- Caching and Invalidation Strategies
Module 8: Cloud Architecture and Deployment
- Cloud Computing Fundamentals (IaaS, PaaS, SaaS)
- Containers and Orchestration with Docker and Kubernetes
- Serverless Architecture
- Cloud-Native Design Patterns
- Infrastructure as Code (IaC)
Module 9: Quality, Security and Observability
- Scalability: Horizontal vs Vertical and Load Balancing
- High Availability and Fault Tolerance
- Security by Design and Authentication/Authorization
- Observability: Logging, Metrics and Tracing
- Performance and Load Testing
