What makes a change cheap or expensive. Parnas’s information hiding, rebuilt in Go and measured; coupling and cohesion as first defined in 1974; deep and shallow modules; DRY as knowledge; and how C#, Java, Go and Rust enforce a module boundary.
Every codebase gets split into parts: files, classes, packages, services. Some splits make later changes cheap. A new requirement touches one part, and nothing else needs to know. Other splits make every change expensive: one small decision is spread across ten files, and changing it means finding all of them.
This part starts Stage 2, low-level design: how to structure the code inside one service or library. It begins with the idea everything else in the stage rests on. What goes together in a module, and what a module should hide. We rebuilt the most famous example, from 1972, in Go and changed it, so the difference between two designs is measured rather than asserted.
Try this first
A program produces a KWIC index, short for keyword in context. It reads lines of text and prints every circular shift of every line in alphabetical order. A circular shift moves the first word to the end: “Pigs fly” becomes “fly Pigs”.
Two teams design it:
- Team A makes a module for each step of the processing: read the input, compute the shifts, sort them, print them. The steps share the data they work on.
- Team B makes a module for each decision that might change: how lines are stored, how shifts are represented, how sorting is done. Each module hides its decision behind functions.
Now the requirement changes: store all the characters in one buffer, instead of a list of words per line, to save memory. How many modules change in each design? Write your guesses down.
What a module is
The two papers that founded this topic define “module” in opposite ways.
In “Structured Design” (1974), Stevens, Myers and Constantine define it as “a set of one or more contiguous program statements having a name by which other parts of the system can invoke it”. In other words, a subroutine.
David Parnas, two years earlier, wrote that “module” is “a responsibility assignment rather than a subprogram”. A module is a piece of work, with something it’s responsible for knowing.
This series uses Parnas’s meaning. A module is any unit with an inside and an outside: a class, a package, a crate, an assembly, a service. Its interface is what others may rely on. Its implementation is everything they mustn’t. The unit changes from language to language, and the questions don’t. Part 7 looks at the SOLID principles, which restate several of these ideas for classes.
Parnas’s criterion: hide the decisions likely to change
Parnas’s 1972 paper, “On the Criteria To Be Used in Decomposing Systems into Modules”, compared the two designs from the opening exercise.
His first design was the one “most programmers” would propose, based on “small scale” experiments he mentions: a module per processing step, all sharing the data. He described how it’s usually reached: “One might say that to get the first decomposition one makes a flowchart.”
His second design used “information hiding” as the criterion: “Every module in the second decomposition is characterized by its knowledge of a design decision which it hides from all others.” The line storage module offered a function CHAR(r,w,c), the cth character of the wth word of the rth line, and nobody else knew how lines were stored. The alphabetizer offered ITH(i), the index of the shift that comes ith in alphabetical order.
Then he listed decisions likely to change, and traced each one through both designs. For storage, he found that in the first design the change “would result in changes in every module!” In the second, “Knowledge of the exact way that the lines are stored is entirely hidden from all but module 1.”
His conclusion is the criterion used ever since:
“We propose instead 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.”
Measured: the KWIC index, built twice and changed three times
Parnas made his comparison on paper. We built his two designs in Go, following his descriptions, plus a third: his first design with one improvement most engineers would make. Then we made three changes, adapted from his list, to each.
| Design | Modules | Lines of Go (code only) |
|---|---|---|
| Processing steps, as Parnas described | files in one package: data (shared), input, circular shift, alphabetize, output, main | 87 (69) |
| Processing steps, with a shared helper | the same, but one shiftWords helper in the shift file builds a shift for both sorting and output |
89 (69) |
| Hidden decisions | a Go package each: line storage, input, circular shifter, alphabetizer, output; and main | 138 (105) |
In the first two designs, the steps read and write the shared data in data.go directly, and in Parnas’s version sorting and output each rebuild a shift from the line data, as his modules 3 and 4 did. In the third, every module is its own Go package with unexported fields, so the compiler enforces the hiding. All three produce the same output before and after every change. The counts come from git diff against each design’s starting version. Choose a change:
All three designs produce the same output before and after every change. File and line counts are git diff against each design’s base version, by checks/part06_kwic/run.py.
| Change | Processing steps | With a shared helper | Hidden decisions |
|---|---|---|---|
| Input format: split words on anything that isn’t a letter or digit | 1 file, 6 lines | 1 file, 6 lines | 1 file, 6 lines |
| Line storage: all characters in one buffer, found by offsets | 5 files, 44 lines | 3 files, 37 lines | 1 file, 27 lines |
| Shift representation: store each shift as a copy, not as an index | 4 files, 19 lines | 2 files, 12 lines | 1 file, 22 lines |
“Lines” counts lines added plus lines removed. The storage change is adapted from Parnas’s “packing characters four to a word”, and all three designs use the same buffer layout for it.
So the answer to the opening question: the storage change touched 5 of the 6 files in Team A’s design as Parnas described it, 3 once the shift-building code is shared, and 1 in Team B’s.
What the numbers show, including the parts that don’t flatter information hiding:
- When a decision was already private, all three did equally well. Only the input code knew the input format in every design.
- When a decision lived in shared data, the change went everywhere that data was read. In Parnas’s processing-step design, the input, shift, sort and output code all read the line structure directly, so all of them changed, and so did the shared definitions in
data.go. - The shared helper halves the ripple, and it’s the first step towards hiding. Putting “build a shift” in one function is exactly Parnas’s advice that a data structure’s “accessing procedures and modifying procedures are part of a single module”. Take that further, for each decision, and you reach the third design.
- The information-hiding design is bigger: 105 lines of code against 69. Packages and interfaces cost code.
- Lines changed isn’t the measure. The shift change edited more lines in the information-hiding design, 22 against 19 and 12, but all of them in one package whose inside no other package can reach. The cost of a change is how many places you must understand and touch, and how much other code you must re-test.
Three more points from Parnas’s paper are often left out. He stressed that the two designs could share every data representation and could even be “identical after assembly”: “The differences between the two alternatives are in the way that they are divided into the work assignments, and the interfaces between modules.” He admitted that the second design could “prove to be much less efficient” if each module’s functions became “a procedure with an elaborate calling sequence”. And he criticised his own better design: the circular shifter’s interface promised an order for the shifts, which gave “more information than necessary”, and he called that “a design error”. Information hiding is about what the interface promises, not only about private fields.
Coupling: how much modules know about each other
Coupling was defined in the 1974 “Structured Design” paper as “the measure of the strength of association established by a connection from one module to another”. The same paper explains why it matters: fewer connections means fewer “paths along which changes and errors can propagate”, which it calls “disastrous “ripple” effects”.
The paper names three things that make coupling stronger:
- How complicated the connection is, from simple and obvious to complicated and obscure.
- What it refers to: a connection to a module by its name is weaker than one that reaches “internal elements of another module”.
- What passes across it: data connections are weaker than control connections, and control connections are weaker than “hybrid” ones, where one module modifies another’s code.
Control coupling is a flag that tells the other module what to do. A function parse(input, strict: bool) that behaves as two different functions depending on the flag couples every caller to the inside of parse. The paper’s fix is to split it into two functions.
Shared data is one of the strongest everyday couplings. The paper calls it a “common environment”, and counts the damage: data shared by several modules couples each of them to every other, “without regard to their functional relationship”. Its example is 3 modules sharing 25 variables, which gives “150 such paths” for a change or an error to travel. That’s the KWIC processing-step design: data.go coupled every step to the representation of lines.
Parnas’s earlier paper, from 1971, gives the broadest definition, and the most useful one: “The connections between modules are the assumptions which the modules make about each other.” A function signature is one assumption. So are the order of the results, the units of a number, the fact that a list is never empty, and how long a call takes.
A finer vocabulary: connascence
Connascence, a term Meilir Page-Jones brought to software in 1992, names the kinds of assumptions two pieces of code can share. The community reference, connascence.io, lists nine. Two components are connascent when they must agree on:
| Kind | Must agree on | Example |
|---|---|---|
| Name | the name of something | calling total() |
| Type | the type of something | passing cents as a long |
| Meaning | what a value means | status == 3 means “shipped” |
| Position | the order of values | transfer(fromAccount, toAccount): swap two same-typed arguments and it still compiles |
| Algorithm | an algorithm | two services hashing a password the same way |
| Execution | the order things run in | call open() before read() |
| Timing | when things run | a cache refresh must finish before a deadline |
| Value | values that must change together | a test that hard-codes the discount rate the production code uses |
| Identity | the same instance | two modules holding the same shared object |
The first five can be seen by reading the code. The last four only show up when the program runs, and connascence.io calls those stronger. The practical rules: prefer the weaker kinds (a name over a magic number’s meaning, named arguments over position), and keep the stronger kinds inside one module rather than across boundaries.
Cohesion: how much a module’s parts belong together
Coupling is about the connections between modules. Cohesion is about the connections inside one. The 1974 paper put them together: coupling goes down when relationships between modules are minimized and relationships inside each module are maximized.
The paper called it binding, and ranked six levels, from weakest to strongest:
| Level | The elements are together because… | Example today |
|---|---|---|
| Coincidental | no meaningful reason | Utils with date formatting, retries and a CSV parser |
| Logical | they’re the same kind of thing | a Validators class holding every validation in the app |
| Temporal | they run at the same time | Startup.Initialize() that opens the database, loads config and warms caches |
| Communicational | they use the same data | functions that all read and write the order record |
| Sequential | one’s output is the next one’s input | parse, then validate, then save |
| Functional | they all perform one function | a ShippingQuotes module that produces a shipping price |
The paper warned that “The scale is not linear. Functional binding is much stronger than all the rest, and the first two are much weaker than all the rest.” Yourdon and Constantine’s book Structured Design (1975; second edition 1978) uses Constantine’s own term, cohesion, and lists seven levels, with procedural between temporal and communicational. That’s why some courses list seven.
Many courses also teach a ladder of coupling types: content, common, external, control, stamp and data. That list isn’t in the 1974 paper, which has data, control and hybrid connections plus the common environment. It’s usually credited to later structured design books, and it describes the same idea: the more a connection passes control or reaches inside, the stronger it is.
One cause of coincidental binding the paper names is still common: modules “created to consolidate “duplicate coding” in other modules”. Its advice, in the same paper, is short: “Eliminate duplicate functions but not duplicate code.” That leads to DRY.
DRY means one home for each piece of knowledge
The Pragmatic Programmer states the DRY principle as: “Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.”
Its authors say they explained it badly: “In the first edition of this book we did a poor job of explaining just what we meant”. “Many people took it to refer to code only.” In the 20th anniversary edition they wrote: “DRY is about the duplication of knowledge, of intent.” And they give an example of two functions with identical code that validate two different things: “That’s a coincidence, not a duplication.”
Merging those two functions into one creates exactly the coincidental binding the 1974 paper warned about. When one of the rules changes, the shared function grows a flag, and now it has control coupling too. So:
- The same knowledge in two places (a tax rate in the code and in a report query): give it one home.
- The same code for two different reasons (a customer name limit and a product name limit that happen to both be 100 characters): leave it duplicated. It will diverge.
Deep and shallow modules
John Ousterhout, in A Philosophy of Software Design (second edition, 2021), gives a way to judge a module’s interface against its implementation. In his written debate with Robert Martin, he put it this way: “The best methods are those that provide a lot of functionality but have a very simple interface”. He calls those deep. When a module’s interface is nearly as complicated as what it does, he calls it shallow: “they don’t help much in terms of reducing what the programmer needs to know.”
A shallow shipping module might offer GetRateTable(zone), FindBand(table, grams) and RoundToCents(amount). Every caller must call all three, in order, and knows how rates are stored. A deep one offers one method: the price for this weight and zone. The rate table, the weight bands and the rounding rule stay inside, so any of them can change without touching a caller.
Ousterhout also warns about information leakage, where one design decision shows up in more than one module. His example comes from students’ text editor projects in his design class: a text class with a method for each user interface action, such as the backspace key, which tied the text class to the user interface. “I now think,” he writes, “that over-specialization may be the single greatest cause of complexity in software.”
Here’s the deep version, in the four languages this series uses. Each quotes two parcels, then asks for a zone that doesn’t exist and a parcel that’s too heavy, and each keeps those two failures apart.
C#:
var quotes = new ShippingQuotes();
foreach (var (grams, zone) in new[] { (1_200, "EU"), (300, "DOMESTIC"), (300, "MARS"), (5_000, "EU") })
{
try
{
Console.WriteLine(quotes.PriceCents(grams, zone));
}
catch (ArgumentException e)
{
Console.WriteLine($"error: {e.Message}");
}
}
// Callers know one method. The rate table, the weight bands and the rounding rule stay inside.
sealed class ShippingQuotes
{
// Up to this many grams, this price in hundredths of a cent.
private static readonly Dictionary<string, (int UpToGrams, long Hundredths)[]> Rates = new()
{
["DOMESTIC"] = [(500, 49_900), (2_000, 89_950)],
["EU"] = [(500, 129_900), (2_000, 219_950)],
};
public long PriceCents(int weightGrams, string zone)
{
if (!Rates.TryGetValue(zone, out var bands)) throw new ArgumentException($"unknown zone: {zone}");
foreach (var (upToGrams, hundredths) in bands)
{
if (weightGrams <= upToGrams) return RoundToCents(hundredths);
}
throw new ArgumentException("too heavy to quote");
}
private static long RoundToCents(long hundredths) => (hundredths + 50) / 100;
}
It prints:
2200
499
error: unknown zone: MARS
error: too heavy to quote
Java (JDK 25 or later, for the compact source file):
void main() {
var quotes = new ShippingQuotes();
record Parcel(int grams, String zone) {}
for (var p : List.of(new Parcel(1_200, "EU"), new Parcel(300, "DOMESTIC"),
new Parcel(300, "MARS"), new Parcel(5_000, "EU"))) {
try {
IO.println(quotes.priceCents(p.grams(), p.zone()));
} catch (IllegalArgumentException e) {
IO.println("error: " + e.getMessage());
}
}
}
// Callers know one method. The rate table, the weight bands and the rounding rule stay inside.
final class ShippingQuotes {
private record Band(int upToGrams, long hundredths) {}
private static final Map<String, List<Band>> RATES = Map.of(
"DOMESTIC", List.of(new Band(500, 49_900), new Band(2_000, 89_950)),
"EU", List.of(new Band(500, 129_900), new Band(2_000, 219_950)));
long priceCents(int weightGrams, String zone) {
List<Band> bands = RATES.get(zone);
if (bands == null) {
throw new IllegalArgumentException("unknown zone: " + zone);
}
for (Band band : bands) {
if (weightGrams <= band.upToGrams()) {
return roundToCents(band.hundredths());
}
}
throw new IllegalArgumentException("too heavy to quote");
}
private static long roundToCents(long hundredths) {
return (hundredths + 50) / 100;
}
}
It prints:
2200
499
error: unknown zone: MARS
error: too heavy to quote
Go, as a function over a private table:
package main
import (
"errors"
"fmt"
)
func main() {
for _, p := range []struct {
grams int
zone string
}{{1200, "EU"}, {300, "DOMESTIC"}, {300, "MARS"}, {5000, "EU"}} {
cents, err := PriceCents(p.grams, p.zone)
if err != nil {
fmt.Println("error:", err)
continue
}
fmt.Println(cents)
}
}
var (
ErrUnknownZone = errors.New("unknown zone")
ErrTooHeavy = errors.New("too heavy to quote")
)
type band struct {
upToGrams int
hundredths int64 // price in hundredths of a cent
}
// In a package of their own, rates, band and roundToCents would be invisible:
// other packages would see PriceCents and its two errors.
var rates = map[string][]band{
"DOMESTIC": {{500, 49_900}, {2_000, 89_950}},
"EU": {{500, 129_900}, {2_000, 219_950}},
}
func PriceCents(weightGrams int, zone string) (int64, error) {
bands, ok := rates[zone]
if !ok {
return 0, fmt.Errorf("%w: %s", ErrUnknownZone, zone)
}
for _, b := range bands {
if weightGrams <= b.upToGrams {
return roundToCents(b.hundredths), nil
}
}
return 0, ErrTooHeavy
}
func roundToCents(hundredths int64) int64 { return (hundredths + 50) / 100 }
It prints:
2200
499
error: unknown zone: MARS
error: too heavy to quote
Rust, the same shape, with a Result:
mod shipping {
use std::fmt;
#[derive(Debug)]
pub enum QuoteError {
UnknownZone(String),
TooHeavy,
}
impl fmt::Display for QuoteError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
QuoteError::UnknownZone(zone) => write!(f, "unknown zone: {zone}"),
QuoteError::TooHeavy => write!(f, "too heavy to quote"),
}
}
}
// Up to this many grams, this price in hundredths of a cent. Not `pub`, so private to this module.
const DOMESTIC: &[(u32, i64)] = &[(500, 49_900), (2_000, 89_950)];
const EU: &[(u32, i64)] = &[(500, 129_900), (2_000, 219_950)];
pub fn price_cents(weight_grams: u32, zone: &str) -> Result<i64, QuoteError> {
let bands = match zone {
"DOMESTIC" => DOMESTIC,
"EU" => EU,
_ => return Err(QuoteError::UnknownZone(zone.to_string())),
};
bands
.iter()
.find(|(up_to, _)| weight_grams <= *up_to)
.map(|(_, hundredths)| round_to_cents(*hundredths))
.ok_or(QuoteError::TooHeavy)
}
fn round_to_cents(hundredths: i64) -> i64 {
(hundredths + 50) / 100
}
}
fn main() {
for (grams, zone) in [
(1_200, "EU"),
(300, "DOMESTIC"),
(300, "MARS"),
(5_000, "EU"),
] {
match shipping::price_cents(grams, zone) {
Ok(cents) => println!("{cents}"),
Err(e) => println!("error: {e}"),
}
}
}
It prints:
2200
499
error: unknown zone: MARS
error: too heavy to quote
The shape is the same in all four: one public way in, the decisions behind it private, and the same two failures, which are part of the interface too. The languages differ in how a failure comes out: an exception in C# and Java, an error value in Go, a Result in Rust. Part 10 covers that choice.
The Go and Rust versions are already the functional version of a deep module: a function over a table the caller can’t see. What’s hidden is the same decision. Nothing about hiding needs objects.
How each language enforces a boundary
A design that says “don’t use the rounding code directly” only lasts until someone is in a hurry. Each language can make the boundary a compiler error instead. The unit of privacy differs:
| Language | Unit a boundary is drawn around | Private by default? | The tools |
|---|---|---|---|
| Go | the package | you choose per name: it’s exported only if it starts with a capital letter | exported names; internal/ directories, enforced by the go command |
| Rust | the module, and the crate | yes: “By default, everything is private”, except items of a pub trait and variants of a pub enum |
pub, pub(crate), pub(super), pub(in path) |
| Java | the package, and since Java 9 the module | members: no, package access; module packages: yes, until exported | private, package access, protected, public; exports in module-info.java |
| C# | the assembly | class members: yes, private; top-level types: internal |
private, protected, internal, protected internal, private protected, public, file; InternalsVisibleTo |
We built the same boundary in each language: a pricing module with a hidden rounding rule, and a billing module that uses it. Each billing module was built twice. Calling only the public function, it built. Reaching the hidden part, the build failed: the compiler refused it, or, for Go’s internal/, the go command did. Choose a language:
Each billing module was built twice by checks/part06_visibility/run.py: calling only the public function, it built; reaching the hidden part, the build failed with the error shown (for Go’s internal/, from the go command).
The errors, as each tool printed them:
| Language | What billing reached for | Error |
|---|---|---|
| Go | a package under pricing/internal/ |
use of internal package example.com/shop/pricing/internal/rounding not allowed |
| Go | an unexported function | name discount not exported by package pricing |
| Rust | a pub(crate) function in another crate |
error[E0603]: function `discount` is private |
| Java | a package the module doesn’t export | error: package com.shop.pricing.internal is not visible |
| C# | an internal class in another assembly |
error CS0122: 'Rounding' is inaccessible due to its protection level |
Details worth knowing in each language:
- Go:
internal/isn’t in the language specification. Thegocommand enforces it: code in or below a directory namedinternal“is importable only by code that shares the same import path above the internal directory”. There’s no way to make exceptions. - Rust:
pubon a module doesn’t make its contents public, andpubon a struct doesn’t make its fields public, so a struct with private fields can’t be built outside its module. Child modules can see their parent’s private items. - Java: since the module system,
publicno longer means “everyone”. A public class in a package thatmodule-info.javadoesn’t export is visible only inside its module, and the JDK applies the same rules to its own internals. They’ve been encapsulated since JDK 9, JDK 16 made that strong by default at run time, and JDK 17 (JEP 403) removed the--illegal-accessswitch that relaxed it. Specific packages can still be opened with--add-opens. Java also gives a sub-package no special access:com.shop.pricing.internalis as foreign tocom.shop.pricingas any other package, which is why the lab’s hidden class had to bepublicand needed the module to hide it. The specification’s name for the default access is “package access”. “Package-private” is the everyday name. Code on the class path is in the unnamed module, so most applications only meet module encapsulation at the JDK’s boundary. - C#: namespaces give no encapsulation at all. Microsoft’s documentation says “Namespaces have no access restrictions.” The boundary is the assembly.
InternalsVisibleTolets a named assembly, usually a test project, see internal members, never private ones.
These are build-time boundaries, not security. Reflection in C# and Java, --add-opens in Java, and //go:linkname in Go can all reach past them. They stop accidents and shortcuts, which is what a design needs.
Measuring coupling
You can’t see coupling by looking at one file, so teams measure it. Two measurements are worth knowing, along with their limits.
Dependencies between modules. Robert Martin’s 1994 paper “OO Design Quality Metrics” counts, for each group of classes:
- Ca (afferent couplings): classes outside that depend on classes inside.
- Ce (efferent couplings): classes inside that depend on classes outside.
- Instability, I = Ce ÷ (Ca + Ce). 0 means it depends on nothing outside and something depends on it. 1 means it depends on others and nothing depends on it.
His rule of thumb is that dependencies should point towards stable modules, and stable modules should be abstract, so they can be extended without being changed. He was also careful about it: “a metric is not a god; it is merely a measurement against an arbitrary standard.” In his paper, the most stable classes are those that depend on nothing and that many things depend on: they have “no reason to change, and lots of reasons not to change.” Stability comes from the dependency graph. It isn’t a count of how often the code has actually changed, or of its bugs. Part 7 returns to this with the dependency inversion principle.
Change coupling from history. Files that are always changed together are coupled, whatever the import graph says. Gall, Hajek and Jazayeri described this in 1998 as logical coupling: “observed identical change behavior of different elements during system evolution.” They found it from release histories, and they drew the design conclusion: “If programs change together across module or subsystem boundaries, the decomposition structure of the application should be reconsidered”. Today, the same analysis can be run over a version control history, counting how often each pair of files appears in the same commit.
Change coupling finds what static analysis can’t: the two services that must be deployed together, or the report query that breaks whenever the orders table changes.
The same ideas apply between services. A database that two services both write to is the 1974 paper’s common environment, stretched across a network: every service sharing it is coupled to every other one’s use of it. Part 32 comes back to service boundaries.
And tests are callers too. A test that reaches into a module’s internals is coupled to the implementation, and breaks when the inside changes even though the behaviour didn’t. Test through the interface, and the tests protect the boundary instead of cementing it.
Explain it like I’m ten
Think of a restaurant. The waiter takes your order, and the kitchen cooks it. You don’t walk into the kitchen and tell the cook which pan to use. You just order “pasta”.
That’s a good module. The menu is its interface: small and simple. How the kitchen stores the pasta, which stove it uses, and what the cook does when the pasta runs out are its secrets. If the kitchen buys a new oven tomorrow, nobody eating in the restaurant needs to know.
A badly split restaurant would let every waiter walk into the kitchen and grab things from the shelves. The first time the kitchen moves the salt, every waiter gets it wrong.
The precise version
- The menu is the module’s interface, and the kitchen’s arrangements are its implementation, hidden by information hiding.
- Waiters grabbing things from the shelves is coupling to internal elements, or shared data: a change in one place breaks many.
- A kitchen that only cooks is cohesive. A kitchen that also does the accounts and repairs the car park is not.
- Where the analogy breaks: a real menu can change without warning, but a module’s interface is a promise other code is compiled against. Changing it breaks callers, so a good interface is designed to stay stable while the inside changes.
Trade-offs
More interfaces, more code. The information-hiding KWIC has 52% more lines of code. Every boundary is a function signature, a type, and something to name and document.
Abstraction can cost performance, less often than it used to. Parnas said so in 1972, and his remedy was to stop assuming a module must be “one or more subroutines”: code from several modules can be assembled together. Today compilers do much of that, inlining small functions across boundaries in Go, Rust, C# and Java. The costs that remain are at network hops, allocations and dynamic dispatch in hot loops, where a wider call, such as a batch, helps more than removing the boundary.
You have to guess what will change. Hiding a decision that never changes adds indirection for nothing. Not hiding one that does change spreads it everywhere. Parnas’s list started with “difficult design decisions” as well as ones likely to change. Good candidates: file and wire formats, storage representations, third-party vendors and their APIs, platform details, algorithms with performance trade-offs, and business rules the product team keeps revising. Past changes in the version history, and the requirements discussion in Part 5, are the best guides.
Too many small modules is also coupling. Splitting code until each piece is trivial produces shallow modules. It also tends to produce what Ousterhout calls “entangled” methods, “conjoined” in his book: to understand one, “you also need to read the code of the other.” Robert Martin, on the other side of their debate, argues for small functions with descriptive names. Both agree that a split should reduce what a reader must hold in mind.
Duplication can be the cheaper coupling, for code but not for knowledge. Copying a small helper between services is often cheaper than a shared library, which couples their upgrades. Copying a business rule or a wire format isn’t: that’s the same knowledge in two places, and the services must still change together. Share a published contract for those.
Common mistakes
A utils or common module. The name admits there’s no single purpose, so it collects coincidental cohesion, everything depends on it, and it grows forever. A focused package such as Go’s strings is a different thing: its name says what it’s for.
Sharing a data structure instead of an interface. Every module that reads a shared structure is coupled to its layout, as the KWIC storage change showed.
Boolean flags that select behaviour. export(data, asPdf: true) is control coupling. Two functions are clearer and can change separately.
Merging code that only looks the same. Two rules that happen to match today are different knowledge, and they’ll diverge. That’s the misreading of DRY its authors corrected.
Making everything public “for now”. Whatever is public gets used. Start private, and make things public when a caller needs them.
An interface for every class, by reflex. An interface with one implementation that repeats every method adds a file and an indirection, and hides nothing the class didn’t already hide. Add one where it earns its place: a test seam in front of I/O, a boundary between modules or assemblies, or a small interface defined by the code that consumes it, which is Go’s habit (Part 8).
Treating namespaces or folders as boundaries. In C#, a namespace hides nothing, and in Java, packages without module exports hide only package-access members. Enforce the boundaries that matter with the tools in the table above.
Using InternalsVisibleTo as a shortcut between unrelated assemblies. Microsoft’s documentation names two good uses: unit tests in a separate assembly, and one class library split across several assemblies. Beyond those, every friend assembly widens the boundary until it means nothing.
Interview questions
Try to answer each one before opening the model answer.
1. What’s the difference between coupling and cohesion? Why do we want low coupling and high cohesion?
Show a strong answer
- Coupling is how much modules depend on each other: how many assumptions each makes about another. The 1974 “Structured Design” paper defines it as “the measure of the strength of association established by a connection from one module to another”.
- Cohesion is how much the parts inside one module belong together: whether they serve one purpose.
- Why: with low coupling, a change stays inside one module instead of rippling out. With high cohesion, the code you need for a change is in one place. They reinforce each other: grouping related things together removes connections between modules.
- Evidence: in the KWIC index built both ways, changing how lines were stored touched 5 of 6 files in Parnas’s design with shared data, 3 once the shift-building code was shared, and 1 of 6 in the design that hid storage in its own package.
Likely follow-up: “Can coupling be zero?” No. Modules that don’t depend on each other at all don’t form a system. The goal is weak, explicit coupling, through small interfaces and data rather than shared internals and control flags.
2. What is information hiding, and how is it different from encapsulation?
Show a strong answer
- Information hiding is a design principle: decide which design decisions are difficult or likely to change, and give each to one module that hides it. Parnas, 1972: “Each module is then designed to hide such a decision from the others.”
- Encapsulation, as the term is usually used, is the language mechanism for enforcing it:
private,internal, unexported names, non-exported packages. The word is used loosely, sometimes for the principle itself, so say which you mean. - You can have encapsulation without information hiding: private fields with a getter and setter for each one still leak the representation. And Parnas’s own design leaked the order of the shifts through its interface, which he called “a design error”.
- What gets hidden isn’t only data. It can be an algorithm, an order, a file format, a vendor, or a timing assumption.
Likely follow-up: “How do you decide what to hide?” List the decisions most likely to change, from past changes, the product roadmap and requirements discussions, and the ones most expensive to get wrong. Give each a module.
3. You find a Utils class with 3,000 lines used by 40 other classes. What’s wrong, and what do you do?
Show a strong answer
- The problem: coincidental or logical cohesion. Its parts are together only because they’re “helpers”. Every change risks all 40 users, and nobody owns it.
- Plan: 1. Group its functions by the knowledge they encode: dates, money, retries, CSV. 2. Move each group next to the code that uses it, or into a small module with a real name and a narrow interface. 3. Where only one caller uses a function, move it into that caller. 4. Check for functions that look alike but encode different rules, and separate them.
- Do it gradually, with tests around each move, and let the compiler find the callers.
- Prevent it coming back: no
utilsorcommonnames, and code review that asks “what decision does this module hide?”
Likely follow-up: “Isn’t duplicating a helper worse than sharing it?” Not always. DRY is about knowledge, not code. Two identical-looking functions that represent different rules should stay separate, and small copies of helper code between independently deployed services are often cheaper than a shared library.
4. What are deep and shallow modules?
Show a strong answer
- Deep: a simple interface over a lot of functionality. The caller learns little and gets a lot. John Ousterhout: “The best methods are those that provide a lot of functionality but have a very simple interface”.
- Shallow: the interface is almost as complicated as the implementation. Pass-through methods, getters and setters for every field, or a split into many tiny functions that must be called in a fixed order.
- Why it matters: the interface is what every caller pays to understand. A deep module keeps that cost low and hides more decisions.
- Tension with “small functions”: splitting code further helps until the interfaces become as complex as the code. Ousterhout argues the deep/shallow test tells you when a split stops helping.
Likely follow-up: “Give an example of making a module deeper.” Replace GetRateTable, FindBand and RoundToCents, which callers must use in order, with one PriceCents(weight, zone). The table, bands and rounding become private and can change freely.
5. How do Go, Rust, Java and C# each let you stop other code using a module’s internals?
Show a strong answer
- Go: privacy is per package. Lowercase names aren’t exported.
internal/directories restrict imports to code under the parent directory, enforced by thegocommand. - Rust: everything is private by default.
pubexposes an item,pub(crate)limits it to the crate,pub(super)to the parent module.pub structdoesn’t make fields public. - Java: member access is
private, package access (the default),protectedorpublic. Since Java 9, a module exports only the packages listed inmodule-info.java, so apublicclass in an unexported package isn’t visible outside the module. - C#: the boundary is the assembly:
internalis visible only within it. Namespaces give no encapsulation.InternalsVisibleToopens internals to a named assembly, normally tests.filelimits a type to one source file. - In each case, the compiler or build tool refuses code that crosses the boundary. For example, Rust reports
error[E0603]: function `discount` is private, and C# reportserror CS0122: 'Rounding' is inaccessible due to its protection level.
Likely follow-up: “Your team splits a monolith into modules inside one repository. How do you stop them re-coupling?” Make the boundaries compiler-enforced (separate assemblies, crates, Java modules, or internal/ packages), add an architecture test that fails on forbidden dependencies, and watch change coupling in the history.
6. What does DRY actually mean? When is duplication acceptable?
Show a strong answer
- DRY: “Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.” It’s about knowledge, not identical lines of code.
- Real duplication: the same business rule in the code, a stored procedure and a report; a schema described twice by hand. When the rule changes, one copy is missed.
- Acceptable duplication: identical code that represents different knowledge. Its authors call that “a coincidence, not a duplication”. Merging it creates coincidental cohesion and, later, flags.
- Also acceptable: small copies of helper code across independently deployed services, where a shared library would couple their upgrades. Not business rules or wire formats: those are knowledge, and belong in one published contract.
Likely follow-up: “How do you remove real duplication between a schema and code?” Generate one from the other: code from the schema, or migrations from the model, so there’s one authoritative source.
7. How would you find the most coupled parts of a large codebase you’ve just joined?
Show a strong answer
- Static dependencies: build a dependency graph of packages or assemblies. Look for cycles, modules everything depends on, and modules that depend on everything. Martin’s afferent and efferent counts give numbers, as a guide, not a target.
- Change coupling: mine version control for files and modules that change together in the same commits, especially across module or team boundaries. Gall, Hajek and Jazayeri called this logical coupling in 1998.
- Shared data: tables or topics written by more than one module or service.
- Hot spots: combine change frequency with complexity, since coupled code that rarely changes costs little.
- Ask people which changes always need two teams, or two deploys.
Likely follow-up: “What would you do first with the results?” Pick the coupling that causes the most real pain (failed deploys, slow changes), and fix one boundary with tests around it, rather than restructuring everything.
8. What’s wrong with a boolean parameter like render(page, isAdmin)?
Show a strong answer
- It’s control coupling: the caller tells the function which of two behaviours to perform, so every caller depends on how
renderis structured inside. - It hides two functions in one: each call site reads
render(page, true), which says nothing, and each new case adds another flag and more branches. - Better: two functions,
renderForAdminandrender, sharing private helpers; or pass the thing that varies, such as a permissions object, as data. - Where a flag is fine: a genuine option of one operation, such as
sort(descending: true), where the function does one thing and the flag only adjusts it.
Likely follow-up: “What about an enum instead of a boolean?” Clearer at the call site, and still control coupling if each value selects a different algorithm. If the enum grows, that’s a sign it should be a strategy passed in (Part 12).
Sources
- Lab:
system-design/checks/part06_kwic/(the KWIC index in three designs and three changes, Go 1.26) andsystem-design/checks/part06_visibility/(module boundaries in Go 1.26, Rust 1.95, JDK 25 and .NET SDK 10.0.302) - D. L. Parnas, “On the Criteria To Be Used in Decomposing Systems into Modules”, Communications of the ACM 15(12), December 1972
- D. L. Parnas, “Information Distribution Aspects of Design Methodology”, Information Processing 71 (IFIP Congress 1971)
- W. P. Stevens, G. J. Myers, L. L. Constantine, “Structured Design”, IBM Systems Journal 13(2), 1974; E. Yourdon and L. Constantine, Structured Design, 1975
- J. Ousterhout, A Philosophy of Software Design, 2nd edition, 2021, and A Philosophy of Software Design vs Clean Code (Ousterhout and Martin, 2024–25)
- connascence.io (community reference), after M. Page-Jones, “Comparing techniques by means of encapsulation and connascence”, CACM 35(9), 1992
- R. C. Martin, “OO Design Quality Metrics: An Analysis of Dependencies”, 1994
- H. Gall, K. Hajek, M. Jazayeri, “Detection of Logical Coupling Based on Product Release History”, ICSM 1998
- D. Thomas and A. Hunt, The Pragmatic Programmer, 20th anniversary edition (copyright 2020): Topic 9, DRY
- Go: the specification, exported identifiers and cmd/go, internal packages
- Rust: the Reference, visibility and privacy and the Book, chapter 7
- Java: JLS §6.6, access control, JLS §7.7, module declarations, JEP 261, JEP 403
- C#: access modifiers, accessibility levels, InternalsVisibleToAttribute
What to remember
- A module is a responsibility with a secret. Its interface is what others may rely on; everything else can change.
- Split by the decisions likely to change, not by the steps of the processing. In the KWIC index, changing the line storage touched 5 of 6 files when steps shared data, 3 with a shared helper, and 1 when storage was hidden.
- Coupling is the assumptions modules make about each other. Shared data and control flags are the strongest everyday kinds.
- Cohesion is how much a module’s parts belong together. Functional cohesion is the goal;
utilsis coincidental. - DRY means one home for each piece of knowledge. Code that only looks the same can stay separate.
- Prefer deep modules: simple interfaces over a lot of hidden work.
- Make important boundaries compiler-enforced: unexported and
internal/in Go,pub(crate)in Rust, module exports in Java,internalassemblies in C#.
Before you decide how to split code, list what’s likely to change. Then give each of those decisions one home, and hide it there.