CMPUT-274 · Lesson 12 of 12 · 30 min
Capstone: choosing and combining paradigms
The previous eleven lessons treated paradigms one at a time. Real programs combine them. A web service might use objects to model its domain, pure functions for its business rules, asynchronous code for its network I/O, a process pool for heavy computation, and an event-driven framework around everything. The skill this capstone develops is choosing the right paradigm for each part of a system and making the parts fit together. We work through the design of an invented application end to end, then distil the choices into guidelines you can apply to your own projects, including the larger team projects in CMPUT-301 and CMPUT-401.
The project: a trail conditions service
Northwind Parks, an invented regional park authority, wants a service that collects trail condition reports from rangers, weather stations and visitor submissions, cleans and combines them, and publishes a status for each trail: open, caution or closed. Reports arrive continuously from several sources over the network. Each report must be validated, because visitor submissions are often incomplete. The status rules change every season as the parks staff refine them. Rangers must be able to override a status, and every change must be auditable. Statistics for a monthly report involve heavy number crunching over a year of readings.
| Requirement | Characteristic | Paradigm that fits |
|---|---|---|
| Collect reports from many sources at once | I/O-bound, many waits | Asynchronous (async / await) |
| Validate messy submissions | Expected failures, frequent | Result values; parse at the boundary |
| Represent kinds of report | Fixed set of variants | Algebraic data types and pattern matching |
| Compute status from reports | Rules change often, must be testable | Pure functions; strategy functions |
| Trails with overrides and history | Identity and evolving state | Objects with encapsulated state; command log |
| Notify the website and alert subscribers | React to changes | Observer / event-driven |
| Monthly statistics over a year of data | CPU-bound, independent chunks | Process pool with map–reduce |
Step 1: data types at the boundary
Start with the data. A report is one of three kinds, each with different fields, so it is naturally an algebraic data type: a ranger report with an observed condition, a station reading with snow depth and temperature, or a visitor report with free text and an optional photo flag. Raw input arrives as dictionaries, from JSON, and is parsed once, at the boundary, into these immutable types. Parsing returns either a report or an error value, following lesson 8, because bad visitor submissions are expected and must not crash the collector.
from dataclasses import dataclass
from typing import Union
@dataclass(frozen=True)
class Ranger: trail: str; condition: str # 'clear', 'muddy', 'blocked'
@dataclass(frozen=True)
class Station: trail: str; snow_cm: float; temp_c: float
@dataclass(frozen=True)
class Visitor: trail: str; text: str; photo: bool = False
@dataclass(frozen=True)
class Invalid: raw: dict; reason: str
Report = Union[Ranger, Station, Visitor]
CONDITIONS = {"clear", "muddy", "blocked"}
def parse(raw: dict) -> Union[Report, Invalid]:
match raw:
case {"source": "ranger", "trail": str(t), "condition": str(c)} if c in CONDITIONS:
return Ranger(t, c)
case {"source": "station", "trail": str(t), "snow_cm": (int() | float()) as s, "temp_c": (int() | float()) as tc}:
return Station(t, float(s), float(tc))
case {"source": "visitor", "trail": str(t), "text": str(x)} if x.strip():
return Visitor(t, x.strip(), bool(raw.get("photo", False)))
case _:
return Invalid(raw, "unrecognized or incomplete report")
samples = [
{"source": "ranger", "trail": "Ridge Loop", "condition": "muddy"},
{"source": "station", "trail": "Ridge Loop", "snow_cm": 0, "temp_c": 4.5},
{"source": "visitor", "trail": "Lake Path", "text": " tree down near bridge ", "photo": True},
{"source": "visitor", "trail": "Lake Path", "text": " "},
{"source": "ranger", "trail": "Lake Path", "condition": "slippery"},
]
for s in samples:
print(parse(s))Ranger(trail='Ridge Loop', condition='muddy')
Station(trail='Ridge Loop', snow_cm=0.0, temp_c=4.5)
Visitor(trail='Lake Path', text='tree down near bridge', photo=True)
Invalid(raw={'source': 'visitor', 'trail': 'Lake Path', 'text': ' '}, reason='unrecognized or incomplete report')
Invalid(raw={'source': 'ranger', 'trail': 'Lake Path', 'condition': 'slippery'}, reason='unrecognized or incomplete report')Every later stage receives only well-formed `Ranger`, `Station` or `Visitor` values, so none of it needs to re-check fields. The two invalid submissions became `Invalid` values carrying the raw input and a reason, which can be logged and counted without interrupting the flow. Because the report types are frozen dataclasses, they can be shared freely between tasks, threads and processes, as lesson 9 recommended.
Step 2: a pure rules core
The status rules are the part that changes most often and matters most, so they belong in pure functions. A rule takes the recent reports for a trail and returns a status with a reason. Because rules are pure, they can be tested exhaustively with plain input and expected output, and the season's rules can be swapped by passing a different function: the strategy pattern of lesson 6 in its functional form.
from dataclasses import dataclass
@dataclass(frozen=True)
class Ranger: trail: str; condition: str
@dataclass(frozen=True)
class Station: trail: str; snow_cm: float; temp_c: float
@dataclass(frozen=True)
class Visitor: trail: str; text: str; photo: bool = False
SEVERITY = {"open": 0, "caution": 1, "closed": 2}
def judge(report): # one report -> (status, reason)
match report:
case Ranger(_, "blocked"): return ("closed", "ranger: blocked")
case Ranger(_, "muddy"): return ("caution", "ranger: muddy")
case Ranger(_, _): return ("open", "ranger: clear")
case Station(_, s, _) if s >= 30: return ("closed", f"snow {s:.0f} cm")
case Station(_, s, t) if s >= 5 or t < -15: return ("caution", f"snow {s:.0f} cm, {t:.0f} C")
case Station(_, _, _): return ("open", "station normal")
case Visitor(_, text, photo) if photo and "down" in text.lower():
return ("caution", "visitor photo: obstruction")
case Visitor(_, _, _): return ("open", "visitor note")
def summer_rules(reports): # worst single report wins
verdicts = [judge(r) for r in reports] or [("open", "no reports")]
return max(verdicts, key=lambda v: SEVERITY[v[0]])
def winter_rules(reports): # stations only; closed below -25 C regardless
stations = [r for r in reports if isinstance(r, Station)]
if any(r.temp_c <= -25 for r in stations):
return ("closed", "extreme cold")
return summer_rules(stations)
reports = [Ranger("Ridge Loop", "muddy"), Station("Ridge Loop", 2, 3.0),
Visitor("Ridge Loop", "Tree down by km 3", True)]
print("summer:", summer_rules(reports))
print("winter:", winter_rules(reports + [Station("Ridge Loop", 12, -27.0)]))
# pure functions make table-driven tests trivial
cases = [([Ranger("t", "blocked")], "closed"), ([Station("t", 40, -2)], "closed"),
([Station("t", 6, 0)], "caution"), ([], "open")]
assert all(summer_rules(r)[0] == want for r, want in cases)
print("all", len(cases), "rule tests pass")summer: ('caution', 'ranger: muddy')
winter: ('closed', 'extreme cold')
all 4 rule tests passStep 3: objects for identity, state and history
A trail, unlike a report, has an identity that persists while its state changes: the Ridge Loop is the same trail when it moves from open to closed. Rangers can override the computed status, and every change must be auditable. An object is the natural home for this. The `Trail` class encapsulates its current status and an append-only history, exposes methods that keep the invariant that every change is recorded, and notifies observers when its status changes. Each change is recorded as an immutable event value, which is the command pattern from lesson 6 used as an audit log.
from dataclasses import dataclass
@dataclass(frozen=True)
class Change: # an immutable audit record (command-style event)
trail: str; old: str; new: str; reason: str; by: str
class Trail:
def __init__(self, name):
self.name = name
self._status, self._override = "open", None
self._history: list[Change] = []
self._observers = []
def subscribe(self, fn): self._observers.append(fn)
@property
def status(self): return self._override or self._status
@property
def history(self): return tuple(self._history) # read-only view
def _record(self, old, reason, by):
if self.status != old:
ch = Change(self.name, old, self.status, reason, by)
self._history.append(ch)
for fn in self._observers: fn(ch)
def apply_rules(self, verdict): # verdict from the pure core
old = self.status; self._status = verdict[0]; self._record(old, verdict[1], "rules")
def override(self, status, ranger):
old = self.status; self._override = status; self._record(old, "manual override", ranger)
def clear_override(self, ranger):
old = self.status; self._override = None; self._record(old, "override cleared", ranger)
t = Trail("Ridge Loop")
t.subscribe(lambda ch: print(f"website: {ch.trail} is now {ch.new} ({ch.reason})"))
t.apply_rules(("caution", "ranger: muddy"))
t.override("closed", "ranger Okafor")
t.apply_rules(("open", "station normal")) # hidden by the override: no visible change
t.clear_override("ranger Okafor")
print([(c.old, c.new, c.by) for c in t.history])website: Ridge Loop is now caution (ranger: muddy)
website: Ridge Loop is now closed (manual override)
website: Ridge Loop is now open (override cleared)
[('open', 'caution', 'rules'), ('caution', 'closed', 'ranger Okafor'), ('closed', 'open', 'ranger Okafor')]Look at the division of labor. The rules core decides what the status should be and knows nothing about overrides, history or the website. The `Trail` object decides what the status is, keeps the record, and announces changes. Rule changes never touch the object, and changes to auditing never touch the rules. The third call, a rules update while the override is active, changes the underlying computed status but not the visible one, so no change is recorded or announced: the property `status` captures that policy in one place.
Step 4: an asynchronous shell
Reports arrive over the network from many sources at once. Collecting them is I/O-bound, so an asynchronous collector runs one task per source on a single event loop, parses each message at the boundary, and hands valid reports to the core. The shell is imperative and effectful, and deliberately thin: everything it does is either waiting, parsing or calling the pure core and the trail objects. This structure, a functional core with an imperative shell, is the central design idea of the capstone.
import asyncio
from collections import defaultdict
async def source(name, messages, delay, out):
"""Simulates a network feed: yields messages with a delay between them."""
for m in messages:
await asyncio.sleep(delay)
await out.put((name, m))
await out.put((name, None)) # end-of-feed marker
def parse(m): # boundary: keep only well-formed reports
ok = isinstance(m, dict) and isinstance(m.get("trail"), str) and m.get("status") in {"open", "caution", "closed"}
return (m["trail"], m["status"]) if ok else None
SEV = {"open": 0, "caution": 1, "closed": 2}
def decide(statuses): # pure core: worst status wins
return max(statuses, key=SEV.get)
async def main():
q = asyncio.Queue()
feeds = {
"rangers": [{"trail": "Ridge Loop", "status": "caution"}, {"trail": "Lake Path", "status": "open"}],
"stations": [{"trail": "Ridge Loop", "status": "open"}, {"trail": "Ridge Loop", "status": "closed"}],
"visitors": [{"trail": "Lake Path"}, {"trail": "Lake Path", "status": "caution"}],
}
tasks = [asyncio.create_task(source(n, ms, 0.01 * (i + 1), q)) for i, (n, ms) in enumerate(feeds.items())]
seen, rejected, done = defaultdict(list), 0, 0
while done < len(tasks):
name, m = await q.get()
if m is None:
done += 1; continue
parsed = parse(m)
if parsed is None:
rejected += 1; continue
seen[parsed[0]].append(parsed[1])
await asyncio.gather(*tasks)
for trail in sorted(seen):
print(f"{trail}: {decide(seen[trail])} from {sorted(seen[trail])}")
print("rejected messages:", rejected)
asyncio.run(main())Lake Path: caution from ['caution', 'open'] Ridge Loop: closed from ['caution', 'closed', 'open'] rejected messages: 1
Step 5: parallel batch statistics
The monthly report computes statistics over a year of station readings: for each trail, the number of days over a snow threshold, the longest closed streak and similar measures. The work is CPU-bound and splits naturally by trail, with no shared state between trails. That is the map–reduce shape from lessons 2 and 9: a pure function computes one trail's statistics, a process pool maps it over all trails in parallel, and a final reduction assembles the report. Because the per-trail function is pure, running it in another process, in any order, cannot change the answer.
from concurrent.futures import ProcessPoolExecutor
import random
def trail_stats(args): # pure: depends only on its arguments
trail, seed = args
rng = random.Random(seed) # deterministic synthetic year of readings
snow = [max(0.0, rng.gauss(10, 12)) for _ in range(365)]
closed_days = sum(s >= 30 for s in snow)
streak = best = 0
for s in snow:
streak = streak + 1 if s >= 30 else 0
best = max(best, streak)
return trail, closed_days, best, round(sum(snow) / 365, 1)
trails = [("Ridge Loop", 1), ("Lake Path", 2), ("Summit Trail", 3), ("Cedar Walk", 4)]
with ProcessPoolExecutor(max_workers=4) as pool:
rows = list(pool.map(trail_stats, trails)) # results in input order
print(f"{'trail':<13}{'days >= 30 cm':>14}{'longest run':>13}{'mean cm':>9}")
for t, days, run, mean in rows:
print(f"{t:<13}{days:>14}{run:>13}{mean:>9}")
print("worst trail:", max(rows, key=lambda r: r[1])[0])trail days >= 30 cm longest run mean cm Ridge Loop 21 1 12.3 Lake Path 18 1 11.1 Summit Trail 12 1 10.7 Cedar Walk 28 2 11.9 worst trail: Cedar Walk
Worked problem
Evaluating an alternative design
A teammate proposes making Report a class hierarchy with a status() method on each subclass, instead of a closed data type plus a pure judge function. Evaluate the proposal using the expression problem from lesson 4.
Show solution
- The set of report kinds is small and stable: rangers, stations and visitors.
- The set of operations on reports grows often: summer rules, winter rules, audit summaries, statistics, export formats.
- With classes, each new operation means editing every report class; with a data type, each new operation is one new function.
- Seasonal rule sets also need to coexist, which is awkward as methods but natural as separate functions passed as strategies.
Answer: Keep reports as a closed data type with pure functions. The proposal would suit a system where new report kinds arrive often and operations are fixed, which is the opposite of this one.
Guidelines for choosing paradigms
The design used each paradigm where its strengths matched a requirement, and kept the boundaries between them clean. The table below collects the guidelines that emerged. They are starting points, not rules: team experience, the libraries available and performance requirements can all shift the balance. What matters is that every choice has a reason you can state.
| If the part of the system… | Prefer | Because |
|---|---|---|
| transforms data and must be easy to test | Pure functions | Same input, same output; no setup or mocks |
| has a fixed set of data shapes and a growing set of operations | Algebraic data types + pattern matching | New operations are new functions |
| has a growing set of kinds with a fixed set of operations | Classes + dynamic dispatch | New kinds are new classes |
| has identity and state that changes over time | Encapsulated objects | One owner keeps invariants and history |
| waits on many network or disk operations | async / await | One thread overlaps many waits |
| computes heavily over independent pieces | Process pool, map–reduce over pure functions | True parallelism without shared state |
| must react to changes or events | Observer, events, reactive streams | Decouples producers from consumers |
| receives untrusted input | Parse at the boundary into types; result values for expected failures | Invalid states cannot spread |
Two cross-cutting principles tie the guidelines together. First, push effects to the edges: the further a piece of code is from input, output and shared state, the purer it should be, because pure code is the easiest to test, reuse, parallelize and reason about. Second, make illegal states unrepresentable: choose types, objects and boundaries so that invalid data cannot exist past the point where it enters, whether through frozen dataclasses, closed variants, encapsulated invariants or ownership rules. Both principles appeared in several paradigms during the course, which is a sign that they are deeper than any one paradigm.
Common mistakes
MistakeChoosing one paradigm for an entire system out of habit.
InsteadMatch each part to the paradigm that fits its requirements, and keep the boundaries between parts clear.
MistakeLetting effects, such as network calls or database writes, leak into business rules.
InsteadKeep rules pure and pass them data; perform effects in a thin outer shell.
MistakeModeling stable, closed data as an open class hierarchy, or vice versa.
InsteadUse the expression problem to decide whether variants or operations are more likely to grow.
MistakeSharing mutable objects between asynchronous tasks and worker processes.
InsteadPass immutable values across concurrency boundaries; let one owner mutate state.
MistakeValidating inputs repeatedly throughout the code.
InsteadParse once at the boundary into types whose existence guarantees validity.
Summary and key terms
- Real systems combine paradigms; the skill is matching each part to the paradigm that fits.
- Parse untrusted input once at the boundary into immutable algebraic data types, with invalid input represented as values.
- Keep frequently changing business rules in pure functions, swappable as strategies and tested with tables of cases.
- Use encapsulated objects for things with identity, evolving state and history, with immutable change records.
- Wrap the pure core in a thin, effectful shell: asynchronous I/O, events and observers.
- Use process pools and map–reduce over pure functions for CPU-heavy, independent work.
- Push effects to the edges and make illegal states unrepresentable; both principles cut across paradigms.
- multi-paradigm design
- A design that uses different paradigms for different parts of a system.
- functional core, imperative shell
- An architecture in which pure functions do the computation and a thin outer layer performs effects.
- boundary
- The point where external data enters a system and is parsed into internal types.
- audit log
- An append-only record of changes, useful for accountability and debugging.
- table-driven test
- A test that checks a function against a list of input and expected-output pairs.
- separation of concerns
- Dividing a system so that each part addresses one concern and changes for one reason.
- illegal state
- A combination of values that should never occur in a correct program.
- map–reduce
- Processing independent pieces with a function in parallel and combining the results.
- seam
- A place in a design where one implementation can be replaced by another, such as a strategy parameter.
- requirements mapping
- Relating each requirement to the design element and paradigm that satisfies it.
Practice problems
Work each problem on paper first, then reveal the answer to check.
Why are reports modeled as frozen dataclasses rather than mutable objects?
Show answer
They are facts about the past that should not change, and immutability makes them safe to share across tasks and processes.
Why is the rules core easy to test?
Show answer
Its functions are pure, so tests compare outputs for given inputs without setup, mocks or clean-up.
How would you add a spring rule set?
Show answer
Write a new pure function spring_rules(reports) and pass it where the season's rules are selected; no other code changes.
Why is Trail a class rather than a value?
Show answer
A trail has a persistent identity and evolving state with overrides and history, which an encapsulated object models directly.
What invariant does Trail maintain?
Show answer
Every visible status change is recorded in the history and announced to observers.
Why does the collector use async rather than processes?
Show answer
Collecting reports is I/O-bound waiting on many feeds; async overlaps the waits on one thread cheaply.
Why can trail_stats run safely in a process pool?
Show answer
It is pure and shares no state, so running it anywhere in any order gives the same results.
Name one place where the capstone uses the observer pattern.
Show answer
Trail.subscribe registers callbacks, such as the website update, that run on each status change.
What does functional core, imperative shell mean?
Show answer
Computation is done by pure functions in the core, and effects such as I/O are confined to a thin outer layer.
Give one way the design makes illegal states unrepresentable.
Show answer
For example, parsing produces only well-formed frozen report types, so later code never sees a report with missing fields.
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.