Every pattern in this series fixes something. Before we fix anything, let’s meet the thing we’re fixing.
Say hello to CornerStore, a small fictional online shop. It sells running shoes, kitchenware, ebooks and gift cards to customers in Australia and New Zealand. It works. Every order is priced correctly, every refund lands, every report adds up.
And the code is a mess. On purpose.
Agenda:
- What CornerStore Does
- Why the Code Is Messy on Purpose
- A Taste of the Mess
- The 23 Smells
- The Safety Net
- Run It Yourself
What CornerStore Does
CornerStore is a .NET 10 console app with in-memory data. No database, no web host, no authentication. That’s deliberate. I wanted nothing on screen except the code each pattern changes.
Instead of a UI, the store runs a scripted trading day from a JSON file. Over 75 steps, it:
- browses a catalogue of nested categories, bundles and product variants
- bulk imports 100,000 product listings
- registers customers in two regions, AU and NZ
- builds carts, applies promo codes, picks shipping methods and add-ons, and undoes changes
- checks out with card, PayPal or Afterpay, books a courier and sends notifications
- ships, delivers, cancels and refunds orders
- lets back-office staff adjust stock
- exports sales reports as CSV and JSON
Here’s how the feature areas fit together:
flowchart LR
Catalog --> Cart
Cart --> Pricing
Pricing --> Checkout
Checkout --> Payments
Checkout --> Inventory
Checkout --> Courier
Checkout --> Orders
Orders --> Notifications
Orders --> Reporting
BackOffice[Back office] --> Inventory
BackOffice --> Orders
Every step writes business outcomes to a transcript. Here’s step 60, where Ava from Sydney finally checks out:
[060] checkout customer=cu-1 payment=card
card charged A$300.33 for Ava
ShipFast booked SF-0001 for o-1001 (30033 cents)
email to Ava: order o-1001 confirmed, total A$300.33
loyalty updated for Ava (Paid)
analytics order o-1001 Paid
checkout placed o-1001
Remember that transcript. It becomes very important in a minute.
Why the Code Is Messy on Purpose
If you’ve worked on enough projects, you’ve inherited code like this. Nobody set out to write it. It grew one reasonable change at a time: one more if, one more case, one more field copied by hand.
That’s exactly the shape I wanted. Each smell in CornerStore is a learning seam: a spot where a specific Gang of Four pattern is the natural fix.
Two rules kept it honest:
- Wrong in shape, never wrong in behaviour. There are no planted bugs. The mess is in how the code is organised, not in what it calculates.
- Realistic, not absurd. No 3,000-line methods or variables named
x2. Just the kind of code that passes a quick review and then slowly gets in everyone’s way.
Every smell is marked in the code with a comment, so you can find them:
// SMELL S10: the console coordinates every checkout subsystem in a fixed order
A Taste of the Mess
The star of the show is StoreConsole.cs. It’s 772 lines in a codebase of about 2,500, and it knows far too much.
Here’s part of its checkout method. Pricing, validation, payment, stock reservation, rollback and courier booking all happen in one place:
var customer = customers[cart.CustomerId];
var quote = pricing.BuildQuote(cart, customer);
var error = orderValidator.Validate(customer, cart, quote);
if (error is not null)
{
Log.Info("checkout rejected: " + error);
return;
}
var paymentType = Text(step, "payment");
if (!ChargePayment(paymentType, customer, quote.Total))
{
Log.Info("checkout rejected: unknown payment type " + paymentType);
return;
}
var order = orderService.Create(customer, cart, quote, paymentType);
var reservedLines = new List<CartLine>();
foreach (var line in order.Lines)
{
if (!inventory.ReserveStock(line.Product.Sku, line.Quantity))
{
foreach (var reservedLine in reservedLines)
{
inventory.AdjustStock(reservedLine.Product.Sku, reservedLine.Quantity);
}
Log.Info("checkout rejected: stock changed for " + line.Product.Sku);
return;
}
reservedLines.Add(line);
}
Count the smells hiding in there. ChargePayment builds payment processors with a switch (that’s Factory Method). The validator is one long nested method (Chain of Responsibility). And the whole sequence belongs somewhere other than the console (Facade).
That’s the theme of this series. Real code rarely has one problem at a time.
The 23 Smells
Here’s the full list, grouped the way the book groups the patterns that fix them.
Creational
| Smell | What’s wrong | Fixed by |
|---|---|---|
| S01 | Regional rules are scattered across tax, shipping and currency code | Abstract Factory |
| S02 | Orders and invoices use eleven-argument constructors | Builder |
| S03 | Checkout constructs payment processors in a switch | Factory Method |
| S04 | Product variants copy fields by hand | Prototype |
| S05 | Configuration and logging are mutable static state | Singleton |
Structural
| Smell | What’s wrong | Fixed by |
|---|---|---|
| S06 | Checkout depends directly on the courier’s XML API | Adapter |
| S07 | Message type and delivery channel multiply into separate classes | Bridge |
| S08 | Bundles follow a separate catalogue and pricing path | Composite |
| S09 | Add-ons are boolean flags and pricing branches | Decorator |
| S10 | The console coordinates every checkout subsystem | Facade |
| S11 | Imported listings duplicate the same product specifications | Flyweight |
| S12 | Inventory reads are uncached and access checks are scattered | Proxy |
Behavioral
| Smell | What’s wrong | Fixed by |
|---|---|---|
| S13 | Checkout validation is one nested method | Chain of Responsibility |
| S14 | Back-office actions run inline with no undo or audit | Command |
| S15 | Promo rules are parsed with string operations | Interpreter |
| S16 | Three copies of the same recursive tree walk | Iterator |
| S17 | Cart widgets update each other directly | Mediator |
| S18 | Cart internals are public so undo can copy them | Memento |
| S19 | Order transitions call every side-effect service | Observer |
| S20 | Order transitions branch on status strings | State |
| S21 | Shipping cost is picked with a switch | Strategy |
| S22 | CSV and JSON exporters duplicate the same workflow | Template Method |
| S23 | Tax, weight and reports switch on line-item types | Visitor |
The repository’s smells catalog lists the exact files for each smell and the trading-day steps that exercise it.
The Safety Net
Here’s the question every refactor should answer: how do you know you didn’t break anything?
CornerStore’s answer is a golden master test. The baseline’s transcript of the trading day, all 417 lines of it, was approved once and saved to a file. Every one of the 23 pattern solutions, and the capstone, runs the same trading day and must produce that transcript byte for byte.
To make that possible, the app is completely deterministic:
- time comes from a fixed
TimeProvider, neverDateTime.Now - IDs are sequential, never
Guid.NewGuid() - the random number generator is seeded from the scenario
- all formatting uses the invariant culture
That one test does a lot of heavy lifting. It means every “after” in this series is provably equivalent to its “before”. The patterns change the shape of the code, and the transcript proves nothing else changed.
It’s also a technique worth stealing for your own legacy code. Before refactoring something you don’t fully understand, capture what it does today. Then make sure it keeps doing exactly that.
Run It Yourself
From the repository root:
dotnet build baseline/CornerStore.Baseline.slnx
dotnet test baseline/CornerStore.Baseline.slnx
dotnet run --project baseline/src/CornerStore/CornerStore.csproj -- scenarios/trading-day.json
Then open baseline/src/CornerStore and search for SMELL. Try to guess which pattern fixes each one before you read the post.
Next up, we start the Creational patterns with Abstract Factory, and the first smell on the list: regional rules scattered all over the place.
Happy Coding!
Previous: Introduction · Next: Abstract Factory