Failure-mode driven development
A while back I wrote about comment-driven development. I still do that: sketch the steps as comments first, then turn them into code when the flow is clear. It keeps me from coding half-formed ideas into a mess.
I also keep shipping the same kind of bug.
The comments look finished. Check stock, take money, send email, done. Then production hits the stuff I never wrote down. Stock vanishes between the check and the write. Payment lands and the library insert dies. The queue is down so the user paid and never gets a receipt.
So I started doing the same sketch habit in the other direction. Before the success path, I write what can break and what I do next. Call it failure-mode driven development if you want a name. Honestly I just call it "listing the ways this can hurt people" and then I code.
Same disclaimer as the CDD post: this is a teaching sketch. Ignore SOLID for a minute. Ignore whether PurchaseBook should be five classes. The point is the order of thought, not a production service.
What counts as a failure mode
A failure mode is specific. "Handle errors" is not one.
Useful:
- Balance already reduced, then
AddHistorythrows - Book looked available on read, gone at commit
- Purchase committed, email queue rejects the message
Useless:
- Something went wrong
- Handle exceptions
- Be careful with races
If I cannot say what recovery looks like, I do not understand the failure yet. That line sounds a bit preachy even as I type it, but it is still the test I use.
Sketch failures first
Same "purchase a book" shape as CDD, failures on top:
// Purchase a book
//
// Failure modes (write these first):
// - Book unavailable now, or becomes unavailable after the check
// - Balance insufficient, or balance changes after the check
// - Payment history write fails after balance was reduced
// - Library row fails after payment was recorded
// - Email queue is down after the purchase is committed
// - Partial commit if money and library are not in one transaction
//
// Recovery / decisions:
// - Unavailable or insufficient: fail fast, no side effects
// - Balance reduced + payment fails: put money back, or better: one DB transaction
// - Payment ok + library fails: reverse payment, or retry library with an outbox
// - Purchase committed + email fails: keep purchase, retry email later, do not unwind money
// - Money + library in one transaction; email stays async
//
// Happy path (only after the list above stops being wishful):
// - Check availability
// - Check balance
// - Deduct balance
// - Record payment
// - Add book to library
// - Enqueue confirmation email
Happy path is almost the same list as before. The difference is I already decided where atomicity lives, and which failures should slap the user versus which ones support can clean up later.
Then shape the code around that
I still start with comments. The first real code is often the transaction boundary and the ugly branches, not the pretty email template.
// Purchase a book
public async Task PurchaseBook(Book book)
{
// Money + ownership together. Email is outside this transaction on purpose.
await using var tx = await _dbContext.Database.BeginTransactionAsync();
try
{
// Unavailable (including races under concurrency)
if (!_bookService.IsAvailable(book))
{
throw new Exception($"{book.Title} is currently unavailable");
}
// Insufficient funds
if (!_userService.HasEnoughBalanceToPurchase(book.Price))
{
throw new Exception("You don't have enough balance");
}
_userService.ReduceBalance(book.Price);
var payment = _paymentService.AddHistory(new Payment
{
Status = PaymentStatus.Paid,
Amount = book.Price
});
_libraryService.Add(new Library
{
Book = book,
Payment = payment
});
await _dbContext.SaveChangesAsync();
await tx.CommitAsync();
// Queue can die after commit. Purchase stays; delivery retries.
try
{
var emailBody = _emailRenderService.Render(
EmailTemplate.PurchaseConfirmation,
payment);
_emailQueueService.Add(new EmailQueue
{
Recipient = _userService.GetEmail(),
Subject = "Thank you for your purchase",
Body = emailBody,
});
}
catch (Exception ex)
{
// In real code: log, outbox, alert. Here: do not roll back money for an email.
Console.WriteLine($"Purchase committed but email failed: {ex.Message}");
}
}
catch
{
await tx.RollbackAsync();
throw;
}
}
Still one fat method on purpose, same spirit as the CDD demo. Production code would split this. The lesson is not "ship a god method." The lesson is: you already knew email is allowed to fail after money moved, so you did not put the queue call inside the money transaction and then invent panic at 2am.
How I actually do it on a ticket
I name the feature in one line. Purchase a book. Export a report. Apply a coupon.
Then I spend a few minutes only on failure modes. Sticky notes, comments, whatever. No "real" code yet. For each mode I pick roughly one of: fail fast, retry, compensate, or ignore and measure.
I draw the all-or-nothing boundary next. Usually a database transaction. Sometimes a saga if the world is worse than that. I write that decision above the happy path so I cannot forget it when I am deep in autocomplete.
Only then do I fill the sunny steps with CDD-style comments and implement.
If the failure list is three vague bullets, I dig more. Real money or data flows always have more ways to die than I want to admit on a Monday.
When not to bother
This is not a 40 page threat model for a script that renames files. It is not "write every catch before lunch." A local PowerShell helper does not need an outbox.
Match the depth to the blast radius. If it moves money, deletes data, or sends the only copy of a receipt, do the list. If it does not, stop overthinking and ship.
CDD and this, together
CDD answers: what are the steps when everything works?
This answers: what are the steps when something does not?
I use both. CDD stops me from coding fog. The failure list stops me from shipping a function that looks finished while the user is half paid and half entitled.
When a bug report lands, I check the failure list first. If the mode was never written, that is the real bug. The design never considered it.
Quick questions I keep nearby
- What does the user see for this mode?
- Did any durable write already happen?
- Retry, compensate, or stop?
- Can email or a webhook fail after commit?
- Do two concurrent requests make this worse?
- Can support explain the outcome from logs alone?
If I cannot answer those without squinting, the happy path is not ready.
That's basically it
Start with comments if that is how you think. Just do not stop at the sunny path. Spend a few minutes on how the feature fails and how you climb out. You will still delete most of the comments later. The decisions those comments forced tend to stick.