A Philosophy of Software Design
Notes on Ousterhout's take on managing complexity through modular design, deep modules, and information hiding.
Started
1 Apr 2026
19 day span
Progress
190/190
100% complete
What I Learned
- Good software design is about managing complexity.
- Deep modules — simple interfaces over powerful internals — are the best tool against complexity.
- Complexity grows in small steps. Fight it with small, continuous investments.
The core problem in software is splitting a hard problem into pieces you can solve on their own.
Chapter 1 — Introduction
- We aren’t limited by physics. We are limited by how much of our own systems we can hold in our heads.
- Complexity makes code hard to understand, slow to change, and more buggy.
- Complexity breeds more complexity.
- The bigger the team and codebase, the harder it gets.
- Tools can only help so much.
- Two ways to fight complexity:
- Eliminate it — make code simpler. Remove special cases.
- Hide it — wrap it in modules so others don’t have to deal with it.
- Design never stops. Code is soft, so you keep shaping it.
- It’s easier to spot bad design in others’ code than your own.
- Every rule breaks at the edges. Don’t take any idea to the extreme.
Notes
- Every long-lived service I’ve inherited had the same failure mode: nobody owned the system-level design. Individual PRs were fine in isolation; the shape of the system drifted one commit at a time until nothing fit. The fix is a rotating “design owner” who reviews architectural deltas monthly and has the authority to reject additive complexity, not a new process doc.
- Complexity budgets are real. On one payments service we tracked “concepts a new hire had to learn before first on-call shift.” When it crossed ~40 we started deleting features — two of them had fewer than 5 users/month and were costing us a full week of onboarding per engineer.
- The honest metric for “is this codebase complex” is time-to-first-PR for a new hire. Everything else is a proxy.
Chapter 2 — The Nature of Complexity
What complexity is
Complexity is anything about a system’s structure that makes it hard to understand or change.
Total complexity = complexity of each part × time spent on that part.
So make the common case simple. If a popular feature forces users to learn rare features, you’ve added cost to everyone.
Signs of complexity
- Change amplification: a small change forces edits in many places.
- Cognitive load: how much you must know to do a task. Sometimes more lines of code is simpler because it lowers cognitive load.
- Unknown unknowns: you can’t tell what to change, or what you need to know to change it safely.
Where complexity comes from
- Dependencies: code that can’t be understood on its own. Every caller of a function depends on it. You can’t remove dependencies, so keep them few and obvious.
- Obscurity: important info is hidden. Creates unknown unknowns and adds cognitive load.
Complexity grows in small steps
Death by a thousand cuts. No single bad decision creates it — small bits pile up.
Notes
- Unknown-unknowns are the expensive kind. On a Spring Boot service we shipped a “trivial” change to a
@Transactionalmethod — caller was in the same class, so Spring’s proxy didn’t apply, transaction silently didn’t open, and we dual-wrote to two systems for six weeks before a reconciliation job caught it. No grep finds that; the obscurity is in the framework’s runtime behavior. Structural complexity of this flavor — implicit machinery — is where the real outages come from. - Dependency counts lie. A class with 3 imports that reaches into a global
ApplicationContextis more coupled than one with 15 explicit parameter dependencies. Count what the code reaches for at runtime, not what’s listed at the top of the file. - Obscurity compounds with tenure turnover. A team with 2-year average tenure can absorb ~3x the obscurity of one with 6-month turnover. Design for your actual attrition rate, not your aspirational one.
Chapter 3 — Working Code Isn’t Enough
Tactical programming
- Ship features fast. Skip refactoring. Skip anything that doesn’t pay off today.
- Adds complexity through short-sighted choices.
Strategic programming
- Invest in design even when it slows the current task.
- Invest proactively (think ahead) or reactively (fold in what you’ve learned).
- Slower now, faster later.
How much to invest
- You learn the problem by working on it. Big upfront design (waterfall) fails.
- Make many small investments as you go.
- Aim for 10–20% of your time on design work.
Notes
- “Tactical vs strategic” maps cleanly to code lifetime. Rule of thumb I use: if the code will outlive the next reorg (~18 months), design investment pays back; under that, it’s ceremony. The mistake isn’t writing tactical code — it’s writing strategic code for something that’ll be deleted.
- The 10–20% rule hides the distribution. Strategic investment isn’t spread evenly — it’s 0% on throwaway glue and 50% on load-bearing abstractions (auth, persistence, public APIs). Treating it as an average leads to uniformly mediocre design everywhere.
- The clearest symptom a team has gone tactical-forever: PRs with titles like “quick fix” that touch >10 files. The fix isn’t quick; the system just makes every change expensive.
Chapter 4 — Modules Should Be Deep
Modular design
- A module is any unit of code with an interface and an implementation. Classes, services, subsystems — all modules.
- Interface: what other code must know to use it.
- Implementation: the code that fulfills the interface.
- Good modules have simple interfaces over complex implementations. Two wins:
- Simple interface = less cost to callers.
- You can change the implementation without breaking anyone.
What’s in an interface
If someone needs to know it to use your module, it’s part of the interface.
- Formal parts: enforced by the language — signatures, public methods.
- Informal parts: in docs only — behavior, side effects.
Abstractions
An abstraction is a simplified view of something. It drops unimportant details.
Two ways to get it wrong:
- Include too much: abstraction gets harder than it should be.
- Leave out too much: important things get hidden → obscurity.
Deep modules
- Powerful functionality, simple interface.
java.nio.file.Filesis a textbook case — a handful of methods that hide every filesystem quirk on every OS the JVM runs on:
String body = Files.readString(path);
Files.writeString(path, body, StandardCharsets.UTF_8);
List<String> lines = Files.readAllLines(path);
Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING);
try (Stream<Path> s = Files.walk(root)) { ... }
- Think cost vs benefit:
- Benefit = what the module does.
- Cost = its interface.
The JVM is full of the same pattern:
ExecutorService.submit(task). One method accepts aRunnableand returns aFuture. Inside: worker thread pool, bounded queue, rejection policy, graceful shutdown, exception capture.Executors.newVirtualThreadPerTaskExecutor()swapped the entire implementation in JDK 21 without changing the interface — that’s what a deep module earns you.synchronized(lock) { ... }. A keyword. Underneath: fast-path lock acquisition, inflation to a monitor under contention, and memory barriers that give you the JMM’s happens-before guarantee. The block reads like a scope; it buys you correctness on 96-core machines.CompletableFuture.thenApply,thenCompose,exceptionallychain async steps. Underneath: a dependency graph of callbacks, completion propagation, executor hand-off, exception wrapping. Callers read the happy path top to bottom.- The garbage collector. Zero interface — no methods you call. It hides generational collection, write barriers, safepoints, concurrent marking. Switching from Parallel to G1 to ZGC changes pause characteristics by orders of magnitude and your code doesn’t move.
ClassLoader. A handful of methods (loadClass,defineClass,getResource) that underpin the entire module system, hot reload, OSGi, Spring Boot’s nested JARs, and every Java agent ever shipped.
Shallow modules
- Interface is complex relative to what the module does.
- The cost of learning the interface wipes out the benefit of hiding the internals.
- Small modules tend to be shallow.
Classitis
The usual advice is “keep classes small.” Taken too far, you get classitis:
Small classes are each simple, but you end up with too many. Each has its own interface. System-level complexity explodes. You also write more boilerplate.
Java is the usual cautionary tale. A trivial “write a line to a file” becomes:
FileWriter fw = new FileWriter("out.txt");
BufferedWriter bw = new BufferedWriter(fw);
PrintWriter pw = new PrintWriter(bw);
pw.println("hello");
pw.close();
Three classes, three interfaces to learn, one line of real work. Contrast with Files.writeString(path, "hello") — one deep call that hides buffering, encoding, and resource handling.
Notes
- Hyrum’s law bites every successful interface.
HashMap’s iteration order was “unspecified” for fifteen years; production code depended on it, and when JDK 8 changed collision-chain layout, quiet bugs surfaced across the ecosystem. At enough scale, every observable behavior — timing, error messages, allocation patterns — becomes part of the contract. Plan the intentional contract knowing the full contract is whatever callers can see. - Depth isn’t static.
Stringis shallow-looking (immutable sequence of chars) but deep in practice — intern pool, compact strings (JEP 254), hash caching. Over time a deep module accretes the right optimizations precisely because its interface stayed narrow. - The test for “is this interface deep enough”: can I replace the implementation without renegotiating with callers? If not, the real interface is wider than the documented one.
Chapter 5 — Information Hiding (and Leakage)
Information hiding
- Each module holds a few design decisions inside. These don’t show up in the interface.
- Same idea inside a class:
- Each private method should hide some knowledge.
- Use each instance variable in as few places as possible.
Information leakage
- Leakage: the same design decision shows up in more than one module.
- It’s the opposite of hiding.
- Can leak through an interface, or through shared assumptions (like a file format).
Temporal decomposition
- Splitting by when things happen instead of what they know. Leads to leakage.
- A slightly bigger class is often better — it keeps related knowledge in one place and raises the interface to fewer, higher-level steps.
Chapter 6 — General-Purpose Modules are Deeper
- General-purpose interfaces beat special-purpose ones:
- Fewer, deeper methods.
- Cleaner separation. Less leakage.
- Make modules somewhat general. It’s one of the best ways to cut system complexity.
Notes
- “Somewhat general” is the load-bearing word.
java.util.Listis general;java.util.AbstractListis somewhat general and much more useful as a starting point. Fully general abstractions tend to collapse intoObjectandMap<String, Object>— which is what happens when teams mistake generality for power. - The test for “general enough, not too general”: write down three real callers you don’t yet have. If the interface still serves them without contortion, it’s right. If two of the three need escape hatches, you’ve over-specialized. If all three need the same escape hatch, you’ve under-specified.
- Spring’s
JdbcTemplateis the positive example: general enough to handle arbitrary SQL, specialized enough that you’re not re-implementing connection handling, result set mapping, or exception translation.JpaRepositorywent too general and you pay for it in mystery N+1s.
Chapter 7 — Different Layer, Different Abstraction
Every interface, class, or function you add costs something — people have to learn it. It only pays off if it removes more complexity than it adds.
Systems are layered. Higher layers use lower ones. Each layer should have its own abstraction.
Pass-through methods
- A method that only forwards arguments to another method. Usually a sign the classes overlap.
- Put the interface with the code that does the work.
- Fixes:
- Let callers invoke the target directly.
- Move work around to avoid the call.
- Merge the classes.
When duplicate signatures are OK
Same signature is fine if each method does real, different work. Pass-throughs are bad because they add nothing.
Decorators
- A decorator wraps an object and mirrors its API.
- The pattern encourages API duplication.
- Decorators are often shallow. Before writing one, ask:
- Can it go in the base class?
- Can it merge with the specific use case?
- Can it merge with an existing decorator?
- Can it be a standalone class?
Java’s java.io streams are the canonical offender. To read lines from a file you compose three decorators:
BufferedReader in = new BufferedReader(
new InputStreamReader(
new FileInputStream("file.txt"), StandardCharsets.UTF_8));
Each layer is shallow — buffering, byte-to-char conversion, line parsing — and the caller has to know all three to get the common case right. Files.newBufferedReader(path) later collapsed this into one deep call.
Pass-through variables
A variable threaded through many methods just to reach a deep caller.
Fixes:
- Put it on a shared object.
- Use a global.
- Use a context object for system-wide state.
Notes
- Explicit beats implicit until the parameter list starts sorting itself. My rule: once three unrelated call sites pass the same value through, hoist it. Before that, threading is cheaper than the “where does this come from?” tax of a context object.
- Java’s
ThreadLocalis the anti-pattern that looks like the fix. It still works with virtual threads, but the lifetime and footprint story gets easier to get wrong, and it remains brittle across async hand-off (CompletableFutureloses the binding across threads) or parallel tests.ScopedValue(JEP 446, still preview as of JDK 21/22) is the structured replacement once it goes GA — same ergonomics, bounded lifetime. - MDC (SLF4J’s logging context) is the acceptable exception. It’s read-only to application code, lives for a request, and the alternative is threading
requestIdthrough every method — a cost users pay to make logs useful. - Spring’s
RequestContextHolderis the unacceptable one. Call a bean from a@Scheduledjob or a Kafka consumer and it’s null; nobody tells you until 3am.
Chapter 8 — Pull Complexity Downwards
- Users outnumber module authors. Better for the author to suffer than the users.
- Pull complexity down when:
- It’s close to what the module already does.
- It simplifies many callers.
- It simplifies the interface.
Notes
- Every config knob you add will be left at the default by 95% of callers and tuned wrong by the remaining 5%.
HikariCP’smaximumPoolSizeis tuned wrong on most services I’ve seen — set to 100 “to be safe,” causing database connection storms under load. The knob exists because Hikari can’t know your DB, but a saner default + documented math beats a free parameter every time. - Good libraries push defaults down and expose escape hatches sideways. Netty’s
EventLoopGrouphas sensible defaults (CPU × 2 threads), but when you need to share loops across a Thrift and HTTP server you can. That’s the shape: zero-config for 80%, one-line override for 15%, full control for the last 5%. - The caller-knows-something-the-library-can’t test is the right one, but apply it per-parameter, not per-class.
HttpClient.connectTimeoutpasses the test (depends on network path).HttpClient.versiondoesn’t (it’s a capability question the library can negotiate).
Chapter 9 — Better Together Or Better Apart?
When should two pieces of functionality live together, and when apart?
More small modules = simpler parts, but usually more total complexity:
- Too many modules — hard to find or track. More interfaces = more to learn.
- Extra glue code to coordinate them.
- Separation:
- Good if the parts are truly independent.
- Bad if they’re linked — you can’t see them together.
- Duplication can creep in.
Code belongs together when:
- It shares specific knowledge.
- Both sides use each other.
- One concept covers both.
- You can’t understand one piece without looking at the other.
Splitting and joining methods
- Each method should do one thing completely.
- If you can’t understand one method without reading another, that’s a red flag.
Red flag: two pieces of code physically separate, but neither makes sense alone.
Split a method when:
- It has independent, general-purpose subtasks.
- Its interface is too big and callers don’t always need every piece in order.
Join methods when it:
- Replaces two shallow methods with one deep one.
- Removes duplication.
- Removes dependencies or intermediate data.
- Puts related knowledge in one place.
- Simplifies the interface.
Chapter 10 — Define Errors Out Of Existence
- Exceptions a class throws are part of its interface.
- Four ways to handle fewer exceptions:
- Define them out of existence — design the API so the error can’t happen.
- Mask them — catch at a low level and handle it there.
- Aggregate — handle many at once, higher up.
- Just crash when the error is rare and hard to recover from.
Notes
- “Parse, don’t validate” at an API boundary: accept
Stringonce in the controller, convert toEmailAddress/UserId/Moneyvalue types, pass those everywhere else. I’ve measured this on two services — null-check and validation code dropped ~40%, and the bugs that remained clustered in the parser where we could actually test them. - The four strategies have a strict ordering by cost. Define-out is free forever; masking costs whoever reads the module once; aggregation costs a retry layer; “just crash” costs an incident. Teams reach for aggregation when define-out was available because define-out requires changing a type signature and aggregation doesn’t — and the incentive gradient leads to worse systems.
- Checked exceptions in Java were the right idea with the wrong ergonomics. They force callers to confront errors, which is the point, but the lack of a
throwspolymorphism (seeFunction.apply) pushed everyone toRuntimeExceptionand we lost the signal. Kotlin and Scala dropped them; the fix was types likeResult<T>/Either<E, T>, which is define-out by another name. - One concrete define-out move that’s paid off repeatedly: make illegal states unrepresentable in DTOs. A
sealed interface PaymentStatus { Pending, Succeeded(txId), Failed(reason) }eliminates the “succeeded but no txId” and “failed but reason is null” bugs at the type level. Records (Java 16) plus sealed types (Java 17) made this combo ergonomic.
Chapter 11 — Design it Twice
Your first design is rarely your best. Compare at least two options for every big decision.
Notes
- “Design it twice” is cheap on paper, expensive in practice because the first design has already stolen your imagination. The discipline that works for me: write the second design’s interface first — just the signatures, in a scratch file, before touching the first. It forces you to justify each decision against an alternative that isn’t strawmanned.
- The good version of this is also what good design docs do. The “Alternatives Considered” section is not ceremony; it’s where the reviewer learns whether you actually thought about the problem or just described your first idea in more detail.
- SICP captures the frame:
Every computer program is a model, hatched in the mind, of a real or mental process. These processes, arising from human experience and thought, are huge in number, intricate in detail, and at any time only partially understood. They are modeled to our permanent satisfaction rarely by our computer programs. Thus even though our programs are carefully handcrafted discrete collections of symbols, mosaics of interlocking functions, they continually evolve: we change them as our perception of the model deepens, enlarges, generalizes until the model ultimately attains a metastable place within still another model with which we struggle.
Chapter 12 — Why Write Comments? The Four Excuses
Comments capture what was in the designer’s head but couldn’t go into the code.
i. Good code is self-documenting
- Good code needs fewer comments. Not zero.
- Some design info just can’t live in code.
- Reading code to learn an interface is slow and painful.
Comments are part of abstraction. If users must read the code to use a method, you don’t really have an abstraction.
ii. I don’t have time
Comments pay back fast through easier maintenance. Skipping them trades long-term speed for short-term speed.
iii. Comments get stale
Docs change faster than code. Disciplined teams keep them in sync as part of the work.
iv. I’ve never seen useful comments
Fixable — learn to write good ones.
Notes
- Bad comments are Goodhart’s law. Once “has a Javadoc” is a CI check, every getter gets
/** Gets the name. @return the name */and the rule has destroyed the thing it measured. - Comment value is bimodal. The comment that explains why we don’t use
CompletableFuture.allOfhere (it doesn’t preserve per-future results or make multiple failures easy to surface) saves an engineer an afternoon of debugging, and there’s no way to recover that information from the code. Most comments don’t clear that bar; a few do; there’s almost nothing in between. - The comments with the highest ROI are the ones next to nonobvious non-decisions: “we don’t retry here because the upstream is non-idempotent,” “left as
ArrayListnotLinkedListbecause measured, don’t change without re-measuring.” They pre-empt the refactor that reintroduces the bug.
Chapter 13 — Comments Should Describe Things that Aren’t Obvious from the Code
Pick conventions
They help by:
- Keeping comments consistent and readable.
- Making sure you actually write them. Constraints free you up.
Types of comments:
- Interface: above a class, data structure, or method.
- Data structure member: next to a field.
- Implementation: inside a method.
- Cross-module: describes a dependency across module boundaries.
Don’t repeat the code
A comment that says the same thing as the code next to it adds nothing. Avoid ones that just echo the name.
Lower-level comments add precision
- Comments work at a different level than the code.
- Lower-level comments pin down exact meaning.
- Higher-level comments give intuition — the why, or a simpler mental model.
- Same-level comments just repeat the code.
- For variables, think nouns (what it is), not verbs (what’s done to it).
Interface documentation
- Interface comments tell users how to use the thing.
- Implementation comments tell maintainers how it works.
- If the interface comment has to explain the implementation, the class or method is shallow.
- Implementation details leaking into the interface is pollution.
Implementation comments: what and why, not how
- Most short methods don’t need them.
- When you do write them, explain what it’s doing and why — not how.
Notes
The book treats code and comments as separate channels, but a lot of what it puts in comments belongs in the code itself:
- Units in names.
Duration timeoutbeatslong timeoutMsbeatslong timeout+ comment. Prefer types that carry units; fall back to the name. - Constraints in types. A
record PositiveInt(int value) { public PositiveInt { if (value <= 0) throw ...; } }beats a Javadoc saying “must be > 0.” Checked at the boundary once; free everywhere after. - Boundaries in names.
startInclusive, endExclusivebeatsstart, end+ doc.Range.closedOpen(a, b)(Guava) beats both. - Intent in types.
sealed interface Result<T> permits Success<T>, Failurebeats “returns null on error.”
Comments only earn their place when the code genuinely can’t carry the information. The three that consistently do:
- Why not the obvious thing. “Not using
parallelStreamhere — the downstream JDBC connection pool is sized for serial access.” - Cross-module invariants. “Caller holds the
accountLock; this method assumes it.” Ideally encoded in types; when you can’t, a comment is the honest fallback. - Historical context with a link. “See INC-4421 — ordering matters because the reconciliation job reads the table before the write barrier completes.”
Chapter 14 — Choosing Names
- Good names are documentation:
- Easier to read code.
- Fewer other docs needed.
- Easier to spot bugs.
- Names are abstractions. They hide the complex thing they refer to.
- Be precise: a name that could mean many things says little and invites misuse.
- If you can’t find a clean name, the thing itself may not be clean.
- Stay consistent: seeing a name in one spot should let you guess what it means elsewhere.
The greater the distance between a name’s declaration and its uses, the longer the name should be. — Andrew Gerrand
Chapter 15 — Write The Comments First
- Use comments as a design tool.
- Comments written late are usually bad comments.
Notes
- The two-sentence test is the real value. If the Javadoc has to say “this method does X, and Y, and handles Z,” you have three methods wearing one signature. Split them before you ship.
- I only write docs-first for things with committed contracts: public library APIs, cross-team service endpoints, anything a consumer versions against. For internal refactors I write tests first (they’re executable docs) and Javadoc after. Forcing docs onto exploratory code wastes the doc-writing discipline on a design that’s going to change.
- The best engineers I’ve worked with write the changelog entry first, not the method doc. “Adds
X.computeso callers no longer need to grab the lock manually” reveals whether the change has a point. If the changelog is hard to write, the change is probably unjustified.
Chapter 16 — Modifying Existing Code
- After each change, the system should look like it was designed for the new state from day one.
- If you’re not making the design better, you’re making it worse.
- Put comments next to the code they describe, so they stay in sync.
- The farther a comment is from its code, the more abstract it should be.
- Comments belong in code, not commit messages.
- Link external resources, don’t duplicate them. Readers need access, not a copy.
Notes
- “Leave the design better” is aspirational unless someone’s budget includes it. In practice teams either carve out explicit refactor sprints (works, briefly) or adopt the “campsite rule” (works, if tenure > 1 year). Neither works without a senior engineer blocking PRs that degrade structure — the gravity toward “just add the if-statement” is too strong.
- The most damaging anti-pattern in long-lived code isn’t bad design, it’s frozen design — classes that nobody edits because “last time someone touched
OrderServiceproduction went down.” The fear is real, the code gets worse, and eventually you rewrite it under incident pressure. The fix is investing in tests that make the class safe to change before you need to change it. - Duplicate external docs only when the source is likely to disappear or churn under you (vendor pages behind auth, third-party blog posts). For anything authoritative and stable — RFCs, JEPs, language specs, your own wiki — link. The maintenance cost of keeping a copy in sync with a moving source is worse than a dead link, and dead links at least signal staleness.
- Link commits and incidents from comments, not the reverse.
// see INC-4421next to the weird-looking code ages well.// fixed in abc123ages badly because the reader has to go spelunking to learn what was fixed.
Chapter 17 — Consistency
Consistency gives you leverage. Learn a pattern once, apply it everywhere.
Chapter 18 — Code Should be Obvious
- Design code to be read, not written.
- Non-obvious code means the reader is missing something they need.
What helps
- Whitespace.
- Comments — when you can’t make the code obvious, fill the gap with a comment.
What hurts
- Event-driven code: flow of control is hard to follow.
- Generic containers (like Java’s
Map.Entry<K,V>or a homegrownPair): the names say nothing about the contents. - Different types for declaration and allocation — e.g.
List<String> names = new ArrayList<>();forces the reader to reconcile two types for one variable. - Code that surprises the reader.
Notes
- Event-driven code is nonobvious because the call graph lives at runtime, not in the source. Spring
@EventListeneris the usual offender —grepfor the event class is the only way to find what runs, and transactional listeners (@TransactionalEventListener) fire at a phase you can’t see from the publisher. Use events only when the decoupling is worth the readability cost, and never for flows where ordering matters. - Kafka consumers have the same problem at a system level. The service that publishes
OrderPlacedhas no idea that five downstream services consume it, one of which triggers a seventh. The only honest answer is distributed tracing; treat an untraced event-driven system as unmaintainable. - Replace generic containers with named domain types.
Map.Entry<String, Integer>forces the reader to remember which side is the name and which is the score; arecord UserScore(String name, int points)tells them at the call site. Records made this free in Java 17 — there’s no excuse forPair<A, B>in new code. - The “different types for declaration and allocation” rule has a subtle upside in Java:
List<String> names = new ArrayList<>()lets you swap the impl later. But in practice 95% of those variables stayArrayListforever, and the extra word buys nothing.var names = new ArrayList<String>()(Java 10+) is usually what you actually meant.
Chapter 19 — Software Trends
OOP and inheritance
- Private methods and fields support information hiding.
- Interface inheritance: parent defines signatures, children implement them. One interface, many uses — good leverage against complexity.
- Implementation inheritance: parent provides default code, children override.
- Cuts duplication across children.
- But creates tight coupling between parent and children — Ousterhout’s net read is negative; duplication is the lesser evil.
- Prefer composition over implementation inheritance. Java’s own
Stack extends Vectoris the stock example of what goes wrong —Stackinheritsadd(int, E)andget(int)fromVector, so callers can poke at the middle of a stack and the LIFO invariant evaporates.ArrayDequewas the eventual fix, and it composes rather than inherits.
Agile
- Incremental, iterative work.
- Risk: slides into tactical programming.
Unit tests
Tests make refactoring safe.
Test-driven development
Problems:
- Focuses on passing a test, not finding the best design. Tactical.
- Too incremental — tempts you to hack the next feature in.
- Works best for bug fixes.
Design patterns
- Common solutions to common problems.
- The main risk is overuse. Not every problem fits a pattern — forcing one in hurts.
- Patterns don’t improve a system by default. They only help when they fit.
- More patterns isn’t better.
Getters and setters
- Justified as hooks for extra logic.
- They’re shallow. They clutter the interface without adding much.
The JavaBeans convention is the archetype. Every field turns into a getter and a setter, plus hand-rolled equals, hashCode, and toString:
public class User {
private String name;
private int age;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getAge() { return age; }
public void setAge(int age) { this.age = age; }
// ...
}
None of it hides anything; it just re-exposes the fields through a wider surface.
Notes
- Java records (
record User(String name, int age) {}) and Kotlin’sval/varproperties are the right fix: the common case (plain field access) is one line, and the rare case (custom logic) is a one-line override. That’s pulling complexity down at the language level. If you’re still writingUserDtoclasses with 12 getters by hand in 2026, you’re paying for a feature that’s been standard since Java 16. - Tests only enable refactoring when they’re behavioral. Tests that pin down implementation details — Mockito mocks for internal collaborators, assertions on private state, verifying that a helper was called N times — turn every refactor into a test rewrite. A test suite with >30% mock-based tests tends to be a ratchet against change, not a safety net for it. Prefer integration tests hitting a real DB (Testcontainers makes this fast enough) for anything load-bearing.
- TDD’s failure mode in Java codebases I’ve seen isn’t “tactical” — it’s mock-driven development. You test the code you wrote, not the behavior the system needs. The tests stay green through refactors that break production because they mocked the dependency that actually had the bug.
- Design patterns in Java specifically:
AbstractSingletonProxyFactoryBeanis the joke, but real codebases ship subtler versions — five-class “strategy patterns” for what should be aswitchexpression,BuilderBuilderclasses, event buses for synchronous calls between two classes. When in doubt, write the direct version first and extract a pattern only when the third caller shows up.
Chapter 20 — Designing for Performance
- Measure first. That gives you real targets and a baseline.
- Clean design and performance go together.
- Complex code is often slow because it does extra work. Clean code is usually fast enough. When it isn’t, find the hot path and make it simple.