PYTH-270 · Lesson 12 of 12 · 26 min
Capstone: redesigning an application
▶ Video coming soon A video version of this lesson is on the way.
This capstone brings the course together on the problem it opened with. Ridgeline's process_order function has grown to 180 lines, scores 31 for cyclomatic complexity, mixes pricing, discounts, shipping, loyalty, payment, storage and email, has no tests, and breaks something every time it changes. Rosa needs subscriptions next month and the Alder Hotel integration after that. Lena, Jordan and Sam spend three weeks redesigning the order system without stopping the shop, and this lesson follows their work step by step.
Each step uses tools from an earlier lesson: characterization tests and seams (lesson 9), value objects and pure functions (lesson 3), strategies and a chain of rules (lesson 6), protocols and registries (lesson 7), ports, adapters, repositories and a service layer (lesson 8), an API with clear errors (lesson 10) and an ADR to record the plan (lesson 11). The exercises ask you to build the central pieces yourself.
Step 0: measure and decide
Before changing anything, the team measured the starting point so they could tell later whether the redesign had worked: function length 180 lines, complexity 31, zero tests, five modules importing process_order, and, from the issue tracker, 14 bugs in the order code over the past six months, 9 of them regressions caused by other changes. They also listed the changes they expected in the next year: subscriptions, the Alder Hotel API, a second payment provider for wholesale, and new promotions every season.
That list shaped the design. Pricing rules change constantly, so they should be open for extension. Payment providers will multiply, so payment should sit behind a port. Two new front ends are coming, so the checkout steps should live in one service rather than in each front end. Lena wrote these points into ADR 0006, Redesign the order system around a service layer, with the context, the decision and the expected consequences, including the cost: three weeks of work with no visible new features.
Step 1: pin down the current behavior
Following lesson 9, the first commit changed no behavior at all. Jordan added a parameter seam so process_order received the payment provider and the email sender as arguments with the old objects as defaults, then wrote a characterization suite: 240 recorded cases covering every customer type, every promotion, orders on both sides of the free-shipping threshold, wholesale terms and a few odd inputs. The golden master turned up three surprising behaviors. Rosa confirmed two were bugs, to be fixed after the redesign as separate, deliberate changes, and one was an intentional rule for the Alder Hotel that nobody had written down. It became ADR 0007.
Step 2: a domain model with value objects
Next came the domain core, written as pure, fully tested code with no knowledge of databases, payments or the web. Money became an immutable value object using Decimal (lesson 3). OrderLine became a frozen dataclass of SKU, quantity and unit price. Order became an entity with an id, a customer type, a list of lines and a status, with methods that enforce its invariants: an order cannot be placed without lines, quantities must be positive, and the same SKU on two lines is merged. The status transitions from lesson 6 moved into the same module.
from dataclasses import dataclass, field
from decimal import Decimal, ROUND_HALF_UP
CENT = Decimal("0.01")
def money(value):
return Decimal(str(value)).quantize(CENT, ROUND_HALF_UP)
@dataclass(frozen=True)
class OrderLine:
sku: str
qty: int
unit_price: Decimal
def amount(self):
return money(self.unit_price * self.qty)
@dataclass
class Order:
id: int
kind: str # "cafe", "online", "wholesale" or "subscription"
lines: list = field(default_factory=list)
status: str = "new"
def add(self, sku, qty, unit_price):
if qty <= 0:
raise ValueError("quantity must be positive")
for i, line in enumerate(self.lines):
if line.sku == sku:
self.lines[i] = OrderLine(sku, line.qty + qty, line.unit_price)
return
self.lines.append(OrderLine(sku, qty, money(unit_price)))
def subtotal(self):
return money(sum((line.amount() for line in self.lines), Decimal("0")))
order = Order(2201, "online")
order.add("RC-101", 2, "18.50")
order.add("RC-104", 1, "20.25")
order.add("RC-101", 1, "18.50")
print(order.lines)
print(order.subtotal())[OrderLine(sku='RC-101', qty=3, unit_price=Decimal('18.50')), OrderLine(sku='RC-104', qty=1, unit_price=Decimal('20.25'))]
75.75Try it yourself
The Order entity
Write a dataclass Order with fields id, kind and lines (a list of (sku, qty, unit_price) tuples, default empty). Add add(sku, qty, unit_price), which raises ValueError if qty is not positive and merges quantities when the SKU is already on the order, and subtotal(), which returns the total rounded to 2 decimal places.
Also add place(), which raises ValueError if the order has no lines and otherwise sets a status field (default "new") to "placed".
from dataclasses import dataclass, field
@dataclass
class Order:
id: int
kind: str
lines: list = field(default_factory=list)
def add(self, sku, qty, unit_price):
self.lines.append((sku, qty, unit_price))
def subtotal(self):
return sum(q * p for _, q, p in self.lines)Step 3: pricing as an open set of rules
The tangle of discount and shipping conditions became a pipeline of small pricing rules, each a pure function that takes the order and the running amount and returns a new amount (lessons 6 and 7). A registry decorator collects them in a declared order, so the stacking order that Anita cares about is explicit and testable. The wholesale discount, the promotion code, the subscription discount and the shipping fee are each a few lines with their own tests. Adding next season's promotion means adding one decorated function.
from decimal import Decimal
RULES = []
def pricing_rule(func):
RULES.append(func)
return func
@pricing_rule
def wholesale_discount(order, amount):
return amount * Decimal("0.85") if order["kind"] == "wholesale" else amount
@pricing_rule
def promo_code(order, amount):
return amount * Decimal("0.9") if order.get("code") == "WELCOME10" else amount
@pricing_rule
def online_shipping(order, amount):
if order["kind"] == "online" and amount < 50:
return amount + Decimal("8.95")
return amount
def price(order, subtotal):
amount = Decimal(str(subtotal))
for rule in RULES:
amount = rule(order, amount)
return amount.quantize(Decimal("0.01"))
print([r.__name__ for r in RULES])
print(price({"kind": "online", "code": "WELCOME10"}, 37.00))
print(price({"kind": "wholesale"}, 222.00))['wholesale_discount', 'promo_code', 'online_shipping'] 42.25 188.70
Try it yourself
Plug in the subscription rule
The setup holds a hidden pricing pipeline: a list RULES, a decorator pricing_rule, three existing rules and price(order, subtotal), which applies every rule in order and rounds to 2 decimal places. Amounts are floats here to keep things simple.
Add subscriptions without editing price: register a rule subscription_discount(order, amount) that takes 10 off every 100 (multiply by 0.9) when order["kind"] is "subscription", and a rule subscription_shipping(order, amount) that returns the amount unchanged (subscriptions always ship free, and the existing shipping rule applies only to online orders).
def subscription_discount(order, amount):
return amount * 0.9
print(price({"kind": "subscription"}, 40.0))Step 4: ports, adapters and the service
With the domain in place, the team defined the ports the order system needs (lesson 8): an OrderRepository with add and get, a PaymentPort with charge, and an event bus for things that should happen after an order, such as the confirmation email, the low-stock check and Anita's revenue log (lesson 6). Each port is a small Protocol (lesson 7). The existing SQLite code became a repository adapter, LumenPay and Northwind got payment adapters (lesson 5), and the email code became an event subscriber.
OrderService.place_order is now the single facade every front end calls. It builds the Order, asks the pricing pipeline for the total, charges the payment port, saves the order, publishes order_placed and returns the result. It contains the steps of the use case and nothing else: no SQL, no email formatting, no discount arithmetic. Its errors come from one hierarchy, OrderError with subclasses such as EmptyOrder and PaymentDeclined (lesson 10), so the web shop and the Alder Hotel API can respond appropriately.
Try it yourself
The order service
Write OrderService(orders, payments, events, prices). orders has add(order), payments has charge(amount) returning a receipt string, events has publish(name, data), and prices is a dictionary from SKU to unit price.
place_order(order_id, lines) takes a list of (sku, qty) pairs. Raise ValueError if lines is empty or a SKU is not in prices. Otherwise compute the total rounded to 2 decimal places, charge it, build the dictionary {"id": order_id, "lines": lines, "total": total, "receipt": receipt}, add it to orders, publish "order_placed" with that dictionary, and return it. Nothing must be charged if the order is invalid.
class OrderService:
def __init__(self, orders, payments, events, prices):
self.orders = orders
self.prices = prices
def place_order(self, order_id, lines):
total = sum(self.prices[s] * q for s, q in lines)
order = {"id": order_id, "lines": lines, "total": total}
self.orders.add(order)
return orderStep 5: strangle, then delete
The new service went live behind the strangler router from lesson 9. Wholesale orders moved first, because they are few, high in value and placed by people who would call if anything looked wrong. For a week, every wholesale order ran through both the old function and the new service, with only the old result used and any difference logged, a technique called a parallel run or shadow mode. Two differences appeared, both in rounding, both traced to the accountant's line-by-line rule in ADR 0004, and both fixed in the new code. Online orders followed, then cafe sales. Three weeks after the first commit, process_order was deleted.
The results
The team measured again. The longest function in the order system is now 22 lines and the highest complexity 6. There are 318 tests, which run in under two seconds because the domain and service tests use in-memory adapters. No module outside the composition root imports a concrete adapter. Subscriptions took four days instead of the three weeks Jordan had estimated before the redesign, and they required one new pricing rule, one new state machine and one new service method, with no changes to existing pricing code. In the two months since, there has been one bug in the order code and no regressions.
| Measure | Before | After |
|---|---|---|
| Longest function (lines) | 180 | 22 |
| Highest cyclomatic complexity | 31 | 6 |
| Automated tests | 0 | 318 |
| Time to run the tests | none | under 2 seconds |
| Modules importing order internals | 5 | 1 (the composition root) |
| Order bugs in the following two months | about 5 (typical) | 1 |
| Time to add subscriptions | estimated 3 weeks | 4 days |
Not everything improved. There are more files, and a newcomer must learn where things live: the domain package, the services, the adapters, the composition root. The ADRs and an updated README with a C4 container diagram (lesson 11) shorten that learning curve. The team judged the trade clearly worth it, and recorded that judgment, with the measurements, in a short follow-up note under ADR 0006.
A design review checklist
Lena left the team a checklist for reviewing any significant change, distilled from this course. It is deliberately short, and every question traces back to a lesson.
- Does each module, class and function have one clear job and one reason to change? (lessons 1 and 2)
- Are business rules pure functions and immutable values, separate from input and output? (lesson 3)
- Can the likely next change be made by adding code rather than editing working code? (lessons 2, 4 and 6)
- Are third-party libraries and infrastructure behind adapters and ports? (lessons 5 and 8)
- Do dependencies point inward, with wiring in one composition root? (lesson 8)
- Is the change covered by tests, and was existing behavior pinned down first? (lesson 9)
- Is the public interface small, consistent and hard to misuse, with clear errors? (lesson 10)
- Is any significant decision recorded in an ADR, and are the README and diagrams still true? (lesson 11)
Worked problem
Reviewing the Alder Hotel integration plan
Sam proposes the Alder Hotel API: a new web endpoint that parses the hotel's JSON, applies the hotel's special terms inside the endpoint, writes the order with SQL and emails the roastery. Lena asks him to apply the checklist.
Show solution
- One job: the endpoint does parsing, pricing, storage and email, so it should only parse and call the service.
- Rules: the hotel's special terms are a business rule, so they become a pricing rule in the domain, registered like any other.
- Adding, not editing: the new terms plug into the pricing registry; no existing rule changes.
- Adapters: the endpoint is a new driving adapter that calls OrderService.place_order; storage and email already have adapters.
- Tests and records: characterization is not needed for new code, but the endpoint gets tests with in-memory adapters, and ADR 0008 records the API format and versioning policy.
Answer: A thin endpoint that parses and calls OrderService, a new registered pricing rule for the hotel's terms, tests with in-memory adapters, and ADR 0008.
Common mistakes
MistakeStarting a redesign without measuring the current state.
InsteadRecord metrics, bug history and expected changes first, so you can judge the result.
MistakeRefactoring before the current behavior is pinned down.
InsteadAdd seams and characterization tests first.
MistakeDesigning for every imaginable change.
InsteadDesign for the changes you expect, listed from the business, and keep the rest simple.
MistakeSwitching all traffic to the new code at once.
InsteadRoute gradually with a strangler, and consider a parallel run to compare results.
MistakeFixing discovered bugs silently during the redesign.
InsteadRecord them, confirm with the business, and fix them as separate changes.
MistakeForgetting to delete the old code.
InsteadFinish the migration and remove the legacy path, or the system carries both forever.
Summary and key terms
- A redesign starts by measuring the current state and listing the changes the business expects.
- The plan and its trade-offs belong in an ADR before work begins.
- Seams and characterization tests come first, so the redesign preserves behavior.
- The domain core uses value objects, entities with invariants and pure functions.
- Frequently changing rules become an open pipeline of small, registered strategies.
- Ports, adapters and a service layer separate use cases from infrastructure.
- A strangler router and a parallel run move traffic gradually; then the old code is deleted.
- Measure again afterwards, and keep a short checklist for future reviews.
- baseline
- Measurements of the current state taken before a change, for comparison afterwards.
- domain core
- The pure, infrastructure-free heart of an application holding its business rules.
- pricing pipeline
- An ordered list of small rule functions that each adjust an amount.
- use case
- One thing a user wants to do with the system, such as placing an order.
- parallel run
- Running old and new code side by side and comparing results before switching.
- shadow mode
- Running new code on real traffic without using its results, to check them.
- regression
- A bug in behavior that used to work, introduced by a change.
- design review
- A structured examination of a design or change against agreed principles.
Practice problems
Work each problem on paper first, then reveal the answer to check.
Why did the team list expected changes before designing?
Show answer
Good design makes likely changes cheap, so the design depends on which changes are expected.
What was the first commit of the redesign, and why did it change no behavior?
Show answer
Seams and characterization tests; behavior had to be pinned down before structure could change safely.
Why are pricing rules registered in a list rather than written as one function?
Show answer
New rules can be added without editing existing ones, and the stacking order is explicit and testable.
What does OrderService.place_order contain, and what does it not contain?
Show answer
The steps of the use case; not SQL, email formatting or discount arithmetic.
Why move wholesale orders first?
Show answer
They are few and valuable, and customers would notice and report problems quickly, so risk was contained.
Name one cost of the redesign.
Show answer
More files and structure for newcomers to learn, plus three weeks without new features.
Lesson quiz · 5 questions
Score 60% or more to complete this lesson.
Create a free account to save your progress, take quizzes and earn a certificate.