Skip to content

Instantly share code, notes, and snippets.

@tadas-subonis
Last active March 13, 2026 20:46
Show Gist options
  • Select an option

  • Save tadas-subonis/60f6f5de71f8b3d142e85e2488ec2c91 to your computer and use it in GitHub Desktop.

Select an option

Save tadas-subonis/60f6f5de71f8b3d142e85e2488ec2c91 to your computer and use it in GitHub Desktop.
GOOS Cheatsheet — Growing Object-Oriented Software, Guided by Tests (Typst source)
#set document(title: "GOOS Cheatsheet", author: "Freeman & Pryce")
#set page(
paper: "a4",
margin: (top: 1.8cm, bottom: 1.8cm, left: 1.5cm, right: 1.5cm),
)
#set text(font: "Noto Sans", size: 8.5pt, lang: "en")
#set par(leading: 0.6em, justify: false, spacing: 0.5em)
#set list(indent: 8pt, body-indent: 4pt, spacing: 0.3em)
#set enum(indent: 8pt, body-indent: 4pt, spacing: 0.3em)
#set raw(block: true)
#show raw.where(block: true): it => block(
fill: rgb("#f5f5f5"),
inset: (x: 8pt, y: 6pt),
radius: 3pt,
width: 100%,
text(size: 7.5pt, font: "Noto Sans Mono", it)
)
#show heading.where(level: 1): it => {
v(1.4em, weak: true)
text(size: 9pt, weight: "bold")[★ #upper(it.body)]
v(0.1em, weak: true)
line(length: 100%, stroke: 0.5pt + black)
v(0.4em, weak: true)
}
#show heading.where(level: 2): it => {
v(0.6em, weak: true)
text(size: 8.5pt, weight: "bold")[▸ #it.body]
v(0.2em, weak: true)
}
#let note(body) = block(
width: 100%,
spacing: 4pt,
inset: (x: 7pt, y: 5pt),
stroke: (left: 2pt + rgb("#555")),
fill: rgb("#f8f8f8"),
body
)
#let side(left, right) = grid(
columns: (1fr, 1fr),
gutter: 8pt,
block(stroke: 0.5pt + rgb("#ccc"), inset: (x: 7pt, y: 5pt), radius: 2pt, width: 100%, left),
block(stroke: 0.5pt + rgb("#ccc"), inset: (x: 7pt, y: 5pt), radius: 2pt, width: 100%, right),
)
// ── Title ─────────────────────────────────────────────────────────────────────
#align(center)[
#text(size: 14pt, weight: "bold")[Growing Object-Oriented Software, Guided by Tests]
#linebreak()
#text(size: 9pt, style: "italic")[Freeman & Pryce — London School TDD Reference]
]
#v(0.5em)
#line(length: 100%, stroke: 1pt)
#v(0.6em)
#columns(2, gutter: 1.5em)[
// ─────────────────────────────────────────────────────────────────────────────
= Core Idea
Software dev is a *learning process* — you can't know the right design upfront. TDD gives nested feedback loops (seconds → weeks) to discover design incrementally. Tests aren't just verification. They're a *design tool* that shapes your code. This is the "London school" of TDD: use tests to discover design through object collaboration.
#note[*Golden Rule:* Never write new functionality without a failing test.]
= The Two Loops
*Outer loop* — acceptance tests, drives features end-to-end, measures real progress.
*Inner loop* — unit tests, drives object design, runs in seconds.
```
Acceptance Test (feature goal)
Unit Test (object behavior)
↓ write minimal code
↓ refactor
↑ repeat until AT passes
Next feature
```
= Walking Skeleton
Before any features, build the thinnest end-to-end slice that actually runs:
`automated build → deploy → test via external interface`
Show one value on screen, send one message. Then grow features through it.
#side(
[*Instead of:*\ — build DB layer\ — build service\ — build UI\ — integrate later],
[*Build first:*\ `UI → Service → DB`\ Works, does almost nothing. Grow features through it.]
)
- Avoids big-bang integration · proves architecture early
- Forces real system boundaries · front-loads risk (correct!)
- *Not* Big Design Up Front — reserve the right to change
Projects with late integration start calm and end in chaos. Teams that skip this often can't deploy when it matters.
= Five Key Practices
== Practice 1 — Write Acceptance Tests First
Write a failing acceptance test describing user behavior → make it fail → implement using smaller unit tests.
```
auction.placeBid(100)
expect auction.lastBid == 100
```
Write in *domain language*, not technology. If the protocol changes FTP→HTTP, acceptance tests shouldn't break. Test through external interfaces only — never call internal methods.
Separate *progress tests* (failing = work to do) from *regression tests* (must stay green). Start with simplest success case, not error handling.
== Practice 2 — Tell, Don't Ask
Tell objects what to do — don't pull their state and decide for them.
#side(
[*Bad — data pulling:*\ ```
if (order.getCustomer()
.getBalance() > price)
order.getCustomer()
.withdraw(price)
```],
[*Good — tell:*\ ```
customer.purchase(price)
```\ \ Hides structure.\ Reduces coupling.]
)
Replace `obj.getA().getB().doC()` with a single intention-revealing call. Name the interaction. Exception: asking for values and collections is fine.
== Practice 3 — Test Behavior, Not Methods
Tests should verify *observable behavior*, not internal implementation.
#side(
[*Brittle — method:*\ `test calculateTax()`],
[*Resilient — behavior:*\ `test order.totalPrice()`]
)
Name tests to describe scenarios: `rejectsOutsideBusinessHours()`, not `testBidAccepted()`. Behavior-level tests survive refactoring.
== Practice 4 — Listen to the Tests
When tests become hard to write, your design is wrong. Don't fight the test — listen to it.
#table(
columns: (1fr, 1fr),
stroke: 0.5pt + rgb("#ccc"),
inset: 5pt,
fill: (_, y) => if y == 0 { rgb("#eee") } else { white },
table.header([*Symptom*], [*Design problem*]),
[Hard to create object], [too many dependencies],
[Need many mocks], [object doing too much],
[Complex setup], [poor boundaries],
[Hard assertions], [unclear behavior],
)
== Practice 5 — Start Every Feature With a Failing Test
Full TDD cycle:
+ Write a failing *acceptance test* in domain language
+ Write a failing *unit test* for the next small behavior
+ Write *simplest code* to pass — no gold plating
+ *Refactor* — improve structure, keep tests green. Not optional.
+ Repeat 2–4 until the acceptance test passes
= Three Deeper Ideas
== 1. Collaboration over Data Structures
Most devs design around data models (`User`, `Order`, `Product`) → anemic models and procedural services. OO systems are *networks of communicating objects*. Behavior emerges from interactions. Design roles and messages — not entities.
#side(
[*Procedural — exposes data:*\ ```
OrderService
getUser()
checkInventory()
chargePayment()
```],
[*OO — sends messages:*\ ```
order → PaymentGateway
order → Inventory
order → Shipment
```]
)
Conceptual ancestor of: microservices, actor systems, event-driven arch, DDD aggregates.
== 2. Interfaces Emerge From Tests
Don't design APIs first. Write tests describing collaboration — interfaces emerge naturally.
```
// write test:
order.submit()
// → sends payment request
// → reserves inventory
// discover while writing:
PaymentGateway, InventoryService, OrderRepository
// → now define the interfaces
```
Avoids speculative abstractions. Produces smaller, usage-driven APIs. Foundation of hexagonal architecture and DDD.
== 3. Value World vs. Object World
*Values* — immutable, represent facts, no identity: `Money`, `Price`, `Date`, `Quantity`
*Objects* — have identity, behavior, change over time: `Order`, `Auction`, `Session`
Confusing these leads to bad design. Modern patterns trace directly to this distinction: DDD Value Objects, immutable data in FP, event sourcing event semantics. Benefits: safer concurrency, easier testing, fewer bugs.
= External vs. Internal Quality
#side(
[*External quality*\ Does the system work for users? Functional, reliable, responsive. Measured by acceptance tests.],
[*Internal quality*\ Can devs understand and change the code safely? Loosely coupled, cohesive. Driven by unit tests.]
)
If a class is hard to unit-test, it's probably tightly coupled, has hidden dependencies, or unclear responsibilities. Don't work around it with bytecode tricks — *fix the design*.
= OO Design Rules
*Separation of concerns* — gather code that changes for the same reason. Isolate domain from infrastructure (ports & adapters / anticorruption layer).
*No And's, Or's, or But's* — describe an object without conjunctions. If you can't — split it into collaborators.
*Composite simpler than its parts* — the composed object's API must be simpler than all its internals combined. (2 clock hands, dozens of gears inside.)
*Context independence* — objects shouldn't know where they live. Pass in everything they need. A class using terms from multiple domains may be violating this.
*Values vs. Objects* — values are immutable, no identity (`Money`, `Price`, `Date`). Objects have identity, mutable state, communicate via messages (`Order`, `Session`). Define domain value types even if small — they find bugs and attract behavior.
*Higher abstraction* — program by combining meaningful components, not by manipulating variables and control flow directly.
= Object Peer Stereotypes
*Dependencies* — required services. Must be injected via constructor. No valid object without them.
*Notifications* — fire-and-forget peers. Object doesn't know or care who listens. Default to null object or empty collection.
*Adjustments* — policy/strategy objects that tune behavior. Can default to common values, changed later.
= How Objects Are Born
*Breaking out* — object too big or test too hard to read → split into smaller collaborators, unit-test separately. Sometimes roll back and reimplement clean.
*Budding off* — need a new service → define the interface from the client's perspective ("on-demand design"), mock it in tests, implement later. Ask: "If this worked, who would know?"
*Bundling up* — cluster always used together → wrap in a new composite. Name it (naming forces understanding). Test it directly. Mock it in callers' tests.
= Only Mock Types You Own
Don't mock third-party APIs. You don't understand them well enough to simulate correctly, and you can't act on design feedback about code you can't change.
+ Write a thin *adapter layer* in your domain terms
+ *Mock your adapters* in unit tests
+ Write *integration tests* against the real library to verify your understanding
= Design Smell Catalog
*Bloated constructor* — args always used together? Bundle into new object. Separate dependencies (ctor) from adjustments/notifications (setters).
*Confused object* — can't describe without "and"? Mixes responsibilities. Split it. Test suite slices with no overlap = time to split the class too.
*Too many dependencies* — too many responsibilities, or some args are adjustments that can default.
*Too many expectations* — mock setup massive? Object orchestrates too much. Distinguish stubs (`allowing`) from expectations (`oneOf`). Tell the nearest object to do the work.
*Can't replace an object* — singletons, `new Date()`, statics are hidden dependencies. Inject them explicitly. (Inject a `Clock`, not `new Date()`.)
*Logging everywhere* — separate support logging (user-facing, test-driven) from diagnostic scaffolding. Extract a notification interface. Test it.
*Mocking concrete classes* — missing a role abstraction. Extract an interface — naming it discovers domain concepts. (`CdPlayer` → `ScheduledDevice`)
*Mocking values* — just construct instances. Use test data builders for complex objects.
= Modern Architecture Mapping
#table(
columns: (1fr, 1fr),
stroke: 0.5pt + rgb("#ccc"),
inset: 5pt,
fill: (_, y) => if y == 0 { rgb("#eee") } else { white },
table.header([*Modern concept*], [*Origin in GOOS*]),
[Microservices], [Object collaboration],
[Hexagonal / ports & adapters], [Role-based interfaces from tests],
[Event-driven systems], [Message-based object design],
[DDD Value Objects], [Value vs. object separation],
[Clean architecture], [Dependency direction from tests],
)
= Writing Good Tests
- Names describe features, not methods: `holdsItemsInOrder`, not `testAdd`. Include scenario + expected result.
- Write *backwards*: name → target call → expectations → setup
- *Test data builders*: safe defaults + chainable `with*()` methods\
`anOrder().from(aCustomer().withNoPostcode()).build()`
- Test for *information, not representation* — assert behavior, not exact string format
- *One coherent feature per test* — if it asserts unrelated things, split it
- Let exceptions propagate — no try/catch unless asserting on the exception
- Name literals: `UNUSED_CHAT = null`, `INVALID_ID = 666`
- *Precise assertions*: don't over-specify (brittle) or under-specify (false confidence)
= Applying to Legacy Code
- Start by automating *build and deploy*, then add end-to-end tests for areas you need to change
- Safest first test = simplest path through the system; build infrastructure before tackling hard functionality
- Introduce unit tests as you add features; refactor and inject dependencies as you go
- Risky to rework without tests — but also risky to leave poor structure baking in. The longer you wait, the harder cleanup becomes.
= Coding Style Notes
*Between objects* — message-passing style. Tell, don't ask. Communicate through roles and interfaces.
*Within an object* — functional style. Build up behavior from methods and values with no side effects. Immutability within a class leads to safer code.
*Small methods to express intent* — even tiny methods are worth it if they make the code read naturally. Helper methods in tests especially.
*Interface ≠ protocol* — an interface describes whether components will fit together. A protocol describes whether they will work together. Tests with mocks make protocols visible.
*Code isn't sacred* — if a spike taught you what to do, roll it back and reimplement cleanly. The second time is faster.
= Practices Checklist
*Before a feature* — failing acceptance test in domain language.\
*Before a class* — failing unit test for this scenario.\
*After green* — refactor: names, extract methods, simplify.\
*New dependency* — inject via constructor; if bloated, extract a hidden concept.\
*Third-party lib* — adapter → mock adapter → integration test real lib.\
*Hard to test* — stop. Fix the design. Don't fight the test.\
*Getter chain* — replace with one intention-revealing method. Name the interaction.\
*Building a composite* — API must be simpler than sum of its parts.\
*Naming a test* — describe scenario + expected outcome, not the method called.
#note[*The real takeaway:* The book isn't about testing. It's about using tests as a tool for designing clean, decoupled object systems. When a test is hard to write, don't fight it — listen to it. The pain points at a design problem.]
] // end columns
#v(0.4em)
#line(length: 100%, stroke: 0.5pt + rgb("#aaa"))
#align(center)[#text(size: 7pt, fill: rgb("#aaa"), style: "italic")["Growing Object-Oriented Software, Guided by Tests" — Freeman & Pryce (Addison-Wesley, 2009). Read the book for the full Auction Sniper walkthrough.]]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment