Bad Shortcuts Anti-Patterns — Refactoring Practice¶
Category: Development Anti-Patterns → Bad Shortcuts — convenience taken now, paid back many times later. Covers (collectively): Copy-Paste Programming · Magic Numbers / Strings · Hard Coding · Cargo Cult Programming · Pokémon Exception Handling · Stringly-Typed Programming
These are not "spot the smell" puzzles — find-bug.md does that. Here the code is shortcut-ridden but already working, and your job is to transform it into clean code without changing behavior. The skill on display is the process, not just the destination:
- Pin behavior first. Write a characterization test — a test that records what the code does now, bugs included. You are freezing behavior, not blessing it. (Replacing a swallowed exception is the one place where the point is to change behavior — there you pin the observable output, then deliberately widen what escapes.)
- Take small, reversible steps. One named refactoring at a time (Extract Function, Replace Magic Literal with Symbolic Constant, Introduce Configuration, …), tests green after each, commit.
- Separate structural from behavioral commits. A pure refactor preserves behavior; an error-strategy or config change is behavioral and gets its own reviewed commit.
Each worked solution names the canonical refactoring moves (Fowler's Refactoring, Feathers' Working Effectively with Legacy Code) so you build the vocabulary, not just the instinct.
How to use this file: read the "Before" code, write down the move sequence yourself before expanding the solution, then compare. The gap between your plan and the worked plan is where the learning is.
Table of Contents¶
| # | Exercise | Anti-pattern(s) | Lang | Key moves |
|---|---|---|---|---|
| 1 | Name the magic literals | Magic Numbers | Java | Replace Magic Literal with Symbolic Constant |
| 2 | Extract the copy-pasted block | Copy-Paste | Python | Extract Function, DRY |
| 3 | Leave the coincidental duplication alone | Copy-Paste (false positive) | Python | Restraint — do NOT merge |
| 4 | Hard-coded values → configuration | Hard Coding | Go | Introduce Configuration; secret handling |
| 5 | Clean up the cargo-culted snippet | Cargo Cult | Python | Justify-or-delete; read the docs |
| 6 | Catch the Pokémon exception | Pokémon Exceptions | Java | Replace Bare Catch with Typed Handling; preserve cause |
| 7 | Stringly-typed status → enum | Stringly-Typed | Java | Replace Type Code with Enum |
| 8 | Distinct value types for confusable strings | Stringly-Typed | Go | Introduce Value Type |
| 9 | Magic strings → enum dispatch | Magic Strings + Stringly-Typed | Python | Replace String Dispatch with Enum/Polymorphism |
| 10 | Copy-paste with a duplicated magic value | Copy-Paste + Magic Number | Java | Extract Function + Symbolic Constant |
| 11 | A deliberate typed-error strategy | Pokémon + Stringly-Typed errors | Python | Error classification; wrap-with-cause |
| 12 | Hard-coded business rules → config + value type | Hard Coding + Magic + Stringly-Typed | Go | Introduce Configuration + Value Type |
| 13 | The full shortcut cleanup | All six | Python | The whole toolbox, in order |
Exercise 1 — Name the magic literals¶
Anti-pattern: Magic Numbers / Strings. Goal: give every load-bearing literal a name that documents its meaning. Constraints: identical computed result; no behavior change; the constant's name must encode the concept, not the value.
// Before — what do 86400, 3, 0.08, and "3" mean?
boolean canRenew(Session s, User u) {
if (s.ageSeconds() > 86400) {
return false;
}
if (u.failedLogins() >= 3) {
return false;
}
if (u.statusCode().equals("3")) { // 3 = suspended
return false;
}
return true;
}
double finalPrice(double base) {
return base * 1.08; // 1.08 = ??
}
Refactored
**Move sequence** 1. **Characterize.** A table test: a session at 86399s and 86401s (boundary), a user at 2 and 3 failed logins, a suspended user, and a price computation. Snapshot the exact booleans and the price. 2. **Replace Magic Literal with Symbolic Constant** (Fowler), one literal at a time, tests green after each. Name the *concept*, not the number — `SECONDS_PER_DAY`, not `EIGHTY_SIX_FOUR_HUNDRED`. 3. The `"3"` status is two smells at once (magic *and* stringly-typed). Name it now as `STATUS_SUSPENDED`; Exercise 7 takes it the rest of the way to an enum.// After — each literal carries its meaning in its name.
static final long MAX_SESSION_SECONDS = 86_400; // one day
static final int MAX_FAILED_LOGINS = 3;
static final String STATUS_SUSPENDED = "3"; // TODO: promote to an enum (Ex. 7)
static final double SALES_TAX_RATE = 0.08;
boolean canRenew(Session s, User u) {
if (s.ageSeconds() > MAX_SESSION_SECONDS) return false;
if (u.failedLogins() >= MAX_FAILED_LOGINS) return false;
if (u.statusCode().equals(STATUS_SUSPENDED)) return false;
return true;
}
double finalPrice(double base) {
return base * (1 + SALES_TAX_RATE);
}
Exercise 2 — Extract the copy-pasted block¶
Anti-pattern: Copy-Paste Programming. Goal: collapse duplicated knowledge into one home. Constraints: preserve both return values exactly; the shared rule must have exactly one place to change.
# Before — the shipping rule is copied and will drift.
def price_for_member(items):
total = sum(i.price for i in items)
if total > 100:
shipping = 0.0
else:
shipping = 9.99
return round(total * 0.9 + shipping, 2) # 10% member discount
def price_for_guest(items):
total = sum(i.price for i in items)
if total > 100: # SAME rule, copied
shipping = 0.0
else:
shipping = 9.99
return round(total + shipping, 2)
Refactored
**Move sequence** 1. **Characterize.** Test each function at a sub-threshold cart, an above-threshold cart, and exactly at `100` (boundary). Snapshot both rounded totals. 2. **Verify the duplication is *knowledge*, not coincidence.** Ask: *if the free-shipping threshold changes, must it change for both member and guest?* Yes — it is one business rule. Safe to merge. (Contrast with Exercise 3.) 3. **Replace Magic Literal with Symbolic Constant** for the threshold and fee while they are still in one place to find. 4. **Extract Function** `shipping_for(total)` — the one home for the rule. Then **Extract Function** the shared `subtotal`, and unify the two entry points by parameterizing the discount.# After — the rule lives in exactly one function.
FREE_SHIPPING_THRESHOLD = 100
FLAT_SHIPPING_FEE = 9.99
MEMBER_DISCOUNT = 0.10
def shipping_for(total: float) -> float:
return 0.0 if total > FREE_SHIPPING_THRESHOLD else FLAT_SHIPPING_FEE
def price(items, *, member: bool) -> float:
total = sum(i.price for i in items)
discount = MEMBER_DISCOUNT if member else 0.0
return round(total * (1 - discount) + shipping_for(total), 2)
Exercise 3 — Leave the coincidental duplication alone¶
Anti-pattern: Copy-Paste — the false positive. Goal: recognize duplication that you must not merge, and make that decision explicit. Constraints: the two rules must remain independently changeable; no shared abstraction.
# Before — these LOOK identical. Should you DRY them?
def validate_username(s: str) -> bool:
return 3 <= len(s) <= 20
def validate_coupon_code(s: str) -> bool:
return 3 <= len(s) <= 20
Refactored
**Move sequence — the discipline of *not* refactoring** 1. **Characterize both** independently — a too-short, in-range, and too-long input for each. Two separate tests, deliberately not shared. 2. **Apply the divergence test** (Sandi Metz, *"the wrong abstraction"*): *if the username rule changes, must the coupon rule change too?* A username is a human identity constraint; a coupon code is a marketing/format constraint. They are unrelated rules that happen to share bounds *today*. The answer is **no** → the duplication is **coincidental**. 3. **Do not extract.** Merging into `validate_length(s, 3, 20)` couples two rules that will diverge (usernames may grow to 32; coupons may require exactly 8). The "fix" would force a flag parameter or a later painful split — the [over-DRY trap](middle.md). 4. **Make the *intent* explicit instead.** Replace the bare bounds with *named, per-domain* constants so the next reader sees two independent rules, not one duplicated one.# After — duplication kept on purpose; intent made explicit.
USERNAME_MIN, USERNAME_MAX = 3, 20 # human-identity rule
COUPON_MIN, COUPON_MAX = 3, 20 # marketing-format rule (independent of username)
def validate_username(s: str) -> bool:
return USERNAME_MIN <= len(s) <= USERNAME_MAX
def validate_coupon_code(s: str) -> bool:
return COUPON_MIN <= len(s) <= COUPON_MAX
Exercise 4 — Hard-coded values → configuration¶
Anti-pattern: Hard Coding. Goal: move environment-specific values out of source, treating secrets correctly. Constraints: the function signature stays usable; defaults must reproduce current behavior in dev; a committed secret must be rotated, not just moved.
// Before — credentials in source, a laptop-only path, an env-specific URL.
func newClient() *Client {
return &Client{
dsn: "postgres://admin:S3cr3t!@10.0.0.5:5432/prod", // secret in git history!
uploadDir: "/Users/alex/dev/app/uploads", // only on Alex's machine
apiBase: "https://api.prod.example.com", // env-specific
timeout: 5 * time.Second, // arguably fine to keep
}
}
Refactored
**Move sequence** 1. **Characterize.** A test that, with the dev-equivalent environment set, builds a `Client` with the same effective field values — this pins behavior across the config move. 2. **Classify each value on the config spectrum** (see [middle.md](middle.md)): the DSN is a **secret** (manager / injected env, never committed); `uploadDir` and `apiBase` are **per-environment** (env vars with sane dev defaults); `timeout` is a **stable tuning constant** (a named code constant is fine). 3. **Introduce Configuration**: a `Config` struct loaded from the environment at startup, with explicit defaults for non-secret values. Read it once; pass it in (dependency injection) rather than reaching into `os.Getenv` deep in the code. 4. **Handle the secret deliberately.** The DSN comes from `DATABASE_URL`, injected at runtime by the secrets manager. **Because the old credential is in git history, rotate it** — removing it from source does *not* un-leak it.// After — configuration loaded at the edge; secret never in source.
type Config struct {
DSN string // from secrets manager / injected env — NEVER a default in code
UploadDir string
APIBase string
Timeout time.Duration
}
const defaultTimeout = 5 * time.Second // stable; not env-specific
func LoadConfig() (Config, error) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
return Config{}, errors.New("DATABASE_URL is required") // fail fast; no fallback secret
}
return Config{
DSN: dsn,
UploadDir: getenvOr("UPLOAD_DIR", "./uploads"), // safe dev default
APIBase: getenvOr("API_BASE", "https://api.dev.example.com"), // safe dev default
Timeout: defaultTimeout,
}, nil
}
func newClient(cfg Config) *Client {
return &Client{dsn: cfg.DSN, uploadDir: cfg.UploadDir, apiBase: cfg.APIBase, timeout: cfg.Timeout}
}
func getenvOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
Exercise 5 — Clean up the cargo-culted snippet¶
Anti-pattern: Cargo Cult Programming. Goal: remove lines kept "because the example had them," keeping only what is justified. Constraints: the function's output and the file it reads must be unchanged; every surviving line must be defensible.
# Before — pasted from a blog; nobody knows why half of this is here.
import os
import time
import pandas as pd
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3" # copied; this code never imports TensorFlow
def load_active_users(path):
time.sleep(0.1) # "the tutorial had a sleep"
df = pd.read_csv(path)
df = df.copy() # "saw this somewhere, seemed safe"
df = df[df["active"] == True] # the only line that does real work
df = df.reset_index().reset_index() # reset twice "just in case"
return df
Refactored
**Move sequence — justify or delete** 1. **Characterize.** A fixture CSV with active and inactive users; snapshot the returned DataFrame's *content* (values), not its incidental index, so the test pins behavior the caller actually depends on. 2. **Interrogate each suspect line** — the cargo-cult test is *"what breaks if I remove this?"* Remove one line, run the test: - `os.environ["TF_CPP_MIN_LOG_LEVEL"]` → no TensorFlow anywhere in the project (`grep -rn tensorflow`); silences a warning that is never emitted. **Delete.** - `time.sleep(0.1)` → an artifact of a streaming tutorial; pure latency here. **Delete.** - `df.copy()` → `read_csv` already returns a fresh frame; the copy guards against a mutation that does not happen. **Delete.** - `.reset_index().reset_index()` → produces two spurious index columns; the second is unjustifiable and the *first* is only needed if a caller relies on a clean integer index. Check callers; here none do. **Replace with a single `reset_index(drop=True)`** to keep a clean index without junk columns. - `df[df["active"] == True]` → the actual purpose. **Keep**, and idiomatically simplify to `df[df["active"]]`. 3. **Remove now-unused imports** (`time`, `os`) — they were only there for the deleted lines. **What improved & how to verify.** Unexplained ritual (a TF env var, a sleep, a redundant copy, a double index reset) is gone; what remains is the one rule the function exists to express. **Verify**: the content-snapshot test is unchanged. The `.reset_index().reset_index()` → `reset_index(drop=True)` step is the one with a *behavioral* edge (it removes the spurious index columns the double-reset created); if any caller read those columns, that surfaces in their tests — which is exactly why you check call sites before deleting. The discipline: *if you cannot explain why a line is there, you cannot safely keep it.*Exercise 6 — Catch the Pokémon exception¶
Anti-pattern: Pokémon Exception Handling. Goal: replace a catch-everything-and-swallow block with deliberate, typed handling that preserves the diagnostic trail. Constraints: expected failures stay handled with the same user-facing result; bugs must now propagate loudly instead of vanishing; the original cause must be preserved when wrapping.
// Before — "gotta catch 'em all", then silence.
ChargeResult charge(Order order) {
try {
int amount = Integer.parseInt(order.amountText()); // may be malformed
gateway.charge(order.card(), amount); // may decline, may time out
return ChargeResult.ok();
} catch (Exception e) {
return ChargeResult.failed("could not charge"); // hides decline, timeout, AND bugs
}
}
Refactored
**Move sequence** 1. **Characterize the observable contract.** What does each *expected* failure currently return? A malformed amount, a declined card, and a gateway timeout all return `failed("could not charge")` today. Pin those three. Then add a *new* expectation that a programming bug (e.g. a `NullPointerException` inside `gateway`) **escapes** — that change is the entire purpose of this exercise. 2. **Classify the catchable exceptions.** Map each real failure to its type: `NumberFormatException` (bad input), `CardDeclinedException` (expected, recoverable), `GatewayTimeoutException` (transient). Everything else is a *bug* and must not be caught. 3. **Replace Bare Catch with Typed Handling**: one `catch` per type you can actually act on, ordered specific-first. Drop the `catch (Exception e)`. 4. **Preserve the cause when translating.** When you turn a low-level exception into a domain result or rethrow, chain the original (`new XException(msg, e)`), never discard it.// After — each catch handles a real, distinct failure; bugs propagate.
ChargeResult charge(Order order) {
int amount;
try {
amount = Integer.parseInt(order.amountText());
} catch (NumberFormatException e) {
return ChargeResult.failed("invalid amount: " + order.amountText());
}
try {
gateway.charge(order.card(), amount);
return ChargeResult.ok();
} catch (CardDeclinedException e) {
return ChargeResult.failed("card declined: " + e.reason()); // expected, recoverable
} catch (GatewayTimeoutException e) {
log.warn("gateway timeout for order {}", order.id(), e); // logged WITH the cause
throw new ChargeRetryableException("charge timed out", e); // wrap + preserve cause
}
// No catch(Exception): a NullPointerException from a bug now crashes loudly and is found.
}
Exercise 7 — Stringly-typed status → enum¶
Anti-pattern: Stringly-Typed Programming. Goal: replace a String status (a fixed value set) with an enum so the compiler rejects typos and lists valid values. Constraints: the persisted/wire representation ("active", "suspended", "closed") stays byte-identical for backward compatibility; public method names unchanged.
// Before — String for a fixed set; "actve" compiles, fails in prod.
class Account {
private String status; // "active" | "suspended" | "closed" — by convention only
void setStatus(String status) { this.status = status; }
boolean canTransact() { return status.equals("active"); }
boolean isSuspended() { return status.equals("suspended"); }
}
// elsewhere:
account.setStatus("suspeded"); // typo compiles cleanly; canTransact() silently wrong
Refactored
**Move sequence** 1. **Characterize.** Test each status's effect on `canTransact()`/`isSuspended()`, *and* the serialization round-trip (the string that gets persisted), since the wire format is a contract. 2. **Replace Type Code with Enum** (Fowler). Define `enum Status` with an explicit `wireValue` so the persisted strings stay identical — never rely on `Status.ACTIVE.name()` (`"ACTIVE"` ≠ `"active"`). 3. **Map at the boundaries only.** Convert string → enum on read (from DB/JSON) and enum → string on write; *inside* the domain, pass the enum, never the string. This is the half-fix to avoid: don't enum-ify and then immediately `status.toString().equals("active")` again. 4. Update the field and methods; the typo at the call site now fails to compile.// After — enum with stable wire values; illegal states unrepresentable.
enum Status {
ACTIVE("active"), SUSPENDED("suspended"), CLOSED("closed");
private final String wire;
Status(String wire) { this.wire = wire; }
String wireValue() { return wire; }
static Status fromWire(String s) {
for (Status v : values()) if (v.wire.equals(s)) return v;
throw new IllegalArgumentException("unknown status: " + s);
}
}
class Account {
private Status status;
void setStatus(Status status) { this.status = status; } // setStatus("suspeded") no longer compiles
boolean canTransact() { return status == Status.ACTIVE; }
boolean isSuspended() { return status == Status.SUSPENDED; }
}
// boundary mapping (persistence/JSON):
account.setStatus(Status.fromWire(row.get("status"))); // read
row.put("status", account.status().wireValue()); // write
Exercise 8 — Distinct value types for confusable strings¶
Anti-pattern: Stringly-Typed Programming (confusable parameters). Goal: stop two string parameters that mean different things from being silently swapped, using distinct value types. Constraints: runtime representation stays a string; the bug class (transposed arguments) becomes a compile error.
// Before — both are strings; transposing them compiles and corrupts data.
func SendWelcome(userID string, email string) error {
// ... uses userID for the audit log, email for delivery ...
audit.Record(userID)
return mailer.Send(email, "Welcome!")
}
// call site — arguments swapped; compiler says nothing:
SendWelcome("a@b.com", "u_42") // emails "u_42", audits "a@b.com"
Refactored
**Move sequence** 1. **Characterize.** A test asserting the *correct* call records the right user and mails the right address (using mocks/spies for `audit` and `mailer`). 2. **Introduce Value Type** for each concept. In Go, distinct named types over `string` are zero-cost and make the type checker reject mismatches: 3. **Tighten the constructors** if there are invariants worth enforcing (e.g. `NewEmail` validates the `@`), so an invalid `Email` cannot exist. Keep this optional and minimal — the primary win is the distinctness. 4. **Change the signature** to take the value types; update call sites. The transposed call now fails to compile.// After — distinct types; swapping is a compile error.
type UserID string
type Email string
func NewEmail(s string) (Email, error) {
if !strings.Contains(s, "@") {
return "", fmt.Errorf("invalid email: %q", s)
}
return Email(s), nil
}
func SendWelcome(id UserID, to Email) error {
audit.Record(string(id))
return mailer.Send(string(to), "Welcome!")
}
// call site — transposition no longer compiles:
SendWelcome(UserID("u_42"), Email("a@b.com"))
// SendWelcome(Email("a@b.com"), UserID("u_42")) // ← compile error: types don't match
Exercise 9 — Magic strings → enum dispatch¶
Anti-pattern: Magic Strings + Stringly-Typed (string-based dispatch). Goal: replace an if/elif ladder over magic string literals with enum-keyed dispatch (or polymorphism). Constraints: identical computed results; an unknown type still raises the same error; behavior open for extension without editing the dispatcher.
# Before — magic strings drive a branching ladder; a typo is a runtime surprise.
def area(shape: str, **dims) -> float:
if shape == "circle":
return 3.14159 * dims["r"] ** 2
elif shape == "square":
return dims["side"] ** 2
elif shape == "rect":
return dims["w"] * dims["h"]
else:
raise ValueError(f"unknown shape: {shape}")
area("circel", r=2) # typo → ValueError at runtime, far from the call
Refactored
**Move sequence** 1. **Characterize.** Test each shape's area, the magic constant `3.14159`, and the unknown-shape error. Snapshot the floats and the exception. 2. **Replace Magic Literal with Symbolic Constant** for `3.14159` → use `math.pi` (the magic number was also slightly *wrong*; if the old imprecise value is a hard contract, keep it as a named constant and note the discrepancy in the test). 3. **Replace String Dispatch with an Enum** (`ShapeKind`) so the set of valid shapes is closed and discoverable. 4. **Replace Conditional with a Lookup Table** keyed by the enum — adding a shape becomes adding one entry, not editing the dispatcher.# After — enum-keyed table; the dispatcher never changes again.
import math
from enum import Enum
class ShapeKind(Enum):
CIRCLE = "circle"
SQUARE = "square"
RECT = "rect"
_AREA = {
ShapeKind.CIRCLE: lambda d: math.pi * d["r"] ** 2,
ShapeKind.SQUARE: lambda d: d["side"] ** 2,
ShapeKind.RECT: lambda d: d["w"] * d["h"],
}
def area(shape: ShapeKind, **dims) -> float:
return _AREA[shape](dims)
# boundary parse from an external string keeps the old error shape:
def area_from_name(name: str, **dims) -> float:
try:
kind = ShapeKind(name)
except ValueError:
raise ValueError(f"unknown shape: {name}")
return area(kind, **dims)
Exercise 10 — Copy-paste with a duplicated magic value¶
Anti-pattern: Copy-Paste + Magic Number (the two cluster). Goal: extract duplicated logic and the magic value it carries, so a rate change is a single edit. Constraints: same totals; the tax rate must live in exactly one place.
// Before — the tax rate 0.08 is copy-pasted into three methods.
class Invoice {
double subtotalWithTax(List<Item> items) {
double sub = 0;
for (Item i : items) sub += i.price() * i.qty();
return sub + sub * 0.08; // copy 1
}
double estimateTax(double amount) {
return amount * 0.08; // copy 2
}
double grandTotal(double subtotal, double shipping) {
return subtotal + shipping + subtotal * 0.08; // copy 3
}
}
Refactored
**Move sequence** 1. **Characterize.** Test all three methods with representative inputs; snapshot the doubles. Include a case where the rate matters (non-zero amount). 2. **Replace Magic Literal with Symbolic Constant**: introduce `SALES_TAX_RATE = 0.08` *once*. Replace all three `0.08` occurrences. Tests green — this alone removes the duplicated *value*. 3. **Extract Function** `tax(double base)` so the *computation* (not just the constant) has one home; the three methods now call it. This removes the duplicated *knowledge*. 4. Confirm the three call sites read identically to before, modulo the extracted call.// After — one rate, one tax computation, three callers.
class Invoice {
private static final double SALES_TAX_RATE = 0.08;
private static double tax(double base) {
return base * SALES_TAX_RATE;
}
double subtotalWithTax(List<Item> items) {
double sub = 0;
for (Item i : items) sub += i.price() * i.qty();
return sub + tax(sub);
}
double estimateTax(double amount) {
return tax(amount);
}
double grandTotal(double subtotal, double shipping) {
return subtotal + shipping + tax(subtotal);
}
}
Exercise 11 — A deliberate typed-error strategy¶
Anti-pattern: Pokémon Exceptions + stringly-typed error signaling. Goal: turn a swallow-everything handler that signals failure via magic strings into a classified, typed error strategy that preserves causes and distinguishes recoverable failures from bugs. Constraints: the caller's observable outcomes for expected failures stay the same; bugs propagate; causes are chained.
# Before — bare except, then a stringly-typed "error code" return.
def process(body: str):
try:
data = json.loads(body)
user = db.get(data["id"])
charge(user, data["amount"])
return "OK"
except Exception:
return "ERR" # which error? bad JSON? missing user? a bug? nobody can tell
Refactored
**Move sequence** 1. **Characterize the observable outcomes.** Today: malformed JSON → `"ERR"`; missing `id` key → `"ERR"`; unknown user → `"ERR"`; declined charge → `"ERR"`; *and a bug also → `"ERR"`*. Pin the *expected* failures; the bug-swallowing is what we are removing. 2. **Classify failures.** Bad JSON and a declined charge are *expected and recoverable*; a missing user is a *real domain condition* (handle explicitly); a `KeyError`/`TypeError` from malformed-but-valid-JSON or a bug must *propagate*. 3. **Replace Bare Catch with Typed Handling**, catching one specific type per recoverable case, near where you can act. 4. **Replace the stringly-typed return** with a small result type (or distinct exceptions) so callers branch on a type, not a magic `"ERR"`/`"OK"` string. **Chain the cause** when translating.# After — classified, typed, cause-preserving.
from dataclasses import dataclass
@dataclass
class Ok: ...
@dataclass
class Rejected: # expected, recoverable — carries a reason
reason: str
def process(body: str) -> Ok | Rejected:
try:
data = json.loads(body)
except json.JSONDecodeError as e:
raise InvalidRequest("malformed JSON") from e # expected; cause preserved
if "id" not in data or "amount" not in data:
return Rejected("missing id or amount") # domain condition, handled explicitly
user = db.get(data["id"])
if user is None:
return Rejected("unknown user") # domain condition
try:
charge(user, data["amount"])
except PaymentDeclined as e:
return Rejected(f"declined: {e.reason}") # expected, recoverable
# any other exception (a bug) propagates to the boundary and is logged with a trace
return Ok()
Exercise 12 — Hard-coded business rules → config + value type¶
Anti-pattern: Hard Coding + Magic Numbers + Stringly-Typed (combined). Goal: lift environment-varying business rules out of source, name the magic, and replace a stringly-typed tier with a value type. Constraints: dev defaults reproduce current behavior; backward-compatible config keys; the tier set is closed.
// Before — magic thresholds, a stringly-typed tier, and an env-specific limit baked in.
func DiscountFor(tier string, cartTotal float64) float64 {
if tier == "gold" {
if cartTotal > 500 { // 500 = ?? and differs by region/promo
return cartTotal * 0.15 // 0.15 = ??
}
return cartTotal * 0.10
} else if tier == "silver" {
return cartTotal * 0.05
}
return 0 // "bronze" or anything unrecognized
}
Refactored
**Move sequence** 1. **Characterize.** Test each tier at/above/below the `500` boundary; snapshot the discounts. Include an unrecognized tier (currently → `0`). 2. **Replace Magic Literal with Symbolic Constant** for the rates and threshold, *in place* first (pure rename, tests green). 3. **Classify the values.** The discount rates are stable product rules → constants. The `500` bulk threshold "differs by region/promo" → it is **configuration**, not a constant. Introduce a `DiscountConfig` with a default of `500` (backward-compatible) overridable per environment. 4. **Replace Type Code with a value type** for `tier` (`Tier` enum-like constants) so an unrecognized string can't silently fall through; map the external string at the boundary, preserving the old default-to-zero for unknowns if that is the contract.// After — named rules, configurable threshold, closed tier set.
type Tier int
const (
Bronze Tier = iota
Silver
Gold
)
func TierFromString(s string) (Tier, bool) {
switch s {
case "gold":
return Gold, true
case "silver":
return Silver, true
case "bronze":
return Bronze, true
}
return Bronze, false // unknown → Bronze (0% discount), preserving old behavior
}
const (
goldHighRate = 0.15
goldBaseRate = 0.10
silverRate = 0.05
)
type DiscountConfig struct {
BulkThreshold float64 // per-environment; default 500
}
func LoadDiscountConfig() DiscountConfig {
return DiscountConfig{BulkThreshold: getenvFloat("BULK_THRESHOLD", 500)}
}
func DiscountFor(tier Tier, cartTotal float64, cfg DiscountConfig) float64 {
switch tier {
case Gold:
if cartTotal > cfg.BulkThreshold {
return cartTotal * goldHighRate
}
return cartTotal * goldBaseRate
case Silver:
return cartTotal * silverRate
default:
return 0
}
}
Exercise 13 — The full shortcut cleanup¶
Anti-pattern: all six at once. Goal: refactor one function exhibiting copy-paste, magic literals, a hard-coded URL, a cargo-culted line, a Pokémon catch, and a stringly-typed mode. Constraints: preserve the observable result for expected inputs; tackle the shortcuts in a safe order; secrets/URLs out of source.
# Before — a bit of every shortcut.
import os, time, requests
def fetch_report(mode, account_id):
time.sleep(0.2) # cargo cult: copied from a retry tutorial
try:
if mode == "json": # stringly-typed mode + magic strings
url = "https://api.prod.example.com/v1/report" # hard-coded, env-specific
r = requests.get(url, params={"id": account_id}, timeout=30) # magic 30
data = r.json()
return {"id": data["id"], "score": data["score"] * 1.1} # magic 1.1
elif mode == "csv":
url = "https://api.prod.example.com/v1/report" # SAME url copy-pasted
r = requests.get(url, params={"id": account_id, "fmt": "csv"}, timeout=30)
return r.text
else:
return None
except Exception: # Pokémon: swallows everything
return None
Refactored
**Move sequence — order matters: isolated, low-risk moves first; behavioral changes last and separately** 1. **Characterize.** Mock `requests.get`. Pin: `json` mode returns `{"id", "score"*1.1}`; `csv` mode returns the body text; unknown mode → `None`. Note the current swallow-all → `None` on *any* error; that is the behavior we will deliberately narrow. 2. **Remove the cargo-culted `time.sleep(0.2)`.** It is leftover from a retry example; nothing here retries. `grep` confirms no dependency. Delete (own structural commit). Remove the now-unused `time`/`os` imports. 3. **Replace Magic Literals with Symbolic Constants** — `REQUEST_TIMEOUT = 30`, `SCORE_MULTIPLIER = 1.1`. Pure rename. 4. **Extract Function** for the duplicated request (`_get_report`), curing the copy-pasted URL/call. 5. **Introduce Configuration** for the base URL (`API_BASE` env var, dev default), curing the hard-coding; the URL is now built from config in one place. 6. **Replace Type Code with an Enum** for `mode` (`ReportFormat`), curing the stringly-typed dispatch; map the external string at the boundary, preserving unknown → `None` if that is the contract. 7. **Replace Bare Catch with Typed Handling** *last* and in its own behavioral commit — catch `requests.Timeout`/`requests.HTTPError` where recoverable, chain the cause, and let bugs propagate.# After — every shortcut addressed; behavior preserved for expected inputs.
import os
from enum import Enum
import requests
REQUEST_TIMEOUT = 30 # seconds
SCORE_MULTIPLIER = 1.1
class ReportFormat(Enum):
JSON = "json"
CSV = "csv"
def _api_base() -> str:
return os.getenv("API_BASE", "https://api.dev.example.com") # configurable; safe dev default
def _get_report(account_id, *, params=None):
url = f"{_api_base()}/v1/report"
return requests.get(url, params={"id": account_id, **(params or {})}, timeout=REQUEST_TIMEOUT)
def fetch_report(fmt: ReportFormat, account_id):
try:
if fmt is ReportFormat.JSON:
data = _get_report(account_id).json()
return {"id": data["id"], "score": data["score"] * SCORE_MULTIPLIER}
# fmt is ReportFormat.CSV (enum makes this exhaustive):
return _get_report(account_id, params={"fmt": "csv"}).text
except requests.Timeout as e:
raise ReportUnavailable("report request timed out") from e # recoverable; cause kept
except requests.HTTPError as e:
raise ReportUnavailable("upstream error") from e # cause kept
# a KeyError (missing "score") or any bug now propagates and gets fixed
def fetch_report_by_name(mode: str, account_id):
try:
fmt = ReportFormat(mode)
except ValueError:
return None # unknown mode → None (preserved)
return fetch_report(fmt, account_id)
Refactoring discipline — the recap¶
Every exercise above runs the same loop. Internalize it and the specific moves become mechanical:
green → one named refactoring → green → commit → repeat
(behavioral change — error strategy, config, precision fix — gets its OWN commit)
- Characterize before you change. A test that pins current behavior (bugs and all) is the seatbelt. For the Pokémon-exception exercises the point is to change behavior — there you pin the observable expected-failure outcomes first, then deliberately widen what escapes, and label the commit behavioral.
- Small, reversible steps. Name the constant, run tests, commit. Extract the function, run tests, commit. If green turns red, your last step is the suspect — revert it.
- Separate structural from behavioral commits. Naming a magic value or extracting a duplicate is structural and must preserve output byte-for-byte (watch algebraic rewrites like
base*1.08→base*(1+0.08)and3.14159→math.pi). Changing an error strategy, adding validation, or introducing config is behavioral — its own reviewed commit. - Name the move. "Replace Magic Literal with Symbolic Constant," "Extract Function," "Introduce Configuration," "Replace Type Code with Enum," "Introduce Value Type," "Replace String Dispatch with Enum/Polymorphism," "Replace Bare Catch with Typed Handling." A named move is a known-safe, often IDE-automatable transformation — not an improvisation.
- Restraint is a refactoring too. Exercise 3's correct answer was not to merge. DRY couples what it touches; only merge knowledge that must change together.
- Secrets are special and irreversible. Moving a credential out of source does not un-leak it — rotate it and add a secret scanner. This is the one shortcut whose cost is catastrophic.
| Move | Cures | Exercises |
|---|---|---|
| Replace Magic Literal with Symbolic Constant | Magic Numbers / Strings | 1, 9, 10, 12, 13 |
| Extract Function / DRY | Copy-Paste | 2, 10, 13 |
| Restraint — recognize coincidental duplication | Copy-Paste (false positive) | 3 |
| Introduce Configuration (+ secret rotation) | Hard Coding | 4, 12, 13 |
| Justify-or-delete; read the docs | Cargo Cult | 5, 13 |
| Replace Bare Catch with Typed Handling (+ chain cause) | Pokémon Exceptions | 6, 11, 13 |
| Replace Type Code with Enum | Stringly-Typed | 7, 9, 12, 13 |
| Introduce Value Type | Stringly-Typed (confusable values) | 8 |
| Replace String Dispatch with Enum/Polymorphism | Magic Strings + Stringly-Typed | 9, 13 |
Related Topics¶
tasks.md— guided exercises building these moves from scratch.find-bug.md— the spotting counterpart: identify the shortcut, don't fix it.junior.md·middle.md·senior.md— recognize → countermove → refactor-at-scale.- Refactoring → Refactoring Techniques — the mechanical catalog for every move named above.
- Refactoring → Code Smells — Duplicate Code, Magic Number, Primitive Obsession at the smell level.
- Clean Code → Error Handling — the target state for the Pokémon-exception exercises.
- Clean Code → Meaningful Names — naming away magic values.
- DRY Principle — and its limit, the coincidental-duplication trap (Exercise 3).
- Secrets Management — handling and rotating credentials (Exercise 4).
- Over-Engineering — Soft Coding, the over-correction to avoid when introducing configuration.
- Bad Structure — the sibling refactoring file; same discipline, different anti-patterns.
In this topic