Coupling & State Anti-Patterns — Exercises¶
Category: Design Anti-Patterns → Coupling & State — hands-on practice severing the wires that make modules know and share too much. Covers (collectively): Singletonitis · Circular Dependency · Action at a Distance · Hidden Dependencies · Sequential Coupling
These are fix-it exercises, not recognition quizzes. Each one gives you a problem statement, starting code (in Go, Java, or Python — the language varies on purpose), acceptance criteria, and a collapsible solution. The goal is to make the change: inject a global singleton, break an import cycle, surface a hidden dependency, make spooky shared state explicit and immutable, and encode a call-order rule so the compiler refuses the misuse.
The unifying idea: a dependency the type system cannot see is a dependency you cannot test, reason about, or change safely. Every cure here pulls a hidden coupling out into the open — a constructor parameter, an interface, a return value, a type — where it can be named, replaced, and verified.
How to use this file. Read the problem, try it in your editor before opening the solution, then compare. The "why it's better" note under each solution matters more than the diff. Refer back to
junior.mdfor the shapes andmiddle.mdfor the countermoves.
Table of Contents¶
| # | Exercise | Anti-pattern(s) | Lang | Difficulty |
|---|---|---|---|---|
| 1 | Inject the config singleton | Singletonitis | Python | ★ easy |
| 2 | Surface the hidden clock | Hidden Dependencies | Go | ★ easy |
| 3 | Make the spooky total explicit | Action at a Distance | Python | ★ easy |
| 4 | Break the import cycle with an interface | Circular Dependency | Go | ★★ medium |
| 5 | Make the file dependency explicit | Hidden Dependencies | Java | ★★ medium |
| 6 | Kill Singletonitis in a service graph | Singletonitis + Hidden Deps | Go | ★★ medium |
| 7 | Freeze the shared mutable config | Action at a Distance + immutability | Python | ★★ medium |
| 8 | Break the cycle with a third module | Circular Dependency | Python | ★★ medium |
| 9 | Encode call order in the type (file handle) | Sequential Coupling | Go | ★★★ hard |
| 10 | Make misuse not compile (typed builder) | Sequential Coupling | Java | ★★★ hard |
| 11 | Trace the action at a distance | Action at a Distance + Hidden Deps | Python | ★★★ hard |
| 12 | Mini-project: detangle the BillingEngine | All five | Python | ★★★★ project |
| 13 | Judgment: when a process-wide singleton is correct | Singletonitis (judgment) | Go | ★★ medium |
| 14 | Write a coupling review checklist | meta | — | ★★ medium |
Exercise 1 — Inject the config singleton¶
Anti-pattern: Singletonitis · Language: Python · Difficulty: ★ easy
PriceCalculator reaches for a global Config singleton in the middle of its logic. The tax rate is now impossible to vary in a test without mutating global state and hoping no other test runs in parallel.
class Config:
_instance = None
@classmethod
def instance(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self):
self.tax_rate = 0.08
self.currency = "USD"
class PriceCalculator:
def total(self, subtotal: float) -> float:
# reaches into the global — the dependency is invisible in the signature
rate = Config.instance().tax_rate
return subtotal * (1 + rate)
Acceptance criteria - PriceCalculator no longer references Config.instance(). - The tax rate it uses is supplied from outside (constructor injection). - A test can construct a calculator with tax_rate=0.20 without touching any global.
Hint: the calculator needs one number, not the whole config object. Inject the smallest thing it actually uses.
Solution
class PriceCalculator:
def __init__(self, tax_rate: float):
self._tax_rate = tax_rate
def total(self, subtotal: float) -> float:
return subtotal * (1 + self._tax_rate)
# Composition root — the ONE place that reads global config and wires it in:
calc = PriceCalculator(tax_rate=Config.instance().tax_rate)
# A test needs no global at all:
def test_total_applies_tax():
assert PriceCalculator(tax_rate=0.20).total(100) == 120
Exercise 2 — Surface the hidden clock¶
Anti-pattern: Hidden Dependencies · Language: Go · Difficulty: ★ easy
Token.Expired looks pure, but it secretly depends on the wall clock. Its tests are flaky — they pass at 10:00 and fail at 10:05 — and you can never assert the boundary case.
type Token struct {
IssuedAt time.Time
TTLMinute int
}
// The signature promises a function of the token alone. It lies:
// it also depends on time.Now(), an invisible input.
func (t Token) Expired() bool {
return time.Now().After(t.IssuedAt.Add(time.Duration(t.TTLMinute) * time.Minute))
}
Acceptance criteria - "Now" becomes an explicit input, not a hidden read of time.Now(). - A test can check "expired exactly one second after the deadline" deterministically. - Production callers are not forced to construct a clock object on every call.
Hint: the simplest cure is to pass now time.Time as a parameter. The signature then tells the whole truth.
Solution
// now is now an explicit input. The signature no longer lies.
func (t Token) ExpiredAt(now time.Time) bool {
return now.After(t.IssuedAt.Add(time.Duration(t.TTLMinute) * time.Minute))
}
// Production callers pass the real clock at the call site:
expired := tok.ExpiredAt(time.Now())
func TestExpiry(t *testing.T) {
tok := Token{IssuedAt: time.Unix(0, 0), TTLMinute: 5}
deadline := time.Unix(0, 0).Add(5 * time.Minute)
if tok.ExpiredAt(deadline) {
t.Error("token should not be expired exactly at the deadline")
}
if !tok.ExpiredAt(deadline.Add(time.Second)) {
t.Error("token should be expired one second past the deadline")
}
}
Exercise 3 — Make the spooky total explicit¶
Anti-pattern: Action at a Distance · Language: Python · Difficulty: ★ easy
add_item mutates a module-level _cart, and checkout reads it. Calling add_item from one part of the program silently changes what checkout returns in another — action at a distance. Two requests sharing this module corrupt each other's carts.
_cart = [] # module-global, mutated from afar
_total = 0.0 # kept "in sync" by hand — until it isn't
def add_item(price: float):
_cart.append(price)
global _total
_total += price # someone, somewhere, forgot to update this once
def checkout() -> float:
return _total # reads state set by a distant, invisible caller
Acceptance criteria - No module-level mutable cart or total. - The cart's contents are an explicit value passed between functions. - Two carts can exist at once without interfering.
Hint: the cart is state. Give it an owner (an object or an explicit value) instead of letting it float at module scope.
Solution
from dataclasses import dataclass, field
@dataclass
class Cart:
items: list[float] = field(default_factory=list)
def add_item(self, price: float) -> "Cart":
# return a new cart rather than mutating in place
return Cart(items=[*self.items, price])
def total(self) -> float:
# derived on read — no hand-maintained _total to drift out of sync
return sum(self.items)
# Each caller owns its own cart; no distant action:
cart = Cart().add_item(9.99).add_item(4.50)
print(cart.total()) # 14.49
Exercise 4 — Break the import cycle with an interface¶
Anti-pattern: Circular Dependency · Language: Go · Difficulty: ★★ medium
Package order imports payment to charge a card; package payment imports order to look up the order total. Go refuses to compile a cycle outright, and even in languages that tolerate it the design is wrong: neither package can be understood, tested, or built alone.
// package order
package order
import "app/payment"
type Order struct {
ID string
Total int
}
func (o *Order) Pay() error {
return payment.Charge(o) // order -> payment
}
// package payment
package payment
import "app/order" // payment -> order : CYCLE
func Charge(o *order.Order) error {
amount := o.Total // payment only needs the amount...
// ... call the gateway with `amount` ...
return nil
}
Acceptance criteria - The import cycle is gone; each package compiles independently. - payment no longer imports order. - The dependency is inverted via an interface owned by the consumer (payment).
Hint: payment does not need the order type — it needs the amount. Have payment declare a tiny interface for what it requires, and let order satisfy it.
Solution
// package payment — depends on an abstraction it OWNS, not on package order.
package payment
// Payable is the narrow contract payment actually needs.
type Payable interface {
Amount() int
}
func Charge(p Payable) error {
amount := p.Amount()
// ... call the gateway with `amount` ...
return nil
}
// package order — now satisfies payment.Payable; depends one direction only.
package order
import "app/payment"
type Order struct {
ID string
Total int
}
func (o *Order) Amount() int { return o.Total } // implements payment.Payable
func (o *Order) Pay() error {
return payment.Charge(o) // order -> payment, and nothing comes back
}
Exercise 5 — Make the file dependency explicit¶
Anti-pattern: Hidden Dependencies · Language: Java · Difficulty: ★★ medium
AuditLog reads an environment variable and opens a file deep inside a method. Its constructor signature claims it needs nothing; in reality it needs a writable filesystem, an env var, and a specific OS path. Tests must set environment variables and clean up files on disk.
public class AuditLog {
// Looks like it has no dependencies. It has three: an env var,
// a filesystem, and a hard-coded path layout.
public void record(String event) {
String dir = System.getenv("AUDIT_DIR"); // hidden env dependency
if (dir == null) dir = "/var/log/app"; // hidden default + FS assumption
Path path = Paths.get(dir, "audit.log");
try (var w = Files.newBufferedWriter(path, StandardOpenOption.CREATE,
StandardOpenOption.APPEND)) {
w.write(Instant.now() + " " + event + "\n"); // also a hidden clock
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
Acceptance criteria - The sink (where audit lines go) is injected, not discovered from the environment. - The clock is injected so timestamps are deterministic in tests. - A unit test can capture the written lines in memory — no env var, no temp files.
Hint: record really wants two collaborators: somewhere to write a line and a way to get the current time. Inject both; let the composition root decide they are a file and the system clock.
Solution
// Two small, honest dependencies.
public interface LineSink {
void writeLine(String line);
}
public final class AuditLog {
private final LineSink sink;
private final Clock clock; // java.time.Clock — injectable
public AuditLog(LineSink sink, Clock clock) {
this.sink = sink;
this.clock = clock;
}
public void record(String event) {
sink.writeLine(Instant.now(clock) + " " + event);
}
}
// Production wiring (composition root):
LineSink fileSink = line -> {
Path dir = Paths.get(Optional.ofNullable(System.getenv("AUDIT_DIR"))
.orElse("/var/log/app"));
try (var w = Files.newBufferedWriter(dir.resolve("audit.log"),
StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
w.write(line + "\n");
} catch (IOException e) { throw new UncheckedIOException(e); }
};
AuditLog log = new AuditLog(fileSink, Clock.systemUTC());
Exercise 6 — Kill Singletonitis in a service graph¶
Anti-pattern: Singletonitis + Hidden Dependencies · Language: Go · Difficulty: ★★ medium
Everything is a global singleton: the DB, the logger, the metrics client, the clock. ProcessOrder reaches for all four. The result is untestable — you cannot run it without a live database — and its dependencies are completely invisible.
var DB *sql.DB // global
var Log *zap.Logger // global
var Metrics *statsd.Client // global
func ProcessOrder(id string) error {
Log.Info("processing", zap.String("id", id)) // hidden global
row := DB.QueryRow("SELECT total FROM orders WHERE id=$1", id) // hidden global
var total int
if err := row.Scan(&total); err != nil {
return err
}
Metrics.Incr("orders.processed", 1) // hidden global
createdAt := time.Now() // hidden clock
_ = createdAt
return nil
}
Acceptance criteria - ProcessOrder becomes a method on a struct that holds its dependencies. - The dependencies are interfaces, so tests can pass fakes. - No global variables remain in the business logic; wiring happens once at startup.
Hint: bundle the collaborators into an OrderProcessor struct built by a constructor. Define the narrowest interface for each — ProcessOrder does not need all of *sql.DB, only "fetch a total."
Solution
// Narrow interfaces — each names only what the logic actually uses.
type OrderStore interface {
Total(id string) (int, error)
}
type Logger interface {
Info(msg string, kv ...any)
}
type Metrics interface {
Incr(name string)
}
type Clock interface {
Now() time.Time
}
type OrderProcessor struct {
store OrderStore
log Logger
metrics Metrics
clock Clock
}
func NewOrderProcessor(s OrderStore, l Logger, m Metrics, c Clock) *OrderProcessor {
return &OrderProcessor{store: s, log: l, metrics: m, clock: c}
}
func (p *OrderProcessor) ProcessOrder(id string) error {
p.log.Info("processing", "id", id)
total, err := p.store.Total(id)
if err != nil {
return fmt.Errorf("process order %s: %w", id, err)
}
_ = total
p.metrics.Incr("orders.processed")
_ = p.clock.Now()
return nil
}
Exercise 7 — Freeze the shared mutable config¶
Anti-pattern: Action at a Distance + missing immutability · Language: Python · Difficulty: ★★ medium
A single mutable Settings object is shared across the whole app. One module flips settings.debug = True for a request, forgets to flip it back, and now a different module logs secrets in production. The mutation acts at a distance.
class Settings:
def __init__(self):
self.debug = False
self.retries = 3
self.region = "us-east-1"
SETTINGS = Settings() # one shared, mutable instance for everyone
def handle_debug_request(req):
SETTINGS.debug = True # mutates the shared object...
process(req)
# ... and "restores" it — except an exception in process() skips this line:
SETTINGS.debug = False
def log_event(event):
if SETTINGS.debug: # a distant reader, surprised by True
print("SECRET", event)
Acceptance criteria - The shared config object is immutable; nobody can mutate it from afar. - A per-request override (debug=True) does not affect any other code path. - The "forgot to restore" / exception-skips-restore bug becomes impossible.
Hint: make Settings frozen, then express an override as a new value (with_debug()), passed explicitly to the code that needs it — not as a mutation of the global.
Solution
from dataclasses import dataclass, replace
@dataclass(frozen=True) # immutable: assigning a field raises FrozenInstanceError
class Settings:
debug: bool = False
retries: int = 3
region: str = "us-east-1"
def with_debug(self, on: bool = True) -> "Settings":
return replace(self, debug=on) # returns a NEW Settings; original untouched
SETTINGS = Settings() # a shared, read-only baseline
def handle_debug_request(req):
# a local override that lives only for this call — no global mutation
request_settings = SETTINGS.with_debug(True)
process(req, request_settings)
def log_event(event, settings: Settings):
if settings.debug:
print("SECRET", event)
Exercise 8 — Break the cycle with a third module¶
Anti-pattern: Circular Dependency · Language: Python · Difficulty: ★★ medium
user.py imports notification.py (to email a new user) and notification.py imports user.py (to format a greeting from a User). Python "tolerates" this with import-order landmines: import the wrong module first and you get ImportError: cannot import name 'User' or a half-initialized module. The interface fix (Exercise 4) is one option; here the cleaner answer is to extract the shared type both depend on into a third module.
# user.py
from notification import send_welcome # user -> notification
class User:
def __init__(self, name, email):
self.name = name
self.email = email
def register(self):
send_welcome(self) # passes self to notification
# notification.py
from user import User # notification -> user : CYCLE
def send_welcome(u: User): # only needs name + email
body = f"Welcome {u.name}!"
_smtp_send(u.email, body)
Acceptance criteria - The import cycle is gone; both modules import in any order. - The shared data type lives in a module that depends on neither user nor notification. - send_welcome depends only on the data it uses.
Hint: the cycle exists because both modules want the User type. Hoist the plain data into a third, leaf module (models.py or a small dataclass) that nothing else depends on, and let both modules import down into it.
Solution
# recipient.py — a leaf module; imports nothing from the cycle.
from dataclasses import dataclass
@dataclass(frozen=True)
class Recipient:
name: str
email: str
# notification.py — depends only on the leaf type, not on user.
from recipient import Recipient
def send_welcome(r: Recipient) -> None:
_smtp_send(r.email, f"Welcome {r.name}!")
# user.py — depends on notification and on the leaf type; nobody imports user back.
from recipient import Recipient
from notification import send_welcome
class User:
def __init__(self, name: str, email: str):
self.name = name
self.email = email
def register(self) -> None:
send_welcome(Recipient(self.name, self.email))
Exercise 9 — Encode call order in the type (file handle)¶
Anti-pattern: Sequential Coupling · Language: Go · Difficulty: ★★★ hard
This Connection only works if you call Open(), then Send() any number of times, then Close() — in that order. Nothing enforces it. Calling Send() before Open(), or after Close(), fails at runtime (or worse, silently). Redesign so the type system makes the legal sequence the only expressible one.
type Connection struct {
open bool
closed bool
conn net.Conn
}
func (c *Connection) Open(addr string) error {
conn, err := net.Dial("tcp", addr)
if err != nil {
return err
}
c.conn, c.open = conn, true
return nil
}
func (c *Connection) Send(b []byte) error {
if !c.open || c.closed { // a runtime guard for a rule the compiler could enforce
return errors.New("send on a connection that is not open")
}
_, err := c.conn.Write(b)
return err
}
func (c *Connection) Close() error {
c.closed = true
return c.conn.Close()
}
Acceptance criteria - You cannot call Send on something that has not been opened — it should not compile. - Open is the only way to obtain a sendable connection. - Close consumes the connection so post-close Send is impossible to write. - The open/closed boolean guards disappear.
Hint: make Open a constructor function that returns a distinct *OpenConn type which is the only thing that has a Send method. Close is the terminal operation. The "you must open first" rule becomes "you can only get an *OpenConn from Open."
Solution
// Open is the only constructor; you cannot make an *OpenConn any other way.
type OpenConn struct {
conn net.Conn
}
// Open returns a sendable connection, or an error. No bool flags.
func Open(addr string) (*OpenConn, error) {
conn, err := net.Dial("tcp", addr)
if err != nil {
return nil, err
}
return &OpenConn{conn: conn}, nil
}
// Send exists ONLY on *OpenConn — so "send before open" cannot be written.
func (c *OpenConn) Send(b []byte) error {
_, err := c.conn.Write(b)
return err
}
// Close is terminal. Idiomatic Go pairs Open with a deferred Close.
func (c *OpenConn) Close() error {
return c.conn.Close()
}
conn, err := Open("localhost:9000")
if err != nil {
return err
}
defer conn.Close() // RAII-style: Close is guaranteed, in order
if err := conn.Send([]byte("ping")); err != nil {
return err
}
// conn.Send(...) before Open() is impossible: there is no zero-value
// connection with a Send method to call it on.
Exercise 10 — Make misuse not compile (typed builder)¶
Anti-pattern: Sequential Coupling · Language: Java · Difficulty: ★★★ hard
An HttpRequest builder lets you call build() before setting the required url, producing a broken request that blows up at send time. The order rule ("set url before build") lives only in a runtime check. Use a staged (step) builder so the compiler refuses build() until url is set.
public class HttpRequest {
private String url;
private String method = "GET";
private String body;
public HttpRequest url(String u) { this.url = u; return this; }
public HttpRequest method(String m) { this.method = m; return this; }
public HttpRequest body(String b) { this.body = b; return this; }
public HttpRequest build() {
if (url == null) // a rule the compiler could enforce
throw new IllegalStateException("url is required");
return this;
}
}
// The bug compiles fine and fails only at runtime:
// new HttpRequest().method("POST").build(); // boom: url is null
Acceptance criteria - build() is not reachable until url has been set — calling it early is a compile error. - Optional settings (method, body) remain freely chainable. - The runtime if (url == null) guard is gone.
Hint: split the fluent chain into interfaces (stages). The entry point returns a "needs url" stage whose only method is url(...); url(...) returns a "can build" stage that also exposes the optional setters and build().
Solution
public final class HttpRequest {
public final String url, method, body;
private HttpRequest(String url, String method, String body) {
this.url = url; this.method = method; this.body = body;
}
// Stage 1: the only thing you can do is supply the required url.
public interface UrlStage {
BuildStage url(String url);
}
// Stage 2: optional setters + build(). Reachable only after url() is called.
public interface BuildStage {
BuildStage method(String method);
BuildStage body(String body);
HttpRequest build();
}
public static UrlStage builder() {
return url -> new BuildStage() { // url() returns the build stage
private String method = "GET";
private String body;
public BuildStage method(String m) { this.method = m; return this; }
public BuildStage body(String b) { this.body = b; return this; }
public HttpRequest build() {
return new HttpRequest(url, method, body); // url is guaranteed non-null
}
};
}
}
// Legal — url first, then anything, then build:
HttpRequest ok = HttpRequest.builder().url("/api").method("POST").body("{}").build();
// Will NOT compile — UrlStage has no build() method:
// HttpRequest bad = HttpRequest.builder().method("POST").build();
// ^ method() doesn't exist on UrlStage
// build() doesn't exist on UrlStage
Exercise 11 — Trace the action at a distance¶
Anti-pattern: Action at a Distance + Hidden Dependencies · Language: Python · Difficulty: ★★★ hard
A bug report: "discounts are sometimes applied twice." The code below is the suspect. Two functions communicate through a module-global, and a third reads it much later. Your task is twofold: (1) explain how the double-discount happens, then (2) eliminate the action at a distance so the bug is structurally impossible.
_applied_discounts = set() # module-global, mutated from several places
def apply_discount(order_id, code, price):
if code not in _applied_discounts:
_applied_discounts.add(code) # global mutation, far from the read
return price * 0.9
return price
def retry_pricing(order_id, code, price):
# a retry path that ALSO prices — but doesn't know about _applied_discounts
return apply_discount(order_id, code, price)
def finalize(order_id):
# reads the global set built up by unrelated calls, possibly across requests
return {"discounts_used": list(_applied_discounts)}
Acceptance criteria - Explain the mechanism of the double-discount bug (and why it is cross-request). - Remove the module-global; discount state is owned by the order being priced. - The "applied?" check and the price live together, so a retry cannot double-apply.
Hint: _applied_discounts is global, so it is shared across all orders and all requests — code keys collide between orders. The state belongs to a single order's pricing, not to the module.
Solution
**How the bug happens.** `_applied_discounts` is keyed by `code`, not by `order_id`, and it is a module-global that lives for the whole process. So: 1. **Cross-order collision:** Order A applies code `SAVE10`; the code is now in the global set. Order B uses `SAVE10` and is *silently skipped* (`code in _applied_discounts` is already true) — B never gets its discount. 2. **The double-apply:** worse, the set is *never cleared*, and `retry_pricing` calls `apply_discount` again. If two requests interleave — one adds `SAVE10`, a concurrent retry checks before the add commits — both can pass the `not in` check and both apply the 0.9 multiplier. The mutation (`add`) and the read (`finalize`) are far apart and span requests: textbook action at a distance. **The fix — give the state an owner:**from dataclasses import dataclass, field
@dataclass
class OrderPricing:
order_id: str
applied: set[str] = field(default_factory=set) # scoped to THIS order
def apply_discount(self, code: str, price: float) -> float:
if code in self.applied: # check and mutation live together, per-order
return price
self.applied.add(code)
return price * 0.9
def summary(self) -> dict:
return {"order_id": self.order_id, "discounts_used": sorted(self.applied)}
# Each order gets its own pricing context; a retry reuses the SAME object,
# so the "already applied" check is correct and local:
pricing = OrderPricing(order_id="A")
price = pricing.apply_discount("SAVE10", 100.0) # 90.0
price = pricing.apply_discount("SAVE10", 90.0) # 90.0 — correctly NOT re-discounted
Exercise 12 — Mini-project: detangle the BillingEngine¶
Anti-pattern: all five, in one small realistic module · Language: Python · Difficulty: ★★★★ project
This module manages to combine every coupling-and-state anti-pattern at once: a Singletonitis global config, a hidden filesystem/clock dependency, action at a distance through a shared mutable ledger, a sequential-coupling rule enforced only at runtime, and (with its partner module) a circular import. Refactor it into clean, injectable, testable units. Work in steps; do not try to fix it all in one edit.
# config.py (and billing.py imports it; config.py imports billing.py for a type — CYCLE)
class Config:
_i = None
@classmethod
def get(cls):
if cls._i is None: cls._i = cls()
return cls._i
def __init__(self):
self.tax_rate = 0.08
self.ledger_path = "/var/billing/ledger.csv"
# billing.py
from config import Config
_ledger = [] # shared mutable state, action at a distance
class BillingEngine:
def __init__(self):
self.started = False # sequential-coupling flag
def start_run(self):
self.started = True
def charge(self, customer_id, amount):
if not self.started: # runtime guard, not a compile guard
raise RuntimeError("call start_run() first")
rate = Config.get().tax_rate # Singletonitis: hidden global
total = amount * (1 + rate)
_ledger.append((customer_id, total)) # mutates module-global from afar
# hidden FS + clock dependency, discovered mid-method:
import csv, time
with open(Config.get().ledger_path, "a") as f:
csv.writer(f).writerow([customer_id, total, time.time()])
return total
def finish_run(self):
return {"charged": len(_ledger), "total": sum(t for _, t in _ledger)}
Acceptance criteria - Singletonitis: Config.get() is read once at the edge; BillingEngine receives a tax_rate (or config value), not the singleton. - Hidden Dependencies: the ledger sink (file) and the clock are injected interfaces, not opened mid-method. - Action at a Distance: the module-global _ledger is replaced by state owned by a run. - Sequential Coupling: the started boolean guard is replaced by a type/structure that makes "charge before start" impossible to express. - Circular Dependency: the config ↔ billing cycle is broken. - Each unit is testable in isolation — no file, no clock, no global.
Hint: model a billing run as an object you can only obtain by starting one (kills the sequential coupling, like Exercise 9). Inject a LedgerSink and a Clock (kills hidden deps). Pass the tax_rate value in (kills Singletonitis). Break the cycle by not importing billing from config (extract any shared type, per Exercise 8).
Solution
from dataclasses import dataclass, field
from typing import Protocol
# --- Collaborators as narrow, injectable interfaces (kills Hidden Dependencies) ---
class LedgerSink(Protocol):
def write(self, customer_id: str, total: float, at: float) -> None: ...
class Clock(Protocol):
def now(self) -> float: ...
# --- A billing run owns its state; the ONLY way to get one is start_run().
# This makes "charge before start" unrepresentable (kills Sequential Coupling
# and the module-global Action at a Distance). ---
@dataclass
class BillingRun:
tax_rate: float
sink: LedgerSink
clock: Clock
charges: list[tuple[str, float]] = field(default_factory=list) # owned, not global
def charge(self, customer_id: str, amount: float) -> float:
total = amount * (1 + self.tax_rate)
self.charges.append((customer_id, total))
self.sink.write(customer_id, total, self.clock.now())
return total
def summary(self) -> dict:
return {"charged": len(self.charges),
"total": round(sum(t for _, t in self.charges), 2)}
class BillingEngine:
"""A factory for runs. tax_rate is a plain value (kills Singletonitis)."""
def __init__(self, tax_rate: float, sink: LedgerSink, clock: Clock):
self._tax_rate, self._sink, self._clock = tax_rate, sink, clock
def start_run(self) -> BillingRun: # the only door to a BillingRun
return BillingRun(self._tax_rate, self._sink, self._clock)
# adapters.py — filesystem + clock concerns isolated here.
import csv, time
class FileLedgerSink:
def __init__(self, path: str):
self._path = path
def write(self, customer_id, total, at):
with open(self._path, "a") as f:
csv.writer(f).writerow([customer_id, total, at])
class SystemClock:
def now(self) -> float:
return time.time()
# main.py — composition root: read the singleton ONCE, inject everything inward.
from config import Config
cfg = Config.get()
engine = BillingEngine(cfg.tax_rate, FileLedgerSink(cfg.ledger_path), SystemClock())
class FakeSink:
def __init__(self): self.rows = []
def write(self, c, t, at): self.rows.append((c, t, at))
class FixedClock:
def now(self): return 1_000.0
def test_charge_applies_tax_and_records():
engine = BillingEngine(tax_rate=0.10, sink=FakeSink(), clock=FixedClock())
run = engine.start_run() # you literally cannot charge() without this
assert run.charge("c1", 100.0) == 110.0
assert run.summary() == {"charged": 1, "total": 110.0}
Exercise 13 — Judgment: when a process-wide singleton is correct¶
Anti-pattern: Singletonitis (judgment call) · Language: Go · Difficulty: ★★ medium
Not every single instance is Singletonitis. A senior engineer's skill is knowing when to keep one. Below is a structured logger that is genuinely process-wide. A junior wants to "fix the singleton" by threading a *Logger parameter through all 200 functions in the codebase. Decide what is actually right.
package logx
var global *slog.Logger = slog.New(slog.NewJSONHandler(os.Stdout, nil))
// Used everywhere, by name, with no injection:
func Info(msg string, args ...any) { global.Info(msg, args...) }
func Error(msg string, args ...any) { global.Error(msg, args...) }
The question. Is this Singletonitis? Should you (a) inject a *Logger everywhere, (b) leave it exactly as is, or (c) something in between? Justify the call, and show the change (if any). Consider: what breaks in tests? what is the cost of injecting it into 200 call sites? what genuinely needs per-instance variation?
Acceptance criteria - A clear verdict with reasoning, not a reflexive "all globals are bad." - Identify what (if anything) about the current design actually causes pain. - If you keep the global, show how tests still get control.
Solution
**Verdict: keep it as a process-wide instance, but inject the *interface* where behavior must vary under test — option (c), leaning heavily toward (b).** **Why it is *not* Singletonitis.** Singletonitis is the *reflex* of making everything a global, hidden, untestable singleton — config, DB, session, "managers." A logger is different on three counts: 1. **It is genuinely process-wide.** There is one stdout, one log stream per process. A per-request logger is occasionally useful (request-scoped fields), but the *sink* is legitimately singular. This is the category of resource singletons are *for*. 2. **It is a cross-cutting concern.** Threading a `*Logger` through 200 functions — including pure computational ones that log once on an error path — adds a parameter to every signature for a concern orthogonal to what those functions *do*. That is a large, ongoing readability tax for little gain. (This is exactly why most languages keep logging ambient.) 3. **Logging is an observation, not a behavior under test.** You rarely assert "this function logged exactly X." You assert what it *returns* or *changes*. So the usual reason to inject — to verify or control behavior in tests — mostly does not apply. **What *would* be a real problem (and the fix):** the only legitimate pain is a test that genuinely needs to *assert* a log line (e.g. "an auth failure is logged at WARN") or *silence* output. Solve that without injecting into 200 sites by making the global *swappable behind an interface*:package logx
type Logger interface {
Info(msg string, args ...any)
Error(msg string, args ...any)
}
var global Logger = slog.New(slog.NewJSONHandler(os.Stdout, nil))
// SetForTest swaps the logger and returns a restore func. Test-only seam.
func SetForTest(l Logger) (restore func()) {
prev := global
global = l
return func() { global = prev }
}
func Info(msg string, args ...any) { global.Info(msg, args...) }
func Error(msg string, args ...any) { global.Error(msg, args...) }
Exercise 14 — Write a coupling review checklist¶
Anti-pattern: meta (prevention) · Difficulty: ★★ medium
Coupling-and-state anti-patterns are cheapest to stop in review, before the wires multiply. Write a concise, actionable reviewer checklist — phrased as questions a reviewer asks of a diff — that catches all five before they merge. Aim for questions with a clear "if yes, push back" trigger, not vague advice like "is this coupled?"
Acceptance criteria - One or more concrete, answerable questions per anti-pattern. - Each question has a clear failure trigger and a suggested action. - Short enough that a reviewer would actually run it on every PR.
Solution
**Coupling-&-State PR review checklist** | # | Question | If the answer is… | Then | |---|---|---|---| | 1 | Does this code call `X.instance()` / read a global / `getenv` *inside* its logic? | "Yes" | Push back: inject it via the constructor; read globals only in the composition root. **Singletonitis / Hidden Deps**. | | 2 | Does the function's signature list *everything* it depends on (clock, FS, network, randomness)? | "No — it reaches for `time.Now()`/files/env" | Push back: make the dependency a parameter or injected interface. **Hidden Deps**. | | 3 | Do these two packages/modules import *each other* (directly or transitively)? | "Yes" | Push back: invert with a consumer-owned interface, or extract the shared type to a leaf module. **Circular Dependency**. | | 4 | Does this mutate a shared/global/module-level variable that something else reads later? | "Yes" | Push back: give the state an owner, pass it explicitly, prefer immutability. **Action at a Distance**. | | 5 | Do these methods only work if called in a fixed order, guarded by a runtime check or a `started`/`open` boolean? | "Yes" | Push back: encode the order in the type (type-state, staged builder, RAII/`defer`/`with`) so misuse won't compile. **Sequential Coupling**. | | 6 | Could a test exercise this unit *without* a real DB / SMTP / clock / filesystem? | "No" | Ask why — usually a hidden dependency or a non-injected singleton (rows 1–2). | | 7 | Is a new singleton being added? | "Yes" | Ask: is the resource *genuinely* process-wide (logger, pool)? If not, inject instead. If yes, is it behind a swappable interface for tests? **Singletonitis judgment** (Exercise 13). | **Author's pre-flight (same list, mirrored):** before requesting review, ask whether each new dependency is visible in a signature, whether any global is mutated across call boundaries, and whether the call-order rules are enforced by types rather than comments. **Why this is better than "review for coupling."** Each row has a *trigger* and an *action*, so the checklist is mechanical enough to apply under time pressure and specific enough that two reviewers reach the same verdict. Row 6 ("can you test it without infrastructure?") is the single most powerful question — it surfaces most hidden dependencies and Singletonitis automatically, because untestable code is almost always over-coupled code. Row 7 keeps the team from over-correcting into "ban every singleton," preserving the judgment from Exercise 13.Summary¶
- These exercises move you from recognizing over-coupling to severing it: inject globals that masquerade as singletons, surface hidden clocks/files/env reads into the signature, give wandering shared state an owner and freeze it, break import cycles by inverting with an interface or extracting a leaf type, and encode call-order rules in types so misuse will not compile.
- A dependency the type system cannot see is one you cannot test, reason about, or change. Every cure pulls a hidden coupling into the open — a constructor parameter, an interface, a return value, or a distinct type.
- Push reads of the environment to the edge. Singletons, env vars, and clocks are read once in a composition root and injected inward; business logic stays pure and testable. The standard escape from Singletonitis is "create the instance at the edge, inject the interface" — not "ban all single instances" (Exercise 13).
- Make illegal sequences unrepresentable. Sequential coupling guarded by
started/openbooleans is fragile; type-state (Exercise 9) and staged builders (Exercise 10) turn a runtime error into a compile error. - Prefer immutability for shared state. Action at a distance dies when there is no shared mutable object to corrupt from afar (Exercises 3, 7, 11).
- The five anti-patterns travel together — Singletonitis breeds Hidden Dependencies, which breed Action at a Distance — so a real module (Exercise 12) usually shows several at once. Inject the one in front of you today and the gravity that pulls in the others weakens.
Related Topics¶
junior.md— the five shapes and how to recognize them on sight.middle.md— the forces that create these shapes and the countermoves used here.senior.md— detecting and migrating these couplings at scale.professional.md— testability, performance, and observability implications.find-bug.md— spot-the-anti-pattern snippets (critical reading practice).optimize.md— more over-coupled designs to redesign.interview.md— Q&A across all levels for job prep.- Design Patterns → Creational — Dependency Injection, Builder, Factory: the positive counterparts.
- Refactoring → Refactoring Techniques — Introduce Parameter Object, Replace Global with Injection, Extract Interface.
- Clean Code → Immutability — the deepest cure for Action at a Distance.
- Clean Code → Modules & Packages — keeping the dependency graph acyclic.
- Clean Code → Classes (SOLID/DIP) — depend on abstractions, not concretions.
In this topic