Software Design Principles
Master the timeless principles — cohesion, coupling, SOLID, information hiding, and modularity — that transform a tangle of code into a system that stays comprehensible and changeable as it grows.
Overview
Software is unique among engineering disciplines: unlike a bridge or a circuit, a software system can grow indefinitely in complexity because the cost of adding one more line of code is nearly zero. This is both its superpower and its curse. A system that starts as 500 lines of clean code can, in five years, become a 500,000-line tangle where every change triggers three unexpected bugs. The discipline of software design exists precisely to fight this entropy.
The principles covered in this module — managing complexity, coupling, cohesion, SOLID, separation of concerns, modularity — are not arbitrary style rules. They are hard-won lessons distilled from decades of building real systems at scale. They are the software equivalents of structural engineering: the reasons bridges do not fall down. Understanding them deeply, at the level of why they work, will make you a better programmer than memorizing any API or framework ever will.
The central theme is managing complexity. Fred Brooks, in his 1986 essay "No Silver Bullet", argued that software complexity is the essential difficulty of the field — it is not accidental, not fixable by a better tool or language, but intrinsic to the domain. The principles in this module are all strategies for decomposing complexity: breaking a system into pieces whose complexity can be understood independently, and whose interactions are disciplined and predictable.
This module is grounded in real code. Every principle will be shown both violated and respected, so you can see the difference in practice. By the end, you will be equipped to look at an unfamiliar codebase and quickly identify why it is hard to change — and what to do about it.
In industry, the majority of software cost is maintenance, not initial development. Studies consistently show that reading and modifying existing code takes 60-80% of total engineering effort. A system with poor design does not just annoy programmers — it becomes a competitive liability. Features take longer, bugs are harder to fix, and the best engineers leave because the codebase is demoralizing to work in. Google, Netflix, and Amazon have all publicly attributed significant engineering productivity losses to accumulated design debt, and have invested heavily in rewrites and refactors as a result.
Beyond individual systems, these principles underlie every major architectural pattern you will encounter in your career: microservices (high cohesion, loose coupling between services), the UNIX philosophy (small tools that do one thing well), layered OS design (separation of concerns between hardware, kernel, and user space), REST APIs (uniform interface hiding implementation), and compiler design (lexer → parser → semantic analysis → codegen as decoupled stages). A programmer who understands why these patterns work can reason about novel systems from first principles, rather than cargo-culting patterns without understanding them.
SOLID Dependency Structure: Clean vs. Violated
In the clean design (top), OrderService depends only on interfaces. Adding a new database or notification provider requires no changes to OrderService — only a new adapter class (Open/Closed Principle). In the violated design (bottom), every implementation change forces OrderService to change, and tests must connect to a real database.
The concepts
01Managing Complexity: The Essential Challenge
The core claim of software design, articulated by David Parnas, Edsger Dijkstra, and Fred Brooks, is that complexity is the root cause of most software failure. It is not bugs, not slow hardware, not bad tools — it is the inability of a human mind to hold a large interacting system in working memory simultaneously.
Dijkstra's insight was precise: the human brain can track roughly 7 ± 2 independent concepts at once (Miller's law). A system with n components and k interactions between each pair has O(n²) interactions to reason about. As n grows, the cognitive burden grows quadratically. This is why a 10-person team working on 10 separate isolated modules outperforms a 10-person team working on one entangled module — the isolation keeps the local complexity constant.
There are two kinds of complexity, per Brooks:
- Essential complexity: inherent in the problem domain itself. An air-traffic control system is genuinely complex; that cannot be engineered away.
- Accidental complexity: complexity introduced by our tools, our design choices, our legacy decisions. This is what software design principles attack.
The fundamental tool for managing complexity is decomposition: dividing a system into smaller pieces, each of which can be understood independently. But decomposition only helps if the pieces have clean interfaces and limited interactions. A bad decomposition (splitting a system into 20 pieces that all depend on each other's internals) can make things worse than no decomposition at all, because you now have 20 pieces to understand plus their 190 pairwise interactions.
The principles in this module are all strategies for achieving good decomposition: high cohesion within pieces, low coupling between pieces, clear interfaces, hidden implementations.
# Example: two decompositions of the same system
# Bad: every piece knows about every other piece
class OrderProcessor:
def process(self, order):
# directly manipulates payment internals
payment.stripe_customer_id = order.user.stripe_id
payment.charge_amount = order.total * 100 # cents
payment._run_stripe_api_call()
# directly manipulates inventory internals
inventory._db_rows[order.item_id] -= order.qty
inventory._dirty = True # internal flag!
# Good: each piece exposes only what it promises
class OrderProcessor:
def process(self, order):
self.payment_service.charge(order.user, order.total)
self.inventory_service.reserve(order.item_id, order.qty)
02Coupling: The Cost of Entanglement
Coupling is the degree to which one module depends on the internals of another. Low coupling is one of the two fundamental goals of modular design (the other being high cohesion).
Coupling is not binary — it exists on a spectrum, from tightest to loosest:
- Content coupling (worst): Module A directly reads or writes Module B's internal data or code.
a.internal_list.append(x)from outside B. A change to B's internals always breaks A. - Common coupling: Both A and B read/write a shared global variable. Any module in the system can be the hidden cause of a bug in any other.
- Control coupling: A passes a flag to B that controls B's internal logic flow.
process(data, mode='fast')where B switches its behavior based on mode. A now needs to understand B's internal decision structure. - Stamp coupling: A passes a whole data structure to B but B only uses part of it. B is now implicitly dependent on the shape of that structure.
- Data coupling (best): A and B communicate only through parameters and return values, and those parameters are the minimal data needed. Changes to either module's internals don't affect the other.
The practical impact of tight coupling: if changing module B requires you to change module A, they are coupled. In a highly coupled system, a change anywhere can cascade to changes everywhere. This is sometimes called the Shotgun Surgery smell: one logical change requires edits in ten files.
A useful mental model: think of modules as black boxes connected by narrow pipes. The narrower the pipe (fewer, simpler interactions), the easier the system is to understand and change. Data coupling means only clean, typed data flows through the pipes; content coupling means you have torn down the walls between the boxes entirely.
# High coupling (control + stamp coupling)
def generate_report(data_bundle, report_type, verbose=False):
# Caller must know about internal flag 'verbose' and
# pass the whole bundle even though we only need one field
if report_type == 'csv':
rows = data_bundle['rows'] # depends on bundle shape
if verbose: print('generating csv...') # control coupling
return _csv(rows)
elif report_type == 'pdf':
...
# Low coupling (data coupling via narrow interface)
class CsvReporter:
def generate(self, rows: list[Row]) -> str:
return _csv(rows)
class PdfReporter:
def generate(self, rows: list[Row]) -> bytes:
return _pdf(rows)
03Cohesion: Keeping Related Things Together
Cohesion measures how strongly related the responsibilities within a single module are. A highly cohesive module does one thing, and does it completely. A low-cohesion module is a grab-bag of unrelated functionality that happened to end up in the same file.
Like coupling, cohesion is a spectrum (from weakest to strongest):
- Coincidental cohesion (worst): Elements are grouped only because they happened to be written at the same time. A
utils.pyfile with 40 unrelated helper functions. - Logical cohesion: Elements perform similar operations but on different data or types.
FileIO.read_csv(),FileIO.read_json(),FileIO.write_png()grouped because they are all I/O, even though they have nothing else in common. - Temporal cohesion: Elements are grouped because they happen at the same time in program execution.
startup_tasks()that opens the DB, seeds random numbers, and loads config — related only by when they run. - Procedural cohesion: Elements are grouped because they share a control flow.
validate_and_save_user()— validation and persistence bundled. - Communicational cohesion: Elements operate on the same data. Better — they share a data structure.
- Sequential cohesion: Output of one element is input to the next. Like a Unix pipeline:
parse → validate → transform → store. - Functional cohesion (best): Every element contributes to exactly one, well-defined task.
Authenticator.verify_password()does one thing.
The cohesion-coupling duality: A module with low cohesion almost always has high coupling, because its grab-bag of functionality is pulled in many directions by many callers. Improving cohesion often automatically reduces coupling.
A practical test: try to write a one-sentence description of what a module does. If you use the word "and" more than once, it probably lacks cohesion. The Unix philosophy makes this explicit: do one thing and do it well.
# Low cohesion: one class does too many unrelated things
class UserManager:
def save_to_db(self, user): ...
def send_welcome_email(self, user): ...
def resize_avatar(self, image_bytes): ...
def log_login_event(self, user, timestamp): ...
def generate_report_csv(self, users): ...
# High cohesion: each class has a single clear responsibility
class UserRepository:
def save(self, user): ...
def find_by_id(self, user_id): ...
class UserEmailService:
def send_welcome(self, user): ...
class AvatarProcessor:
def resize(self, image_bytes, size) -> bytes: ...
04Separation of Concerns
Separation of Concerns (SoC) is the principle, articulated by Dijkstra in 1974, that different aspects of a program should be handled by different, non-overlapping sections of the code. A "concern" is any distinct feature or behavior: parsing, validation, persistence, business logic, presentation, logging, authentication.
SoC is both more general and more concrete than cohesion. Cohesion tells you that a module should do one thing; SoC tells you which specific things should be separated. The classic example is the three-tier architecture:
- Presentation layer (HTML, React component): how data is displayed
- Business logic layer (domain model, services): what the system does
- Data access layer (SQL queries, ORM): how data is stored and retrieved
Mixing these concerns (writing SQL queries inside a JSX component, or rendering logic inside a data model) creates systems that are impossible to test in isolation, impossible to reuse, and hard to change.
A more subtle form of SoC is the separation of policy from mechanism. Mechanism is the how: how a lock is implemented, how memory is allocated, how events are dispatched. Policy is the what: what gets locked, what memory strategy to use, what to do when an event fires. In Linux, the kernel provides mechanisms (scheduling primitives, file I/O) but is deliberately agnostic about policy — that is the job of user space. This is why Linux is used in systems as different as Android phones and supercomputers.
In application code, this manifests as injecting policy through configuration or dependency injection rather than hard-coding it. A sorting module should implement the sort (mechanism) but accept a comparator (policy) as a parameter.
# Violation: business logic and SQL mixed with presentation
def render_homepage(request):
# SQL in the view — total violation of SoC
users = db.execute(
"SELECT * FROM users WHERE active=1 ORDER BY created_at DESC LIMIT 10"
)
# Business rule embedded in rendering code
featured = [u for u in users if u['score'] > 500 and not u['banned']]
return render_template('home.html', users=featured)
# Respected: each layer handles its own concern
class UserRepository: # data layer
def recent_active(self, n=10):
return db.execute("SELECT * FROM users WHERE active=1 "
"ORDER BY created_at DESC LIMIT ?", n)
class UserService: # business logic layer
def featured_users(self):
candidates = self.repo.recent_active()
return [u for u in candidates if u.score > 500 and not u.banned]
def render_homepage(request): # presentation layer
users = user_service.featured_users()
return render_template('home.html', users=users)
05Information Hiding and Encapsulation
Information hiding, introduced by David Parnas in his seminal 1972 paper "On the Criteria to Be Used in Decomposing Systems into Modules", is perhaps the single most important software design idea of the 20th century. The principle: each module should hide a design decision — a piece of knowledge that is likely to change — behind an interface. Callers interact only with the interface; they are shielded from the decision.
Parnas's canonical example was a KWIC (Key Word in Context) index system. He showed that if you decompose by computational steps (input → circular shift → alphabetize → output), then changing the data structure used for circular shifts requires changes everywhere. But if you decompose by design decisions (one module owns the knowledge of how circular shifts are stored), then that decision can change without affecting any other module.
Encapsulation in object-oriented languages is the language-level mechanism for enforcing information hiding: private fields mean external code cannot depend on them, because the compiler prevents it. But information hiding is a design principle, not a language feature. You can violate it in Python (which has no enforced private) by being disciplined, or honor it in C by carefully designing your .h header files.
The key insight: the thing you hide is the thing most likely to change. Algorithms get replaced. Data formats evolve. Third-party APIs change. If you hide these behind stable interfaces, you can change the implementation without touching the rest of the system.
This leads directly to the concept of stable interfaces over stable implementations. An interface can remain stable for years even as the implementation changes constantly. The C stdio interface (fopen, fread, fclose) has been stable since 1978 while implementations have changed dramatically.
# Parnas's lesson: hide the decision most likely to change
# BAD: callers depend on internal storage format
class EmailQueue:
messages = [] # public list! callers do: queue.messages.append(m)
# If we switch to a priority queue or a Redis backend,
# EVERY caller breaks.
# GOOD: hide the storage decision behind a stable interface
class EmailQueue:
def __init__(self):
self._storage = [] # hidden — free to change
def enqueue(self, message): # stable interface
self._storage.append(message)
def dequeue(self):
return self._storage.pop(0)
def __len__(self):
return len(self._storage)
# Now we can swap _storage for a Redis queue, a priority heap,
# or a database table — zero callers change.
06SOLID Principles
SOLID is an acronym coined by Robert C. Martin ("Uncle Bob") for five principles of object-oriented design. They are not laws of physics — they are design heuristics, each capturing a specific failure mode.
S — Single Responsibility Principle (SRP): A class should have only one reason to change. "Reason to change" means: one stakeholder, one business concern. If your User class changes both when the authentication team changes the login flow and when the billing team changes the invoice format, it has two reasons to change and should be split. Note: SRP is not the same as "one method per class" — it is about sources of change, not size.
O — Open/Closed Principle (OCP): Software entities should be open for extension but closed for modification. You should be able to add new behavior without changing existing, tested code. The classic mechanism is polymorphism: define an abstract interface, add new behavior by creating a new implementation. Adding a new payment provider should mean adding a new class, not editing the existing PaymentProcessor.
L — Liskov Substitution Principle (LSP): Subtypes must be substitutable for their base types without breaking the program. This is the precise formulation: if S is a subtype of T, then for all programs P, substituting an S for a T in P must not change any desirable property of P. The canonical violation: Square extends Rectangle. A Rectangle allows setting width and height independently; a Square must enforce width == height. Code that widens a Rectangle will break if given a Square. LSP says the inheritance hierarchy was wrong.
I — Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they do not use. A fat interface with 20 methods, where each client uses only 3, creates unnecessary coupling: changing an irrelevant method (for that client) forces recompilation. Split fat interfaces into role-specific smaller ones.
D — Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions. Concretely: your OrderService should not import MySQLOrderRepository directly. It should import an OrderRepository interface; MySQLOrderRepository implements that interface. The direction of dependency is inverted — the implementation depends on the abstraction, not the other way around. This is the foundation of Dependency Injection.
from abc import ABC, abstractmethod
# DIP + OCP: OrderService depends on abstraction, not concrete DB
class OrderRepository(ABC): # abstraction
@abstractmethod
def save(self, order): ...
@abstractmethod
def find(self, order_id): ...
class MySQLOrderRepository(OrderRepository): # detail depends on abstraction
def save(self, order): ... # MySQL-specific
def find(self, order_id): ...
class InMemoryOrderRepository(OrderRepository): # easy to add (OCP)
def __init__(self): self._store = {}
def save(self, order): self._store[order.id] = order
def find(self, order_id): return self._store.get(order_id)
class OrderService: # high-level; depends on abstraction
def __init__(self, repo: OrderRepository):
self.repo = repo # injected — DIP in practice
def place_order(self, order):
order.validate() # SRP: validation logic lives in Order
self.repo.save(order)
# ISP: don't force implementers to implement methods they don't need
class Printable(ABC):
@abstractmethod
def print(self): ...
class Scannable(ABC):
@abstractmethod
def scan(self): ...
class SimplePrinter(Printable): # only implements what it supports
def print(self): ...
07Modularity and Module Systems
Modularity is the property of a system that can be divided into independently deployable, compilable, or replaceable units (modules). It is the structural realization of all the design principles above.
A well-designed module has:
- A defined interface (what it promises to callers)
- A hidden implementation (how it delivers on that promise)
- Clear dependencies (what it needs from others)
Modularity exists at multiple scales:
- Function level: a function encapsulates a computation
- Class/object level: a class encapsulates state and behavior
- File/package level: a Python package, a Java package, a C header+source pair
- Service level: a microservice, a Unix daemon, a shared library
- System level: a plugin architecture, an OS with loadable kernel modules
Dependency graphs: at each scale, modules form a directed graph (module A depends on B means edge A→B). A good module graph is a DAG (directed acyclic graph) — no cycles. Cycles mean you cannot understand A without understanding B and vice versa; they also make independent compilation, testing, and deployment impossible. In Java, circular package dependencies are a common code smell. The fix: extract a shared abstraction that both packages depend on.
The UNIX philosophy is perhaps the most successful modularity discipline in software history. Peter Salus summarized it: (1) Write programs that do one thing and do it well. (2) Write programs to work together. (3) Write programs to handle text streams, because that is a universal interface. The | pipe is the universal low-coupling connector between highly cohesive modules.
Module design heuristics: (1) Design modules around secrets (Parnas). (2) Keep interface surface area minimal. (3) Make modules deep — a small interface hiding a lot of useful implementation (John Ousterhout's framing). A shallow module (large interface, little implementation) provides little value and creates coupling.
# A deep module: small interface, rich implementation hidden
# (Ousterhout's ideal)
class Cache:
"""Interface: 3 methods. Implementation: LRU eviction,
thread-safety, statistics, TTL — all hidden."""
def get(self, key): ...
def set(self, key, value, ttl=None): ...
def invalidate(self, key): ...
# A shallow module: large interface, trivial implementation
# (anti-pattern — adds coupling without value)
class TrivialWrapper:
def set_key(self, key): self._key = key
def get_key(self): return self._key
def set_value(self, value): self._value = value
def get_value(self): return self._value
def set_ttl(self, ttl): self._ttl = ttl
def get_ttl(self): return self._ttl
08Designing for Change: Stable vs. Volatile Elements
All software changes. Requirements change, environments change, understanding of the domain deepens. The question is not whether your design will face change, but how expensive that change will be.
The key insight: not all parts of a system change at the same rate. Some elements are very stable (the concept of a "user" in a social app, the mathematical definition of a hash function); others are volatile (which third-party payment processor you use, the exact format of an API response, the specific caching strategy). Good design puts stable things at the center and volatile things at the periphery, with abstractions at the boundary.
This is the foundation of Ports and Adapters (also called Hexagonal Architecture), articulated by Alistair Cockburn. The core domain (business logic) is at the center. It defines ports — interfaces for what it needs (persistence, email, payment). Adapters implement those ports for specific technologies (MySQL adapter, SendGrid adapter, Stripe adapter). The domain never imports a specific adapter; it only knows about the port interface. Changing from Stripe to PayPal means writing a new adapter, not touching the domain.
The Dependency Rule from Clean Architecture (Uncle Bob): source code dependencies must point inward, toward the domain. The domain knows nothing about the framework, the database, or the UI. Those outer layers know about the domain.
Anticipating change without over-engineering: a common mistake is to anticipate every possible change and add abstraction layers for all of them. This is speculative generality — a code smell. The right approach: add abstractions only for changes that are likely based on domain knowledge, and only when the cost of adding them later is high. The YAGNI principle (You Aren't Gonna Need It) is the counterweight: don't build abstractions for imagined future requirements.
A practical rule: if you have one implementation of an interface today and no concrete plan for a second, you may not need the interface yet. Add it when you have a second concrete implementation (the Rule of Three).
# Ports and Adapters: the domain defines the port
from abc import ABC, abstractmethod
# Port (stable abstraction at the domain boundary)
class NotificationPort(ABC):
@abstractmethod
def send(self, user_id: str, message: str) -> None: ...
# Domain: depends only on the port
class OrderService:
def __init__(self, notify: NotificationPort):
self.notify = notify
def complete_order(self, order):
order.mark_complete()
self.notify.send(order.user_id, f"Order {order.id} complete!")
# Adapters (volatile — easy to swap, domain is untouched)
class SendGridAdapter(NotificationPort):
def send(self, user_id, message):
sendgrid_api.send_email(user_id, message)
class SMSAdapter(NotificationPort):
def send(self, user_id, message):
twilio_api.send_sms(user_id, message)
class FakeNotifier(NotificationPort): # test adapter
def __init__(self): self.sent = []
def send(self, user_id, message):
self.sent.append((user_id, message))
09The Law of Demeter: Principle of Least Knowledge
The Law of Demeter (LoD), also called the Principle of Least Knowledge, states: a method should only call methods on objects that it directly "knows about". Specifically, a method m of class C may only call methods on:
Citself- Objects passed as parameters to
m - Objects
Ccreated C's direct component objects (fields)
The intuition: you should only talk to your immediate friends, not to friends-of-friends. The colloquial rule: only one dot (with exceptions for fluent interfaces and collections).
Violating LoD creates a chain dependency: a.getB().getC().doSomething() means class A must know about B's structure (it has a C), and also that C has a doSomething. Now a change to B's internal structure (e.g., C is stored differently) breaks A even though A has nothing to do with C.
This pattern — long chains of method calls — is called a Train Wreck. It is a direct symptom of feature envy (a method reaching into another object to do work that should live there).
The fix: Tell, don't ask. Instead of asking an object for its data and then acting on it, tell the object to do something. wallet.charge(amount) instead of if wallet.getBalance().getCents() >= amount.getCents() { wallet.getBalance().deduct(amount) }.
Note: LoD has limits. Fluent builder patterns (builder.setX(a).setY(b).build()) and LINQ/stream chains (stream.filter(p).map(f).collect()) violate the letter of LoD but not its spirit — they navigate a chain of the same object (a builder or a pipeline), not into foreign internals.
# Train wreck (Law of Demeter violation)
# OrderService knows about Order, Customer, Wallet, and Balance internals
def process_payment(order):
amount = order.get_total()
# Navigates: order → customer → wallet → balance
if order.get_customer().get_wallet().get_balance().get_cents() >= amount:
order.get_customer().get_wallet().get_balance().deduct(amount)
# Fixed: tell, don't ask — push behavior to where the data lives
class Wallet:
def charge(self, amount) -> bool:
if self._balance >= amount:
self._balance -= amount
return True
return False
class Customer:
def pay(self, amount) -> bool:
return self._wallet.charge(amount) # one dot, at each level
def process_payment(order):
if not order.customer.pay(order.total):
raise InsufficientFundsError()
10DRY, WET, and the Limits of Abstraction
Don't Repeat Yourself (DRY), stated by Andrew Hunt and David Thomas in The Pragmatic Programmer: "Every piece of knowledge must have a single, unambiguous, authoritative representation within a system."
Note the precise formulation: it says knowledge (a domain rule, a business decision), not code. Two pieces of code that look identical may be DRY if they represent the same knowledge — or they may be fine to duplicate if they represent different knowledge that happens to look the same today.
DRY is violated when the same rule is encoded in multiple places. Classic examples:
- Validation rules duplicated in the frontend and backend
- Database schema knowledge duplicated in the ORM and in raw SQL strings scattered across the codebase
- A business rule (e.g., "discount applies to orders over $100") encoded in three different places
When that rule changes, you change one place and forget the others — inconsistency bugs follow.
The opposite of DRY is WET: Write Everything Twice (or: We Enjoy Typing). WET code is not always wrong. The Rule of Three is a practical heuristic: tolerate the first duplication, tolerate the second, abstract on the third. Premature abstraction is as harmful as duplication — it binds together things that may diverge.
Incidental vs. essential duplication: Two functions that look the same but handle different business rules are incidentally similar. Merging them into one function couples their future evolution. The wrong abstraction is worse than duplication, because it creates hidden coupling under the guise of reuse (Sandy Metz's "wrong abstraction" principle).
Code duplication vs. knowledge duplication: In microservices, the same User data class may appear in ten services. This is acceptable if each service's User represents that service's local concept of a user — they should evolve independently. Sharing one User library across all services is a coupling disaster.
# DRY violation: the discount rule is in three places
def checkout_total(order):
if order.subtotal > 100:
return order.subtotal * 0.9 # 10% discount
return order.subtotal
def display_savings(order):
if order.subtotal > 100: # duplicated rule
return order.subtotal * 0.1
return 0
def apply_coupon(order, coupon):
base = order.subtotal * 0.9 if order.subtotal > 100 else order.subtotal
...
# DRY fix: single authoritative representation of the rule
DISCOUNT_THRESHOLD = 100
DISCOUNT_RATE = 0.10
def discounted_total(subtotal: float) -> float:
if subtotal > DISCOUNT_THRESHOLD:
return subtotal * (1 - DISCOUNT_RATE)
return subtotal
# Now the rule exists once; all callers use discounted_total()
11Abstraction Levels and the Staircase of Clarity
Every system can be described at multiple levels of abstraction. A CPU instruction set, a C function, a Python method, a REST API call, a microservice — these are all the same computation viewed from different altitudes. Good design maintains consistent abstraction levels within a module: functions should either be high-level (orchestrate) or low-level (implement details), not both.
The Single Level of Abstraction principle (SLA): within a single function, all statements should be at the same level of abstraction. A function that mixes business-level logic (invoice.submit_for_approval()) with implementation details (db.execute("INSERT INTO ...")) is confusing because the reader must constantly shift mental gears.
Stepdown rule (Uncle Bob): a function should read like a newspaper — higher-level context first, then lower-level detail in called functions. The main function orchestrates; each called function handles one level lower. This creates a readable hierarchy where you can skim at the top level and dive only into the details you need.
Abstraction levels in architecture: a classic layered architecture (presentation → application → domain → infrastructure) enforces abstraction levels at scale. Each layer may only call the layer immediately below it. This ensures that the presentation layer never contains SQL, and the domain layer never contains HTTP handling.
The failure mode is called abstraction inversion: a high-level construct is built on a low-level primitive, but then the low-level primitive is rebuilt on top of the high-level construct to get something you needed at the low level. This creates confusing, inefficient circular dependencies. Example: using an ORM's execute_raw_sql method inside a high-level domain model to work around the ORM — you have built high-level on low-level, then gone back to raw low-level inside the high-level.
# Mixed abstraction levels (violation)
def process_new_user(form_data):
# Low-level: parsing raw input
name = form_data['name'].strip().title()
email = form_data['email'].lower()
if '@' not in email:
raise ValueError('bad email')
# High-level: business logic
user = User(name=name, email=email)
welcome_service.send(user)
# Low-level: raw SQL
db.execute("INSERT INTO users (name, email) VALUES (?, ?)", name, email)
# Consistent abstraction level
def process_new_user(form_data):
"""High level: orchestrates the steps."""
user = _parse_user_form(form_data) # low level hidden here
user_repo.save(user) # low level hidden here
welcome_service.send(user) # low level hidden here
def _parse_user_form(form_data) -> User:
"""Low level: input parsing and validation."""
name = form_data['name'].strip().title()
email = form_data['email'].lower()
if '@' not in email:
raise ValueError('bad email')
return User(name=name, email=email)
Key insights
- ✦Complexity is the root cause of most software failure; all design principles are strategies for decomposing complexity into independently understandable pieces.
- ✦Coupling and cohesion are the two fundamental measures of design quality: maximize cohesion (relatedness within modules), minimize coupling (dependency between modules).
- ✦Information hiding (Parnas, 1972) is possibly the most important software design idea: hide the design decisions most likely to change behind stable interfaces.
- ✦SOLID principles are not independent rules but a unified set of heuristics that all point toward the same target: modules that can change for one reason, in one direction, without cascading.
- ✦The Dependency Inversion Principle reverses intuition: high-level policy (business logic) should NOT depend on low-level detail (database, network); both should depend on an abstraction in the middle.
- ✦The Law of Demeter is a concrete rule for avoiding hidden coupling through object-graph navigation: only talk to your immediate neighbors.
- ✦DRY is about eliminating duplicate *knowledge* (business rules), not duplicate code; premature abstraction (the wrong abstraction) is worse than tolerated duplication.
- ✦Designing for change means identifying stable vs. volatile elements and placing abstractions at the boundary — stable interfaces over stable implementations.
Common pitfalls
- ⚠Applying SRP as 'one method per class': SRP is about having one *reason to change* (one stakeholder), not about minimizing class size. Splitting too aggressively creates shallow, useless modules.
- ⚠Treating OCP as 'never modify code': the principle applies to *stable, released* components. During active development, modification is often correct. Over-applying OCP too early creates useless abstraction layers.
- ⚠Violating LSP with 'throws NotImplementedError' overrides: if a subclass overrides a method to throw an exception, it is not substitutable. This is a sign the inheritance hierarchy is wrong — prefer composition.
- ⚠Conflating DRY with 'no duplicate code': two identical functions representing different business rules should NOT be merged; incidental similarity is not the same as shared knowledge.
- ⚠Adding interfaces prematurely for testability without a real design reason: one-method interfaces wrapping a single concrete class that will never have a second implementation add indirection without benefit.
- ⚠Ignoring abstraction level consistency: mixing high-level orchestration with low-level details in the same function is one of the most common readability failures in real codebases.
- ⚠Using inheritance for code reuse when composition is correct: inheritance implies substitutability (LSP); if you only want to reuse methods, prefer composition over inheritance.
- ⚠Creating circular module dependencies: two modules that depend on each other cannot be understood, tested, or deployed independently. Extract a shared abstraction to break the cycle.
Practice
- 01medium
**Coupling audit**: Take any open-source Python or Java project you admire (requests, Flask, JUnit). Pick three pairs of modules/classes and classify their coupling type (content, common, control, stamp, or data). Write a one-paragraph justification for each classification. For any coupling tighter than data coupling, propose a refactoring that would loosen it.
- 02medium
**LSP proof by counterexample**: Implement a `Rectangle` class with `set_width` and `set_height`, then implement `Square extends Rectangle`. Write a unit test that passes for `Rectangle` but fails for `Square`. Then redesign the hierarchy (using a common abstract base with no `set_width`/`set_height`) to satisfy LSP. Explain in your write-up exactly what property of `Rectangle` `Square` violates.
- 03hard
**Port and Adapter refactor**: Take the following monolithic function and refactor it using the Ports and Adapters pattern: a function that reads user records from a MySQL database, sends a welcome email via the SendGrid API, and logs results to a file. Your refactored design should allow the storage, email, and logging implementations to be swapped without changing the domain logic. Write tests for the domain logic using fake/in-memory adapters.
- 04hard
**Modularity and DAG**: Design the module graph for a simplified e-commerce system with: user authentication, product catalog, shopping cart, order processing, payment, email notifications, and admin reporting. Draw the dependency graph. Identify any cycles and explain how to break them. Argue for your decomposition in terms of cohesion and anticipated change.
- 05easy
**Information hiding design exercise**: You are building a rate limiter for an API. Clients call `rate_limiter.check(user_id)` and get back a boolean. The current implementation uses an in-memory token bucket. Future implementations might use Redis or a sliding window counter. Design the module interface. What is the secret? What is exposed? Write the interface and two implementations. Verify that no client code changes when switching implementations.
- 06hard
**Identify SOLID violations in real code**: Find the source of a real open-source bug (GitHub issues often link to the offending code). Analyze whether the bug was made *possible* by a SOLID violation (e.g., a fat class with too many responsibilities, missing abstraction letting a change cascade). Write a 2-page analysis: what SOLID principle was violated, how it enabled the bug, and what the refactored design would look like.
Resources
John Ousterhout's modern treatment of complexity, deep modules, and information hiding. Arguably the best book for this exact topic written in the last decade.
Hunt and Thomas's classic: DRY, orthogonality, designing for change. Essential reading for any software engineer.
Uncle Bob's definitive treatment of SOLID, component principles, the Dependency Rule, and Ports/Adapters at scale.
Parnas's 1972 foundational paper on information hiding. Read the original — it is clear, short, and still correct.
Fred Brooks's 1986 essay on essential vs. accidental complexity. Foundational motivation for all software design.
Good concise overview of each principle with history and links to original sources.
Martin Fowler's catalog of code smells and refactorings — the practical companion to the design principles in this module.
MIT's Software Construction course covers modularity, specifications, and design for change with excellent Java examples.
The original Ports and Adapters article by its inventor. Concrete and motivating.
Metz's influential post on why duplication is far cheaper than the wrong abstraction. Counterweight to naive DRY application.
Checkpoints — you can now…
- ✓You can classify the coupling between any two modules in a codebase (content, common, control, stamp, or data) and explain the practical consequences of each level.
- ✓You can identify low-cohesion classes by applying the single-responsibility test ('one reason to change') and propose a decomposition that improves cohesion.
- ✓You can state each SOLID principle, give a concrete example of its violation, explain *why* the violation is harmful, and show a corrected design.
- ✓You can apply the Ports and Adapters pattern to a new feature: define the domain port (interface), write domain logic depending only on that port, and write two implementations (one real, one fake for tests).
- ✓You can distinguish DRY violations (duplicate *knowledge*) from acceptable code similarity (incidentally similar but representing different rules), and explain the Rule of Three.
- ✓You can draw a module dependency graph for a multi-module system, identify any cycles, and propose how to break them using a shared abstraction or event.
- ✓You can apply the Law of Demeter to refactor a train-wreck method chain using the 'tell, don't ask' principle.