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.

Mapping requirements to paradigms
RequirementCharacteristicParadigm that fits
Collect reports from many sources at onceI/O-bound, many waitsAsynchronous (async / await)
Validate messy submissionsExpected failures, frequentResult values; parse at the boundary
Represent kinds of reportFixed set of variantsAlgebraic data types and pattern matching
Compute status from reportsRules change often, must be testablePure functions; strategy functions
Trails with overrides and historyIdentity and evolving stateObjects with encapsulated state; command log
Notify the website and alert subscribersReact to changesObserver / event-driven
Monthly statistics over a year of dataCPU-bound, independent chunksProcess pool with map–reduce
Architecture diagram. On the left, an asynchronous collector receives reports from rangers, stations and visitors. Reports pass through a parsing boundary that produces either valid reports or errors. Valid reports go into a pure rules core that computes statuses. The Trail objects store state and history and notify observers: the website and the alert service. A separate batch box shows a process pool computing monthly statistics.
A functional core inside an object-oriented and asynchronous shell

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.

Parsing raw dictionaries into an algebraic data type, with invalid input as a value
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))
Output
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.

Status rules as pure functions, swappable per season and tested with a table of cases
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")
Output
summer: ('caution', 'ranger: muddy')
winter: ('closed', 'extreme cold')
all 4 rule tests pass

Step 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.

Trail objects encapsulate state, keep an audit trail of immutable changes and notify observers
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])
Output
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 labour. 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.

An asynchronous collector: three feeds on one event loop, a parsing boundary and a pure decision core
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())
Output
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.

A pure per-trail function mapped over a process pool; synthetic data with fixed seeds
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])
Output
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
  1. The set of report kinds is small and stable: rangers, stations and visitors.
  2. The set of operations on reports grows often: summer rules, winter rules, audit summaries, statistics, export formats.
  3. With classes, each new operation means editing every report class; with a data type, each new operation is one new function.
  4. 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.

Paradigm selection guidelines from the capstone
If the part of the system…PreferBecause
transforms data and must be easy to testPure functionsSame input, same output; no setup or mocks
has a fixed set of data shapes and a growing set of operationsAlgebraic data types + pattern matchingNew operations are new functions
has a growing set of kinds with a fixed set of operationsClasses + dynamic dispatchNew kinds are new classes
has identity and state that changes over timeEncapsulated objectsOne owner keeps invariants and history
waits on many network or disk operationsasync / awaitOne thread overlaps many waits
computes heavily over independent piecesProcess pool, map–reduce over pure functionsTrue parallelism without shared state
must react to changes or eventsObserver, events, reactive streamsDecouples producers from consumers
receives untrusted inputParse at the boundary into types; result values for expected failuresInvalid 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.

  • MistakeModelling 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.

  1. Why are reports modelled 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.

  2. 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.

  3. 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.

  4. 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.

  5. What invariant does Trail maintain?

    Show answer

    Every visible status change is recorded in the history and announced to observers.

  6. 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.

  7. 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.

  8. 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.

  9. 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.

  10. 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.

Take the quiz