Simple Design — Practice Tasks¶
Category: Craftsmanship Disciplines — Kent Beck's four rules for writing code that is no more complicated than it needs to be, in strict priority order.
10 graded hands-on tasks with full Python, Java, and Go solutions. Try each before expanding the solution. Each task names the rule(s) it exercises.
Table of Contents¶
- Task 1: Reveal Intent via Naming
- Task 2: Remove Real Duplication
- Task 3: Don't DRY Coincidental Similarity
- Task 4: Delete a Needless Abstraction
- Task 5: Evolve a Design Through All Four Rules
- Task 6: Apply the Rule of Three
- Task 7: Remove Speculative Generality
- Task 8: Resolve a Clarity-vs-DRY Conflict
- Task 9: Escape the Wrong Abstraction
- Task 10: Decide Emergent vs. Up-Front
- Practice Tips
Task 1: Reveal Intent via Naming¶
Rule: 2 (reveals intention). Goal: Rename cryptic identifiers and extract magic numbers so the code's purpose is obvious. Behavior must not change (Rule 1).
Given (Python):
Solution
### PythonDISCOUNT_PER_UNIT = 0.05
def discounted_price(base_price, units):
return base_price - (base_price * units * DISCOUNT_PER_UNIT)
Task 2: Remove Real Duplication¶
Rule: 3 (no duplication). Goal: The same knowledge (line total = price × quantity) is stated three times. Give it one home.
Given (Java):
double subtotal(List<Item> items) {
double s = 0;
for (Item i : items) s += i.price() * i.qty();
return s;
}
double tax(List<Item> items) {
double s = 0;
for (Item i : items) s += i.price() * i.qty() * 0.2; // line total duplicated
return s;
}
Solution
### Javadouble lineTotal(Item i) { return i.price() * i.qty(); } // ONE home
double subtotal(List<Item> items) {
return items.stream().mapToDouble(this::lineTotal).sum();
}
double tax(List<Item> items) {
return subtotal(items) * TAX_RATE; // tax built on subtotal
}
Task 3: Don't DRY Coincidental Similarity¶
Rule: 3 (no duplication — correctly). Goal: Two functions look identical but encode different rules. Recognize the coincidence and keep them separate.
Given (Python):
def shipping_fee(weight): return weight * 1.5
def handling_fee(weight): return weight * 1.5 # looks identical — merge?
Solution
### PythonSHIPPING_RATE_PER_KG = 1.5
HANDLING_RATE_PER_KG = 1.5 # SAME value today, DIFFERENT rule — keep separate
def shipping_fee(weight): return weight * SHIPPING_RATE_PER_KG
def handling_fee(weight): return weight * HANDLING_RATE_PER_KG
Task 4: Delete a Needless Abstraction¶
Rule: 4 (fewest elements). Goal: Remove a one-implementation interface and its indirection. Behavior unchanged.
Given (Go):
type Clock interface{ Now() time.Time }
type SystemClock struct{}
func (SystemClock) Now() time.Time { return time.Now() }
func timestamp(c Clock) string { return c.Now().Format(time.RFC3339) }
// SystemClock is the only implementation, and timestamp is called once with it.
Solution
### Go ### Java ### Python **Why:** The interface, the struct, and the parameter were speculative — added "in case we need a fake clock for tests." If a test seam is *actually* needed now, that's a present requirement and the interface is justified. If it isn't, Rule 4 says delete the indirection; reintroduce it the day a second clock (real or test fake) is real. *(Note: a test-time clock is a common, legitimate reason to keep this seam — judge by whether the need exists today.)*Task 5: Evolve a Design Through All Four Rules¶
Rules: 1 → 2 → 3 → 4. Goal: Take a broken first draft and walk it through every rule in order. State which rule each step satisfies.
Given (Python) — buggy and crude:
Solution
**Step 1 — pass the tests (Rule 1):**def fare(km, t):
if t == 1: return km * 2 # standard
if t == 2: return km * 3 # express
return km * 1 # economy (default)
RATE_PER_KM = {"economy": 1, "standard": 2, "express": 3}
def fare(distance_km, service_class):
rate = RATE_PER_KM[service_class]
return distance_km * rate
Task 6: Apply the Rule of Three¶
Rule: 3 + rule of three. Goal: You have two similar blocks. Decide whether to extract now — and when a third appears, do the extraction shaped by all three.
Given (Python) — two occurrences:
# occurrence 1
total_a = sum(x.amount for x in cart_a)
print(f"Cart A total: ${total_a:.2f}")
# occurrence 2
total_b = sum(x.amount for x in cart_b)
print(f"Cart B total: ${total_b:.2f}")
Solution
**At two occurrences:** *don't extract yet.* You can't see the abstraction's real shape — is the label always `"Cart X total"`? Always dollars? Always 2 decimals? Tolerate the duplication. **When a third appears (now you can see the invariants):** ### Pythondef print_cart_total(label, cart):
total = sum(x.amount for x in cart)
print(f"{label} total: ${total:.2f}")
print_cart_total("Cart A", cart_a)
print_cart_total("Cart B", cart_b)
print_cart_total("Cart C", cart_c)
Task 7: Remove Speculative Generality¶
Rule: 4 + YAGNI. Goal: Strip out flexibility added for an imagined future — unused parameters, config nobody sets, a hook nobody uses.
Given (Java):
String greet(String name, String locale, boolean formal,
Map<String,Object> extensions, GreetingHook hook) {
String g = formal ? "Good day, " : "Hi "; // locale, extensions, hook unused
return g + name;
}
// Every caller: greet(name, "en", false, Map.of(), null)
Solution
### Java ### Python ### Go **Why:** `locale`, `extensions`, and `hook` were never used — pure speculative generality. Every caller passed the same dead values. YAGNI says remove them; when localization or extension is a *real* requirement, add exactly what it needs (and shaped by the real need, not a guess). The signature shrinks to the two parameters that actually vary.Task 8: Resolve a Clarity-vs-DRY Conflict¶
Rules: 2 vs 3 (clarity wins). Goal: Removing duplication here would create a confusing multi-flag helper. Choose clarity.
Given (Python) — two methods share ~70% of code, but differ in meaningful ways:
# A "DRY" attempt someone proposed:
def render(self, mode, compact, redact, totals):
out = []
if mode == "summary":
out.append(self.title)
if not compact: out.append(self.subtitle)
elif mode == "detail":
for row in self.rows:
out.append(self._fmt(row, redact))
if totals: out.append(self._totals())
return "\n".join(out) # 4 params, 2 modes, hard to follow
Solution
### Pythondef render_summary(self, compact=False):
out = [self.title]
if not compact:
out.append(self.subtitle)
out.append(self._totals())
return "\n".join(out)
def render_detail(self, redact=False):
out = [self._fmt(row, redact) for row in self.rows]
out.append(self._totals())
return "\n".join(out)
# _totals() is the genuinely shared knowledge — it stays DRY.
String renderSummary(boolean compact) {
var out = new ArrayList<String>(List.of(title));
if (!compact) out.add(subtitle);
out.add(totals());
return String.join("\n", out);
}
String renderDetail(boolean redact) {
var out = rows.stream().map(r -> fmt(r, redact)).collect(toList());
out.add(totals());
return String.join("\n", out);
}
func (d Doc) RenderSummary(compact bool) string {
out := []string{d.Title}
if !compact { out = append(out, d.Subtitle) }
out = append(out, d.totals())
return strings.Join(out, "\n")
}
func (d Doc) RenderDetail(redact bool) string {
out := make([]string, 0, len(d.Rows))
for _, r := range d.Rows { out = append(out, d.fmt(r, redact)) }
out = append(out, d.totals())
return strings.Join(out, "\n")
}
Task 9: Escape the Wrong Abstraction¶
Rules: 2, 3, 4 (re-introduce duplication, then re-extract). Goal: A shared abstraction has accreted flags serving divergent callers. Inline it, simplify each caller, re-extract only what's truly shared.
Given (Python) — the wrong abstraction:
def notify(user, kind, urgent=False, digest=False, channel="email"):
if kind == "welcome":
subject = "Welcome!"
elif kind == "receipt":
subject = "Your receipt"
elif kind == "alert":
subject = ("URGENT: " if urgent else "") + "Account alert"
body = render(kind, user, digest)
send(channel, user, subject, body)
Solution
### Pythondef send_welcome(user):
send("email", user, "Welcome!", render_welcome(user))
def send_receipt(user, order):
send("email", user, "Your receipt", render_receipt(user, order))
def send_alert(user, urgent=False):
subject = ("URGENT: " if urgent else "") + "Account alert"
send("email", user, subject, render_alert(user))
# `send(...)` is the genuinely shared knowledge (transport) — it stays.
# The kind-switch, the unused `digest`, and the `channel` flag are gone.
Task 10: Decide Emergent vs. Up-Front¶
Concept: reversibility / one-way doors. Goal: For each decision, say whether to defer (YAGNI/emergent) or design up-front, and why.
Decisions: 1. The internal structure of an order-pricing module. 2. The JSON shape of a public webhook payload third parties consume. 3. Whether to put a repository interface between domain and SQL. 4. The on-disk format for a new event-log file. 5. The names and signatures of private helper methods.
Solution
| Decision | Verdict | Reasoning | |---|---|---| | 1. Internal pricing structure | **Emergent** | Reversible — refactor freely behind tests. Apply the four rules; don't over-design. | | 2. Public webhook payload | **Up-front** | One-way door — third parties depend on it; changing it breaks them. Design deliberately, version it. | | 3. Repository interface | **Mostly emergent, but watch reversibility** | If persistence might migrate (often a one-way door), the seam is cheap insurance. If you have a *present* need (a test fake or two stores), it's justified now; otherwise defer — but note storage migrations are costly. | | 4. On-disk event-log format | **Up-front** | One-way door — existing files must stay readable; a format change means a migration. Decide carefully, include a version field. | | 5. Private helper names/signatures | **Emergent** | Fully reversible (no external caller). Let them emerge and rename freely. | **Why:** The dividing line is *cost to change later*. Cheap-to-reverse decisions (1, 5) get YAGNI and emergent design. Expensive/irreversible decisions (2, 4) get deliberate up-front design. Decision 3 is the judgement call: weigh the *present* need against the irreversibility of the storage boundary. Applying YAGNI to a one-way door is the costliest mistake in simple design; applying up-front design to reversible internals is speculative generality.Practice Tips¶
- Always run the rules in order — correct, then clear, then DRY, then minimal. Never break a higher rule for a lower one.
- Before DRYing, ask: would a change to one force the same change to the other? If no, it's coincidence — keep them apart.
- Default to the rule of three for extraction; extract immediately only when the knowledge is provably identical.
- For every abstraction you add, name the present requirement that forces it. "Might need it later" → don't.
- To fix a wrong abstraction, inline first (re-introduce duplication), then re-extract only the genuinely shared knowledge.
- Check reversibility before invoking YAGNI: reversible → defer; one-way door → design up-front.
- Keep tests green throughout — they're what let you pursue clarity, DRY, and minimalism fearlessly.
← Interview · Craftsmanship Disciplines · Roadmap · Next: Find-Bug
In this topic