Dispensables — Find the Bug¶
12 buggy snippets where Dispensables hide bugs.
Bug 1 — Stale comment lies (Java)¶
/**
* Returns the user's full name in "First Last" format.
* @return formatted name
*/
public String fullName() {
return lastName + ", " + firstName;
}
Diagnosis
Comment says "First Last"; code returns "Last, First". Someone changed the implementation without updating the comment. Callers expecting "First Last" get "Last, First" — but the comment misleads them into thinking it's right. **Fix:** delete the comment (or update). The smell is: comment didn't compile-check. Self-documenting names would catch the discrepancy: Now the *name* tells callers what they get. No comment needed; no drift possible.Bug 2 — Duplicated logic, one branch fixed (Python)¶
def normalize_email(email):
if email is None: return None
return email.lower().strip()
def normalize_user_email(user):
if user is None or user.email is None: return None
return user.email.strip().lower() # Note: order of strip/lower differs
# Usage 1 — uses normalize_email:
emails_db = [normalize_email(e) for e in input_emails]
# Usage 2 — uses normalize_user_email:
emails_user = [normalize_user_email(u) for u in users]
A user enters " ALICE@example.COM ". - normalize_email(" ALICE@example.COM ") → "alice@example.com" - normalize_user_email(user) where user.email = " ALICE@example.COM " → "alice@example.com" ... wait, same result.
But what about " ALICE@example.COM\n "? - normalize_email → "alice@example.com" - normalize_user_email → "alice@example.com"
Hmm, same. Test with " ÉLOÏSE@example.COM ": - normalize_email(" ÉLOÏSE@example.COM ".lower().strip()) → "éloïse@example.com" - normalize_user_email(user) → " ÉLOÏSE@example.COM ".strip().lower() → "éloïse@example.com"
In Python, .lower().strip() and .strip().lower() are equivalent for most strings. But: a security report fix to normalize_email (handle Unicode case-folding correctly: casefold() instead of lower()) won't apply to normalize_user_email because they're separate functions.
Diagnosis
Duplicated logic with subtle differences. A bug fix to one is unlikely to be applied to the other. Evolution diverges over time. **Fix:** consolidate.Bug 3 — Dead code re-enabled by mistake (Java)¶
public class PaymentProcessor {
public void charge(Order o) {
chargeViaModernGateway(o);
// chargeViaLegacyGateway(o); // dead since 2023
}
private void chargeViaLegacyGateway(Order o) { ... } // 200 lines
private void chargeViaModernGateway(Order o) { ... }
}
// Months later, someone "cleans up" the comment:
public void charge(Order o) {
chargeViaModernGateway(o);
chargeViaLegacyGateway(o); // <-- accidentally activated
}
Diagnosis
The commented-out call was a "dormant tombstone." Someone uncommented it thinking it was needed. Now every order is charged twice — once modern, once legacy. **Fix:** had the dead code been deleted (not commented), this couldn't happen. Don't comment dead code; delete it. Git remembers.Bug 4 — Anemic Domain Model invariant violated (Java)¶
class Account {
public BigDecimal balance;
public List<Transaction> transactions;
}
class AccountService {
public void deposit(Account a, BigDecimal amount) {
a.balance = a.balance.add(amount);
a.transactions.add(new Transaction("DEPOSIT", amount));
}
public void withdraw(Account a, BigDecimal amount) {
a.balance = a.balance.subtract(amount);
a.transactions.add(new Transaction("WITHDRAW", amount));
}
}
// Caller bypasses the service:
account.balance = BigDecimal.ZERO; // doesn't add transaction!
Diagnosis
The anemic Account allows direct field mutation. Caller resets balance without recording a transaction. Audit log is now inconsistent with balance. **Fix:** Encapsulate Field + Move Method.class Account {
private BigDecimal balance;
private final List<Transaction> transactions = new ArrayList<>();
public void deposit(BigDecimal amount) {
balance = balance.add(amount);
transactions.add(new Transaction("DEPOSIT", amount));
}
public void withdraw(BigDecimal amount) {
if (balance.compareTo(amount) < 0) throw new InsufficientFundsException();
balance = balance.subtract(amount);
transactions.add(new Transaction("WITHDRAW", amount));
}
}
Bug 5 — Speculative hook called inconsistently (Java)¶
abstract class JobRunner {
protected void beforeRun() {} // empty default
protected void afterRun() {} // empty default
public void run() {
beforeRun();
execute();
afterRun();
}
protected abstract void execute();
}
class ExpensiveJob extends JobRunner {
@Override protected void beforeRun() { acquireLock(); }
@Override protected void afterRun() { releaseLock(); }
public void execute() {
try {
doWork();
} catch (Exception e) {
// crashes — afterRun not called
throw e;
}
}
}
Diagnosis
`afterRun()` is called only if `execute()` returns normally. On exception, the lock is leaked. **Fix:** template method should use try/finally: Better still: don't have empty hooks. Move the lock-management entirely outside the template (e.g., wrap with a `WithLock<>` or use a `try-with-resources` pattern).Bug 6 — Lazy class hides null bug (Java)¶
class TaxCalculator {
private BigDecimal rate; // not final, not initialized
public TaxCalculator() {}
public void setRate(BigDecimal rate) { this.rate = rate; }
public BigDecimal apply(BigDecimal amount) {
return amount.multiply(rate); // NPE if setRate not called
}
}
// Used:
TaxCalculator c = new TaxCalculator();
// Forgot c.setRate(...)
BigDecimal result = c.apply(new BigDecimal("100")); // NPE
Diagnosis
Lazy class with delayed initialization. Required field can be unset. Inlining the logic eliminates the bug entirely. **Fix 1 — make required fields constructor args:** **Fix 2 — inline the class:** If the class is so trivial, eliminate it; the indirection introduced the bug.Bug 7 — Speculative interface mocked badly (Java)¶
interface PaymentProcessor {
PaymentResult charge(Order o);
}
class StripeProcessor implements PaymentProcessor {
public PaymentResult charge(Order o) {
return stripeApi.charge(o.getTotal());
}
}
// In tests:
class TestProcessor implements PaymentProcessor {
public PaymentResult charge(Order o) {
return PaymentResult.success(); // never fails
}
}
// Production:
@Bean
PaymentProcessor processor(@Value("${env}") String env) {
if ("test".equals(env)) return new TestProcessor();
return new StripeProcessor();
}
Diagnosis
The speculative interface was created "for testability" — but the test implementation leaks into production via a config switch. If `env` is misconfigured (or set to "test" in production by accident), real payments are skipped — orders ship without being charged. **Fix:** don't conditionally swap implementations in production. Tests should mock the interface in the test code only: Production wiring always uses the real implementation. The test mock never reaches production code.Bug 8 — Comment hides a workaround removal (Python)¶
def fetch_user(user_id):
# Workaround: API returns 503 sometimes; retry once.
# See incident IR-1234.
try:
return api.fetch(user_id)
except ApiError:
return api.fetch(user_id)
A new engineer cleans up: "old comment, the API is fine now":
Production: 503s spike again three months later (the API still flaps). Customer complaints.
Diagnosis
The comment was a *good* comment (why-comment with reference). Removing it removed the institutional knowledge. The cleanup wasn't smell-fixing; it was deleting useful context. **Lesson:** when in doubt about whether a comment is the smell, check the *content*. Why-comments are not the smell. Don't aggressively delete them. **Fix:** restore the workaround AND the comment.Bug 9 — Duplicate validation drifts (Java)¶
class UserRegistration {
public void register(String email, String password) {
if (email == null || !email.contains("@")) throw new ValidationException();
if (password == null || password.length() < 8) throw new ValidationException();
// ... persist
}
}
class UserUpdate {
public void update(String email, String password) {
if (email == null || !email.contains("@")) throw new ValidationException();
if (password == null || password.length() < 6) throw new ValidationException();
// Note: 6, not 8! Drift.
}
}
Diagnosis
Same validation logic, drifted apart. Updating a user with a 6-character password is allowed; registering one isn't. Inconsistent rule. **Fix:** extract.class UserValidator {
public static void validateEmail(String email) {
if (email == null || !email.contains("@")) throw new ValidationException();
}
public static void validatePassword(String password) {
if (password == null || password.length() < 8) throw new ValidationException();
}
}
class UserRegistration {
public void register(String email, String password) {
UserValidator.validateEmail(email);
UserValidator.validatePassword(password);
// persist
}
}
Bug 10 — Dead branch protects against fixed bug (Java)¶
public Date parseDate(String input) {
// Bug 5678: legacy code sometimes sends "0000-00-00"
if ("0000-00-00".equals(input)) return null;
return DateFormat.getInstance().parse(input);
}
The bug 5678 was fixed in the source system 3 years ago. The defensive branch persists. A new engineer thinks the field can be 0000-00-00 and writes code expecting it; gets confused when the source system never sends that value.
Diagnosis
Defensive code outliving its bug. Now misleading future readers who think the case is real. **Fix:** delete the branch. Add a test that asserts the source system can't send `"0000-00-00"`. If the assumption breaks, the test fails fast (better than silently accepting bad data forever).Bug 11 — Empty Lazy package (Go)¶
A package money was created with no contents, "to claim the namespace." Months later, an engineer adds a Dollar type:
The Dollar type was added to the wrong package; the money package remains empty.
Diagnosis
The empty package created confusion. Either build it out or remove it. **Fix:** delete the empty package. When a type genuinely needs to live in `money`, create it then.Bug 12 — Speculative pluggable backend (Java)¶
interface StorageBackend {
void save(String key, byte[] data);
byte[] load(String key);
}
class FileStorageBackend implements StorageBackend { ... }
class S3StorageBackend implements StorageBackend { ... }
class InMemoryStorageBackend implements StorageBackend {
private final Map<String, byte[]> data = new HashMap<>();
public void save(String key, byte[] d) { data.put(key, d); }
public byte[] load(String key) { return data.get(key); }
}
// Service:
@Bean StorageBackend storage(@Value("${storage}") String type) {
return switch (type) {
case "file" -> new FileStorageBackend();
case "s3" -> new S3StorageBackend();
case "memory" -> new InMemoryStorageBackend();
default -> throw new IllegalArgumentException();
};
}
A misconfigured production deploy sets storage=memory. The service runs fine in production for 6 months, accumulating uploads in memory. Then the JVM restarts. All uploads are gone.
Diagnosis
Speculative backend selection — `InMemoryStorageBackend` was for tests but is selectable in production via config. Misconfiguration silently degrades to lossy storage. **Fix:** don't allow test backends in production. Use distinct beans (only one is registered based on Spring profile): Profile is set at startup; misconfiguration fails fast (no bean of expected type).Next: optimize.md — inefficient cures.