Programming with PythonFiles, Errors, and Object-Oriented Python › Day 70

Hands-on lab — Day 70: Modeling a Domain with Objects

Commands

Setup

cd labs/sections/programming-with-python/day-070-modeling-a-domain-with-objects
python3 --version

Run

cat examples/domain-rules.md
cat starter/domain-worksheet.md
python3 examples/demo.py club.json
head -18 club.json
PYTHONPATH=examples python3 -c "import gym_core as g; print(g.Money(2900, 'EUR') + g.Money(4900, 'EUR'))"
python3 starter/demo.py club.json

Test

bash tests/run_tests.sh

File tree

examples/demo.py
examples/domain-rules.md
examples/gym_core.py
examples/gym_repository.py
expected-output/FIELDS.md
expected-output/sample-run.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/README.md
security.md
starter/demo.py
starter/domain-worksheet.md
starter/gym_core.py
starter/gym_repository.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 070 lab — Modelling Northside Gym

Lesson

Purpose

Day 70 is the day the week's parts become a method. This lab hands you what a real modelling job actually starts with: a page of plain-English rules from a gym owner — eleven numbered sentences and a short vocabulary list — and nothing else. No schema, no class diagram, no starter hints about which noun becomes which type.

Your job is to turn those sentences into a domain model: value objects that cannot hold an invalid value, entities identified by something that never changes, invariants enforced at the exact moment an object is built, a pure core that touches no file, a repository that hides JSON behind two methods, and a domain exception family the command-line adapter can translate into sentences a person can act on.

The domain is deliberately not the spending tracker from the lesson. The lesson models expenses because that is what the Week 10 project asks for; the lab models gym membership so that you practise the method on something new rather than copying an answer you have already seen. The two share not one line of code — only the discipline.

The design is what is being tested here, not just the functions. The suite proves your value objects are frozen, your entities compare by identity, every refusal belongs to one exception family, the round trip through JSON loses nothing, and — twice over, once by reading your imports and once by running your core from an empty directory — that the core genuinely cannot touch the outside world.

Learning objectives

  • Extract nouns and verbs from stated domain rules and decide which class owns each behaviour, before writing any code.
  • Classify each noun as an entity (identity, a life cycle) or a value object (defined entirely by its values, immutable) and justify each choice in one sentence.
  • Implement value objects as frozen dataclasses that validate in __post_init__ and refuse invalid values — a malformed membership number, a negative price, a mismatched currency, a backwards billing period.
  • Implement an entity whose equality and hash come from its identity, so it stays the same member after a rename, a plan switch and fifty check-ins.
  • Enforce a rule that spans two objects (the monthly check-in limit, the duplicate-number refusal) in the object that can see enough to check it.
  • Keep a domain core free of input and output, and demonstrate the payoff by testing every rule from an empty directory.
  • Put every storage decision in one repository class, and rebuild loaded values through the domain constructors so a hand-edited file is refused.
  • Fill in an anti-pattern checklist against your own design — god object, anemic model, primitive obsession, premature inheritance, stringly typed, leaky boundary.

Prerequisites

  • The Day 70 lesson (read it first — it walks the method this lab applies).
  • Day 69: dataclasses, field(default_factory=...), frozen=True, and type hints.
  • Day 68: composition versus inheritance, and the dunder protocols (__eq__, __hash__, __add__, __str__).
  • Day 67: classes, instances, __init__, methods and attributes.
  • Day 66: raising exceptions on purpose and designing an exception hierarchy.
  • Days 64–65: reading and writing text files, and JSON.
  • Day 63: the pure core and the thin shell, which this lab promotes from functions to types.
  • A text editor and a terminal. Nothing beyond this course is assumed.

Supported operating systems

  • macOS — fully supported (tested on macOS 26.5.1, Apple Silicon, Python 3.14.0, bash 3.2.57).
  • Linux — fully supported (any distribution with Python 3 and bash).
  • Windows — use WSL and follow the Linux path, or substitute python for python3. The roster header contains an em dash, so a UTF-8 terminal is needed for it to render; the numbers are unaffected either way.

Hardware requirements

Any computer that runs Python 3. The whole lab is under 1,200 lines of text and code, the club it builds has three members, and the JSON file it writes is about 1 KB. No special memory, disk, GPU, or network.

Required software

  • python3 (3.8 or newer; tested on 3.14.0). The lab uses dataclasses(frozen=True), enum.Enum, re, datetime, json and pathlib — all standard library.
  • bash for the test runner (preinstalled on macOS and Linux).
  • Nothing to install. See requirements/README.md.

Free and open-source options

Everything here is free and open source: Python, bash, and the standard library. No account, API key, network access, or purchase is needed at any point. The lesson's Alternatives section discusses sqlite3 (also standard library), SQLAlchemy and pydantic (both free and open source, installed with pip) — none of them is required to complete this lab, and the point of the repository boundary is precisely that you could adopt any of them later by rewriting one class.

Installation

None beyond Python itself. Move into this directory and you are ready:

cd labs/sections/programming-with-python/day-070-modeling-a-domain-with-objects
python3 --version   # confirm Python 3.8+ is available

File structure

day-070-modeling-a-domain-with-objects/
├── README.md                       ← you are here
├── metadata.yml                    ← machine-readable lab metadata
├── examples/
│   ├── domain-rules.md             ← the eleven rules and the owner's vocabulary
│   ├── gym_core.py                 ← reference pure domain core (no I/O)
│   ├── gym_repository.py           ← reference JSON repository (the only file-aware module)
│   └── demo.py                     ← reference CLI adapter: roster, round trip, refusals
├── starter/
│   ├── domain-worksheet.md         ← YOUR design work: nouns, classification, invariants, boundaries
│   ├── gym_core.py                 ← YOUR working file (exercises 1–6)
│   ├── gym_repository.py           ← YOUR working file (exercise 7)
│   └── demo.py                     ← provided complete; runs once your core is finished
├── tests/
│   └── run_tests.sh                ← behaviour checks; exits 0 only if all pass
├── expected-output/
│   ├── sample-run.txt              ← real captured session with the reference
│   ├── test-run.txt                ← real captured run of the test suite
│   └── FIELDS.md                   ← required behaviour on every platform
├── requirements/
│   └── README.md                   ← dependency statement (Python 3 only)
├── troubleshooting.md
└── security.md

Running either demo.py also creates club.json in whatever directory you run it from. It is safe to delete at any time.

How to run

From this directory:

## 1. Read the domain. This is the only specification you get.
cat examples/domain-rules.md

## 2. Model on paper first. Open the worksheet and fill in parts 1-4
##    BEFORE writing any code.
cat starter/domain-worksheet.md

## 3. See the finished reference: the roster, the monthly revenue, a JSON
##    round trip, and every rule being refused in turn.
python3 examples/demo.py club.json

## 4. Look at what the repository wrote — plain, readable data.
head -18 club.json

## 5. Call the pure core directly, with no adapter and no file in sight.
PYTHONPATH=examples python3 -c "import gym_core as g; print(g.Money(2900, 'EUR') + g.Money(4900, 'EUR'))"
PYTHONPATH=examples python3 -c "import gym_core as g; print(g.Money(100, 'EUR') + g.Money(100, 'USD'))"

## 6. Your task: complete exercises 1-6 in starter/gym_core.py, then
##    exercise 7 in starter/gym_repository.py.
python3 starter/demo.py club.json

## 7. Check your work.
bash tests/run_tests.sh

What the commands do

  • cat examples/domain-rules.md — prints the eleven rules the owner stated, plus the vocabulary list. Everything you model must come from these sentences, and nothing you model should come from anywhere else.
  • cat starter/domain-worksheet.md — the six-part design worksheet: nouns and verbs, the entity-versus-value-object classification, the invariant table with an enforcement point for each rule, the boundary table, the anti-pattern checklist, and a place to paste your evidence.
  • python3 examples/demo.py club.json — runs the reference program end to end. It prints the roster ordered by membership number, the monthly revenue summed into a single Money, the result of saving and reloading the club through the repository, and then six deliberate rule violations with the exact domain error each one raises.
  • head -18 club.json — shows the first member as the repository wrote them: a membership number, a name, an ISO date, a nested plan with whole cents and a currency code, and a list of check-in dates. Notice there is no Python in there — the file format is the repository's business, not the model's.
  • The two PYTHONPATH=examples one-liners — import the domain core with no adapter, no file and no program around it, and add two money amounts. The first succeeds and prints 78.00 EUR; the second raises CurrencyMismatch. That is the pure core being exercised directly, which is the property the boundary exists to give you.
  • python3 starter/demo.py club.json — the same reference adapter, driven by the core you wrote. demo.py in starter/ is provided complete on purpose: if you ever feel tempted to put a rule in it, that is the signal the rule belongs in gym_core.py.
  • bash tests/run_tests.sh — 33 checks while the starter is unfinished, 46 once you complete every exercise. Exits 0 only if all of them pass.

Expected output

See expected-output/sample-run.txt — a real captured session. The heart of it:

$ python3 examples/demo.py club.json
Northside Gym — roster
  GYM-0001  Ada    joined 2026-01-15  plan basic (29.00 EUR)  April check-ins: 4
  GYM-0002  Grace  joined 2026-02-01  plan plus (49.00 EUR)  April check-ins: 15
  GYM-0003  Ada    joined 2026-03-09  plan basic (29.00 EUR)  April check-ins: 0
  monthly revenue: 107.00 EUR

Saved to club.json and reloaded — round trip intact: True

Rules the model refuses to break
  a membership number that is not GYM-####: InvalidMembershipNumber: membership number must look like GYM-0001, got '0007'
  a negative price: InvalidMoney: money cannot be negative, got -100 cents
  adding dollars to euros: CurrencyMismatch: cannot add USD to EUR
  enrolling a number twice: DuplicateMember: GYM-0001 is already a member of Northside Gym
  a member the club never met: UnknownMember: GYM-9999 is not a member of Northside Gym
  a 13th basic check-in in one month: CheckInLimitExceeded: GYM-0003 is on the basic plan and already used all 12 check-ins in 2026-04
exit: 0

Nothing in this lab reads the clock, the network, or a random number, so the same commands produce the same bytes on any machine with Python 3. expected-output/FIELDS.md lists the exact required behaviour of every value object, entity, the repository, and the CLI adapter on every platform.

Validation steps

  1. python3 examples/demo.py club.json exits 0 and reports round trip intact: True.
  2. The roster shows two members named Ada with different membership numbers, treated as two different people — rule 3, visible in the output.
  3. The monthly revenue is 107.00 EUR. Check it by hand: 29.00 + 49.00 + 29.00. Because the amounts are whole cents, that sum is exact by construction, not by luck.
  4. Grace, on the plus plan, records fifteen April check-ins without complaint; the third member's thirteenth basic check-in in one month is refused with CheckInLimitExceeded.
  5. head -18 club.json shows plain JSON with price_cents: 2900 — an integer, never a float.
  6. The second PYTHONPATH=examples one-liner ends in gym_core.CurrencyMismatch: cannot add USD to EUR.
  7. Every row of starter/domain-worksheet.md is filled in — including part 5, the anti-pattern checklist, in sentences rather than single words.
  8. grep -nE 'import json|import os|from pathlib|open\(|print\(|input\(' starter/gym_core.py finds nothing. The suite checks this too, but do it yourself once.
  9. bash tests/run_tests.sh reports 0 failure(s). and exits 0.

Tests

bash tests/run_tests.sh

Expected final line while the starter is unfinished: 33 checks, 0 failure(s). Those 33 are the reference model, the reference repository, the boundary proofs, and a structural check that your starter is valid Python, defines every required class, and imports nothing that does I/O.

Once you complete all seven exercises the suite stops testing structure and holds your files to exactly the same strict standard as the reference, giving 46 checks, 0 failure(s). The command exits 0 on success and non-zero on any failure, so it can run in CI. A full captured run is in expected-output/test-run.txt.

The suite is worth reading before you run it. Two checks in particular explain the whole design: check_purity reads your core's imports and fails if it can reach the outside world, and check_core runs every model assertion from a directory created with mktemp -d that contains no files at all — so a rule that needs a file on disk cannot pass.

Cleanup

The lab writes only club.json, into whatever directory you ran a demo from:

rm -f club.json

To reset your work, restore the starter from git: git checkout -- starter/. The test runner makes its own temporary directories with mktemp -d and removes each one as that check finishes, so a completed run leaves nothing behind.

Troubleshooting

See troubleshooting.md for the full list: the NotImplementedError raised by every unfinished exercise, the FrozenInstanceError that means your value object is working correctly, the TypeError: unhashable type that follows defining __eq__ without __hash__, ModuleNotFoundError: No module named 'gym_core' when a file is run from the wrong directory, the recursion that comes of calling self == inside __eq__, why Money(True, "EUR") must be refused, and what to do when the purity check fails.

Security notes

See security.md. Short version: validation at construction is a security control — when the only way to obtain a Member is through code that checks the membership number, the price and the currency, a hand-edited file cannot inject a negative price or an unknown tier. The core imports nothing capable of I/O, so it physically cannot delete a file or open a socket whatever input it is handed. json.loads is safe by design; pickle is not. Membership records are personal data, and belong out of version control.

Extension exercises

  1. Add a third tier. Introduce a STUDENT tier with its own price and its own monthly check-in limit. Count the files you had to change. If it was more than the enum, the limits table and the plan construction, the closed set had leaked somewhere — and you have just found where.
  2. Add a second repository. Write a CsvClubRepository with the same save and load methods, storing one row per member. Then prove the swap is free: run the demo against each in turn and confirm the roster, the revenue and the round-trip check are identical. If the core needed even one edit, the boundary was not where you thought it was.
  3. Model billing. Add a report that uses the existing DateRange to produce the amount due for one member over one billing period. Decide deliberately — writing your reasoning in the worksheet — whether it belongs on Member, on Club, or in a separate report class. Then state the invariant your answer implies, in one sentence, and name the line that enforces it.
  4. Break the boundary on purpose. Add print("saving...") to starter/gym_core.py and run the tests. Watch the purity check fail, then remove it. Knowing exactly which check catches a leak is worth more than being told the rule.
  5. Refuse the future. Rule 7 says a check-in never changes, but nothing stops a check-in dated a hundred years from now. Decide whether that is a rule the owner stated (it is not) and write one sentence in the worksheet explaining why you did or did not add it. Restraint is part of modelling.
  • Previous day: Day 69 — Dataclasses and Type Hints (labs/sections/programming-with-python/day-069-dataclasses-and-type-hints/).
  • Next day: Day 71 — the first day of Week 11, Testing and Code Quality (labs/sections/programming-with-python/, to be written).
  • Week 10 project: the Expense Tracker (labs/sections/programming-with-python/projects/week-10/). It applies this exact method to the spending domain from the lesson: Money as a frozen value object, Category as an enum, Expense and Ledger as the model, a CSV importer and a repository as adapters, and a report layer on top.

Expected output

FIELDS.md

# Expected output — Day 070 lab

These are real captured runs from the authoring machine (macOS 26.5.1, Apple
Silicon, Python 3.14.0, bash 3.2.57, 2026-07-19). Nothing in this lab reads
the clock, the network, or a random number, so the same commands produce the
same bytes on any machine with Python 3.

## Files

- `sample-run.txt` — `python3 examples/demo.py club.json` driven end to end:
  the roster, the monthly revenue, the JSON round trip, and every domain rule
  being refused in turn; then the first eighteen lines of the JSON the
  repository wrote; then two direct calls into the pure core showing money
  adding within a currency and refusing across currencies.
- `test-run.txt` — a full run of `bash tests/run_tests.sh` with the starter
  exercises still unfinished (33 checks, 0 failures, exit 0). Absolute paths
  appear as `<repo>`; on your machine they are your real repository path.

## Required behaviour of the domain core

Your finished `starter/gym_core.py` must satisfy exactly this, with no files
present anywhere:

| Call | Result |
| --- | --- |
| `MembershipNumber("GYM-0007") == MembershipNumber("GYM-0007")` | `True` (value equality) |
| `MembershipNumber("GYM-0007").value = "GYM-9999"` | raises `dataclasses.FrozenInstanceError` |
| `MembershipNumber("0007")`, `"GYM-7"`, `"gym-0007"`, `"GYM-00007"`, `7` | each raises `InvalidMembershipNumber` |
| `Money(2900, "EUR") == Money(2900, "EUR")` | `True`; `Money(2900, "USD")` is not equal to it |
| `Money(-1, "EUR")`, `Money(1.5, "EUR")`, `Money(True, "EUR")`, `Money(100, "eur")`, `Money(100, "EURO")` | each raises `InvalidMoney` |
| `Money(2900, "EUR") + Money(4900, "EUR")` | `Money(7800, "EUR")`; `str(...)` is `78.00 EUR` |
| `Money(100, "EUR") + Money(100, "USD")` | raises `CurrencyMismatch` |
| `DateRange(date(2026, 4, 30), date(2026, 4, 1))` | raises `InvalidDateRange` |
| `DateRange(date(2026, 4, 1), date(2026, 4, 30)).contains(date(2026, 4, 30))` | `True`; `date(2026, 5, 1)` is `False` |
| `PlanTier("basic")` | `PlanTier.BASIC`; `PlanTier("pluss")` raises `ValueError` |
| two `Member`s with the same number but different names | compare equal, and land in the same set slot |
| a `Member` after a rename, a plan switch and a check-in | same `hash()` as before (identity is the number) |
| a basic member's 12th check-in in a month | succeeds; the 13th raises `CheckInLimitExceeded` |
| a plus member's 28 check-ins in a month | all succeed |
| `CheckIn(...).day = other_day` | raises `dataclasses.FrozenInstanceError` |
| enrolling `GYM-0001` twice | raises `DuplicateMember` |
| `club.find(MembershipNumber("GYM-9999"))` | raises `UnknownMember` |
| a club of 29.00 + 49.00 + 29.00 EUR plans | `monthly_revenue()` is `Money(10700, "EUR")` |
| every exception class above | is a subclass of `GymError` |

## Required behaviour of the repository

| Action | Result |
| --- | --- |
| `save(club)` then `load()` | a club with the same name, the same roster, the same revenue, and every check-in intact |
| the file on disk | plain JSON: `{"club": ..., "members": [{"number", "name", "joined_on", "plan", "check_ins"}]}` |
| `load()` on a file hand-edited to `"price_cents": -1` | raises `InvalidMoney` — values are rebuilt through the domain constructors |

## Required behaviour of the CLI adapter

| Command | Output | Exit code |
| --- | --- | --- |
| `python3 examples/demo.py club.json` | roster, `monthly revenue: 107.00 EUR`, `round trip intact: True`, and six refused rules | 0 |
| `python3 examples/demo.py /nonexistent-dir-xyz/club.json` | `error: [Errno 2] No such file or directory: ...` on standard error | 2 |

## Platform notes

- The only difference between macOS and Linux is the shell prompt (`$`) shown
  before each command in `sample-run.txt`; the program's own bytes are
  identical.
- The test runner writes only into directories made with `mktemp -d` and
  removes each one when that check finishes, so a failed run leaves nothing
  behind but the temporary directory of the check that crashed.
- `demo.py` writes `club.json` into whatever directory you run it from. The
  `Cleanup` section of the README removes it.
- On Windows, run everything inside WSL. The em dash and the `—` in the
  roster header need a UTF-8 terminal; on a legacy code page the roster text
  may render with replacement characters while the numbers stay correct.

sample-run.txt

$ python3 examples/demo.py club.json
Northside Gym — roster
  GYM-0001  Ada    joined 2026-01-15  plan basic (29.00 EUR)  April check-ins: 4
  GYM-0002  Grace  joined 2026-02-01  plan plus (49.00 EUR)  April check-ins: 15
  GYM-0003  Ada    joined 2026-03-09  plan basic (29.00 EUR)  April check-ins: 0
  monthly revenue: 107.00 EUR

Saved to club.json and reloaded — round trip intact: True

Rules the model refuses to break
  a membership number that is not GYM-####: InvalidMembershipNumber: membership number must look like GYM-0001, got '0007'
  a negative price: InvalidMoney: money cannot be negative, got -100 cents
  adding dollars to euros: CurrencyMismatch: cannot add USD to EUR
  enrolling a number twice: DuplicateMember: GYM-0001 is already a member of Northside Gym
  a member the club never met: UnknownMember: GYM-9999 is not a member of Northside Gym
  a 13th basic check-in in one month: CheckInLimitExceeded: GYM-0003 is on the basic plan and already used all 12 check-ins in 2026-04
exit: 0

$ head -18 club.json
{
  "club": "Northside Gym",
  "members": [
    {
      "number": "GYM-0001",
      "name": "Ada",
      "joined_on": "2026-01-15",
      "plan": {
        "tier": "basic",
        "price_cents": 2900,
        "currency": "EUR"
      },
      "check_ins": [
        "2026-04-03",
        "2026-04-05",
        "2026-04-08",
        "2026-04-10"
      ]

$ PYTHONPATH=examples python3 -c "import gym_core as g; print(g.Money(2900, 'EUR') + g.Money(4900, 'EUR'))"
78.00 EUR

$ PYTHONPATH=examples python3 -c "import gym_core as g; print(g.Money(100, 'EUR') + g.Money(100, 'USD'))"
    raise CurrencyMismatch(f"cannot add {other.currency} to {self.currency}")
gym_core.CurrencyMismatch: cannot add USD to EUR

test-run.txt

Testing the domain core in <repo>/labs/sections/programming-with-python/day-070-modeling-a-domain-with-objects/examples (no files, empty working directory) ...
  ok: MembershipNumber compares by value
  ok: MembershipNumber is immutable
  ok: MembershipNumber rejects a bad format
  ok: Money compares by value and is immutable
  ok: Money rejects negatives, floats and bad currencies
  ok: Money adds within a currency
  ok: Money refuses to add two currencies
  ok: DateRange rejects a backwards period and answers contains()
  ok: PlanTier is a closed set
  ok: Member equality is identity, not values
  ok: a Member stays the same member after every value changes
  ok: a basic member gets 12 check-ins a month, not 13
  ok: a plus member is unlimited
  ok: a CheckIn is frozen history
  ok: Club refuses duplicate numbers and unknown members
  ok: Club sums monthly revenue into one Money
  ok: every refusal is a GymError the adapter can catch
Testing the repository in <repo>/labs/sections/programming-with-python/day-070-modeling-a-domain-with-objects/examples (JSON round trip) ...
  ok: save then load returns an identical club
  ok: loading validates: a hand-edited bad file is refused
  ok: the JSON on disk is plain, readable data
Testing the boundary (the core must not be able to do I/O) ...
  ok: examples/gym_core.py imports nothing that does I/O
  ok: examples/demo.py runs end to end and exits 0
Testing starter/ ...
  ok: gym_core.py is valid Python
  ok: gym_repository.py is valid Python
  ok: starter/gym_core.py imports nothing that does I/O
Note: starter/ still has unfinished exercises — testing structure only.
  ok: starter defines MembershipNumber
  ok: starter defines Money
  ok: starter defines DateRange
  ok: starter defines Plan
  ok: starter defines CheckIn
  ok: starter defines Member
  ok: starter defines Club
  ok: starter defines JsonClubRepository

33 checks, 0 failure(s).

Source files

examples/demo.py (3701 bytes)
"""Northside Gym — the CLI ADAPTER (the outermost ring).

Run it:  python3 examples/demo.py [path-to-json]   (default: club.json)

This file does every messy thing the core refuses to do: it prints, it takes
a command-line argument, it decides an exit code, and it turns domain errors
into sentences a human can act on. It holds no rules of its own — every rule
it appears to apply is really enforced one layer in, by the model.
"""

import sys
from datetime import date

from gym_core import (
    Club,
    GymError,
    Member,
    MembershipNumber,
    Money,
    Plan,
    PlanTier,
)
from gym_repository import JsonClubRepository

BASIC = Plan(PlanTier.BASIC, Money(2900, "EUR"))
PLUS = Plan(PlanTier.PLUS, Money(4900, "EUR"))


def build_club():
    """Create a small, fixed club. Pure: no files, no clock, no randomness."""
    club = Club("Northside Gym")
    club.enroll(Member(MembershipNumber("GYM-0001"), "Ada", date(2026, 1, 15), BASIC))
    club.enroll(Member(MembershipNumber("GYM-0002"), "Grace", date(2026, 2, 1), PLUS))
    club.enroll(Member(MembershipNumber("GYM-0003"), "Ada", date(2026, 3, 9), BASIC))
    for day in (3, 5, 8, 10):
        club.check_in(MembershipNumber("GYM-0001"), date(2026, 4, day))
    for day in range(1, 16):
        club.check_in(MembershipNumber("GYM-0002"), date(2026, 4, day))
    return club


def print_roster(club):
    print(f"{club.name} — roster")
    for member in club.roster():
        print(
            f"  {member.number}  {member.name:<6} joined {member.joined_on}  "
            f"plan {member.plan}  April check-ins: {member.check_ins_in_month(2026, 4)}"
        )
    print(f"  monthly revenue: {club.monthly_revenue()}")


def show_rules_being_enforced(club):
    """Break each rule on purpose and print what the model says. Adapters do this."""
    print("Rules the model refuses to break")
    attempts = [
        ("a membership number that is not GYM-####", lambda: MembershipNumber("0007")),
        ("a negative price", lambda: Money(-100, "EUR")),
        ("adding dollars to euros", lambda: Money(100, "EUR") + Money(100, "USD")),
        ("enrolling a number twice", lambda: club.enroll(
            Member(MembershipNumber("GYM-0001"), "Impostor", date(2026, 5, 1), BASIC))),
        ("a member the club never met", lambda: club.find(MembershipNumber("GYM-9999"))),
        ("a 13th basic check-in in one month", lambda: [
            club.check_in(MembershipNumber("GYM-0003"), date(2026, 4, d)) for d in range(1, 14)
        ]),
    ]
    for label, attempt in attempts:
        try:
            attempt()
        except GymError as err:
            print(f"  {label}: {type(err).__name__}: {err}")
        else:
            print(f"  {label}: NOT REFUSED — the model has a hole")


def main(argv):
    path = argv[1] if len(argv) > 1 else "club.json"
    try:
        club = build_club()
        print_roster(club)
        print()

        repository = JsonClubRepository(path)
        repository.save(club)
        reloaded = repository.load()
        same = [
            reloaded.name == club.name,
            reloaded.roster() == club.roster(),
            reloaded.monthly_revenue() == club.monthly_revenue(),
            reloaded.find(MembershipNumber("GYM-0002")).check_ins_in_month(2026, 4) == 15,
        ]
        print(f"Saved to {path} and reloaded — round trip intact: {all(same)}")
        print()

        show_rules_being_enforced(club)
    except GymError as err:
        print(f"error: {err}", file=sys.stderr)
        return 1
    except OSError as err:
        print(f"error: {err}", file=sys.stderr)
        return 2
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
examples/domain-rules.md (2705 bytes)
# The domain: Northside Gym membership

This is the page of plain-English rules a gym owner gave you. Everything you
model must come from these sentences — and nothing you model should come from
anywhere else. Read them twice before you write a line of code.

## The rules, as the owner stated them

1. The club is called **Northside Gym**. It keeps a list of its members.
2. Every **member** has a **membership number**, a name, and the date they
   joined. The membership number is written `GYM-` followed by exactly four
   digits, for example `GYM-0007`. No two members ever share a number, and a
   member's number never changes for as long as they are a member.
3. Two members with the same name are still two different people. Two records
   with the same membership number are the same member.
4. Every member is on exactly one **plan** at a time. A plan has a **tier** —
   either **basic** or **plus** — and a **monthly price**.
5. A **price** is an amount of money in a currency. All of Northside Gym's
   prices are in euros (`EUR`). A price is never negative, and you can never
   add a euro amount to a dollar amount.
6. A member on the **basic** tier may **check in** at most **12 times in any
   one calendar month**. A member on the **plus** tier may check in as often
   as they like.
7. A **check-in** records which member came in and on which day. A check-in,
   once it has happened, never changes.
8. A member may **switch plans** at any time. Switching does not erase the
   check-ins they already have.
9. A **billing period** is a start date and an end date. The start date must
   be on or before the end date.
10. The club's **monthly revenue** is the sum of the monthly prices of every
    member's current plan. It is one money amount, in one currency.
11. Anything the rules forbid — an unknown membership number, a duplicate
    enrolment, a thirteenth basic check-in in a month, a negative price,
    adding two currencies — must be **refused with a clear error**, not
    silently allowed or silently ignored.

## Vocabulary the owner uses

These are the words that must appear in your code, spelled the way the owner
spells them: *club*, *member*, *membership number*, *plan*, *tier*, *basic*,
*plus*, *price*, *check in*, *switch plan*, *billing period*, *monthly
revenue*. If your code calls a member a "user record" or a plan a "type
string", the owner can no longer read it, and neither will you in six months.

## What is deliberately not here

The rules say nothing about payments, refunds, lockers, classes, trainers, or
a website. Do not model any of them. Building what the rules do not ask for is
the fastest way to make a small model unmaintainable.
examples/gym_core.py (9556 bytes)
"""Northside Gym — the PURE DOMAIN CORE.

This module is the whole model of the gym: its value objects, its entities,
its invariants, and its errors. It is deliberately free of input and output.

Read the import list: `dataclasses`, `datetime`, `enum`, `re`. There is no
`json`, no `pathlib`, no `open()`, no `print()`, no `input()`, no `os`. That
is not an accident and it is not a style preference — it is the design. A
module that cannot touch a file can be tested with nothing but function
calls, can never corrupt data by surprise, and can be reused behind a CLI, a
web app, or a test harness without changing a line.

Everything here maps back to a sentence in `domain-rules.md`.
"""

import re
from dataclasses import dataclass, field
from datetime import date
from enum import Enum

# ---------------------------------------------------------------------------
# Errors are part of the model (rule 11)
# ---------------------------------------------------------------------------


class GymError(Exception):
    """Base class for every rule this domain refuses to break.

    An adapter (the CLI, a web handler, a test) can catch `GymError` and know
    it is holding a *domain* problem — a broken rule — rather than a bug or a
    disk failure.
    """


class InvalidMembershipNumber(GymError):
    """A membership number was not of the form GYM-#### (rule 2)."""


class InvalidMoney(GymError):
    """A money amount was negative or its currency code was malformed (rule 5)."""


class CurrencyMismatch(GymError):
    """Two money amounts in different currencies were combined (rule 5)."""


class InvalidDateRange(GymError):
    """A billing period ended before it started (rule 9)."""


class DuplicateMember(GymError):
    """A membership number already in the club was enrolled again (rule 2)."""


class UnknownMember(GymError):
    """A membership number that the club has never seen was used."""


class CheckInLimitExceeded(GymError):
    """A basic-tier member tried to check in a 13th time in one month (rule 6)."""


# ---------------------------------------------------------------------------
# Value objects: defined entirely by their values, immutable, compared by value
# ---------------------------------------------------------------------------


class PlanTier(Enum):
    """The closed set of plan tiers (rule 4).

    A string would let `Plan(tier="pluss", ...)` sail through and fail months
    later. An enum makes the typo impossible: `PlanTier("pluss")` raises
    immediately, and the set of legal tiers is written down in one place.
    """

    BASIC = "basic"
    PLUS = "plus"


MEMBERSHIP_NUMBER_PATTERN = re.compile(r"^GYM-\d{4}$")
CURRENCY_PATTERN = re.compile(r"^[A-Z]{3}$")

#: How many check-ins each tier allows per calendar month. `None` = unlimited.
CHECKIN_LIMITS = {PlanTier.BASIC: 12, PlanTier.PLUS: None}


@dataclass(frozen=True)
class MembershipNumber:
    """A membership number, e.g. GYM-0007 (rule 2).

    A value object: two membership numbers with the same text ARE the same
    membership number. Frozen, so it can never drift after validation.
    """

    value: str

    def __post_init__(self):
        if not isinstance(self.value, str) or not MEMBERSHIP_NUMBER_PATTERN.match(self.value):
            raise InvalidMembershipNumber(
                f"membership number must look like GYM-0001, got {self.value!r}"
            )

    def __str__(self):
        return self.value


@dataclass(frozen=True)
class Money:
    """An amount of money in one currency (rule 5).

    Stored in whole cents, because binary floats cannot represent 0.10
    exactly and money you cannot add up exactly is money you have lost.
    Frozen and compared by value: Money(1000, "EUR") == Money(1000, "EUR").
    """

    cents: int
    currency: str

    def __post_init__(self):
        if not isinstance(self.cents, int) or isinstance(self.cents, bool):
            raise InvalidMoney(f"money must be a whole number of cents, got {self.cents!r}")
        if self.cents < 0:
            raise InvalidMoney(f"money cannot be negative, got {self.cents} cents")
        if not isinstance(self.currency, str) or not CURRENCY_PATTERN.match(self.currency):
            raise InvalidMoney(
                f"currency must be a three-letter code like EUR, got {self.currency!r}"
            )

    @staticmethod
    def zero(currency):
        """The additive identity for a currency: Money.zero('EUR')."""
        return Money(0, currency)

    def __add__(self, other):
        if not isinstance(other, Money):
            return NotImplemented
        if other.currency != self.currency:
            raise CurrencyMismatch(f"cannot add {other.currency} to {self.currency}")
        return Money(self.cents + other.cents, self.currency)

    def __str__(self):
        return f"{self.cents // 100}.{self.cents % 100:02d} {self.currency}"


@dataclass(frozen=True)
class DateRange:
    """A billing period: a start date and an end date, inclusive (rule 9)."""

    start: date
    end: date

    def __post_init__(self):
        if self.start > self.end:
            raise InvalidDateRange(f"billing period {self.start} .. {self.end} ends before it starts")

    def contains(self, day):
        """True if `day` falls inside the period (both ends included)."""
        return self.start <= day <= self.end


@dataclass(frozen=True)
class Plan:
    """A tier plus its monthly price (rule 4).

    A plan is a value object: two basic plans at 29.00 EUR are the same plan.
    Nothing about a plan has a life story of its own, so it needs no identity.
    """

    tier: PlanTier
    monthly_price: Money

    @property
    def checkin_limit(self):
        """Check-ins allowed per calendar month; None means unlimited (rule 6)."""
        return CHECKIN_LIMITS[self.tier]

    def __str__(self):
        return f"{self.tier.value} ({self.monthly_price})"


@dataclass(frozen=True)
class CheckIn:
    """A member walked in on a day (rule 7). Frozen: history never changes."""

    member: MembershipNumber
    day: date


# ---------------------------------------------------------------------------
# Entities: they have identity and a life cycle
# ---------------------------------------------------------------------------


@dataclass(eq=False)
class Member:
    """A person who belongs to the club (rules 2, 3, 6, 8).

    An ENTITY. `eq=False` turns off the dataclass's value equality on purpose
    so that identity — the membership number — decides who is who. Change a
    member's name, add fifty check-ins, switch their plan: it is still the
    same member, exactly as rule 3 says.
    """

    number: MembershipNumber
    name: str
    joined_on: date
    plan: Plan
    check_ins: list = field(default_factory=list)

    def __eq__(self, other):
        if not isinstance(other, Member):
            return NotImplemented
        return self.number == other.number

    def __hash__(self):
        return hash(self.number)

    def switch_plan(self, new_plan):
        """Move this member onto another plan, keeping their history (rule 8)."""
        self.plan = new_plan

    def check_ins_in_month(self, year, month):
        """How many times this member checked in during one calendar month."""
        return sum(1 for c in self.check_ins if c.day.year == year and c.day.month == month)

    def check_in(self, day):
        """Record a visit, enforcing the tier's monthly limit (rule 6).

        Raises CheckInLimitExceeded rather than quietly dropping the visit.
        """
        limit = self.plan.checkin_limit
        if limit is not None and self.check_ins_in_month(day.year, day.month) >= limit:
            raise CheckInLimitExceeded(
                f"{self.number} is on the {self.plan.tier.value} plan "
                f"and already used all {limit} check-ins in {day.year}-{day.month:02d}"
            )
        visit = CheckIn(self.number, day)
        self.check_ins.append(visit)
        return visit


@dataclass
class Club:
    """The gym itself (rules 1, 10) — the one door into the model.

    Every change to a member goes through the club, so the club is the single
    place where "no duplicate numbers" and "no unknown members" are enforced.
    """

    name: str
    members: dict = field(default_factory=dict)

    def enroll(self, member):
        """Add a new member, refusing a number already in use (rule 2)."""
        key = member.number.value
        if key in self.members:
            raise DuplicateMember(f"{key} is already a member of {self.name}")
        self.members[key] = member
        return member

    def find(self, number):
        """Look a member up by membership number, or refuse."""
        key = number.value if isinstance(number, MembershipNumber) else str(number)
        if key not in self.members:
            raise UnknownMember(f"{key} is not a member of {self.name}")
        return self.members[key]

    def check_in(self, number, day):
        """Record a visit for one member (rules 6, 7)."""
        return self.find(number).check_in(day)

    def monthly_revenue(self, currency="EUR"):
        """Sum every member's current monthly price into one amount (rule 10)."""
        total = Money.zero(currency)
        for member in sorted(self.members):
            total = total + self.members[member].plan.monthly_price
        return total

    def roster(self):
        """Members in membership-number order — deterministic, for reports."""
        return [self.members[key] for key in sorted(self.members)]
examples/gym_repository.py (3225 bytes)
"""Northside Gym — the PERSISTENCE ADAPTER (a repository).

This module is the *only* place in the lab that knows the club is stored as
JSON on disk. It imports `json` and `pathlib`; the core imports neither.

The point of the split: if the owner later wants SQLite, or a different JSON
shape, or a remote service, you rewrite this one class and the domain core
never notices. That is what "persistence is a boundary concern" means in
practice.
"""

import json
from datetime import date
from pathlib import Path

from gym_core import (
    CheckIn,
    Club,
    Member,
    MembershipNumber,
    Money,
    Plan,
    PlanTier,
)


class JsonClubRepository:
    """Loads and saves a Club as a JSON file.

    Two public methods — `save` and `load` — plus two private translators.
    The translators exist because the domain objects are the shape the *rules*
    want, and JSON is the shape the *file format* wants; keeping the mapping
    in one place stops file concerns from leaking into the model.
    """

    def __init__(self, path):
        self.path = Path(path)

    # -- domain -> plain data -------------------------------------------------

    @staticmethod
    def _member_to_dict(member):
        return {
            "number": member.number.value,
            "name": member.name,
            "joined_on": member.joined_on.isoformat(),
            "plan": {
                "tier": member.plan.tier.value,
                "price_cents": member.plan.monthly_price.cents,
                "currency": member.plan.monthly_price.currency,
            },
            "check_ins": [c.day.isoformat() for c in member.check_ins],
        }

    # -- plain data -> domain -------------------------------------------------

    @staticmethod
    def _member_from_dict(raw):
        number = MembershipNumber(raw["number"])
        plan = Plan(
            PlanTier(raw["plan"]["tier"]),
            Money(raw["plan"]["price_cents"], raw["plan"]["currency"]),
        )
        member = Member(
            number=number,
            name=raw["name"],
            joined_on=date.fromisoformat(raw["joined_on"]),
            plan=plan,
        )
        member.check_ins = [CheckIn(number, date.fromisoformat(d)) for d in raw["check_ins"]]
        return member

    # -- the two operations the rest of the program is allowed to call --------

    def save(self, club):
        """Write the whole club to `self.path` as JSON. Deterministic order."""
        payload = {
            "club": club.name,
            "members": [self._member_to_dict(m) for m in club.roster()],
        }
        self.path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
        return self.path

    def load(self):
        """Read `self.path` back into a fully valid Club.

        Every value goes back through the domain constructors, so a hand-edited
        file with a bad membership number or a negative price is rejected here
        rather than silently poisoning the model.
        """
        raw = json.loads(self.path.read_text(encoding="utf-8"))
        club = Club(raw["club"])
        for member_raw in raw["members"]:
            club.enroll(self._member_from_dict(member_raw))
        return club
metadata.yml (917 bytes)
lesson_id: D070
day: 70
kind: python-program
languages: [python]
setup_commands:
  - cd labs/sections/programming-with-python/day-070-modeling-a-domain-with-objects
  - python3 --version
run_commands:
  - cat examples/domain-rules.md
  - cat starter/domain-worksheet.md
  - python3 examples/demo.py club.json
  - head -18 club.json
  - PYTHONPATH=examples python3 -c "import gym_core as g; print(g.Money(2900, 'EUR') + g.Money(4900, 'EUR'))"
  - python3 starter/demo.py club.json
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - rm -f club.json
  - 'git checkout -- starter/  # optional: reset your work'
requires_network: false
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-19'
executed_on: 'macOS 26.5.1 (Apple Silicon), Python 3.14.0, bash 3.2.57 — bash tests/run_tests.sh -> 33 checks, 0 failure(s), exit 0 (46 checks, 0 failure(s) with the starter exercises completed)'
requirements/README.md (2523 bytes)
# Dependencies — Day 070 lab

**Python 3 only. No third-party packages, no network, no API key.**

- `python3` (3.8 or newer; tested on 3.14.0). You set this up on Day 43.
- `bash` for the test runner (preinstalled on macOS and Linux).
- Standard library only, and the split is the lesson in miniature:

| Module | Used in | Why |
| --- | --- | --- |
| `dataclasses` | `gym_core.py` | `frozen=True` value objects, `field(default_factory=list)`, `__post_init__` validation |
| `enum` | `gym_core.py` | `PlanTier` as a closed set, so a misspelled tier is impossible |
| `datetime` | `gym_core.py`, `gym_repository.py`, `demo.py` | real `date` objects, never date-shaped strings |
| `re` | `gym_core.py` | the membership-number pattern `GYM-####` |
| `json` | `gym_repository.py` **only** | the storage format — a repository concern, never a model concern |
| `pathlib` | `gym_repository.py` **only** | reading and writing the file |
| `sys` | `demo.py` **only** | command-line arguments, standard error, exit codes |

Read that table as a picture of the boundary. The four modules the core uses
can all be described as "ways of writing down a value". The three it does not
use can all be described as "ways of touching the world". That separation is
not a style preference — it is why the test suite can run every rule of this
gym from a directory containing no files at all.

There is deliberately no `requirements.txt`. Python ships everything a small
domain model needs, and adding a dependency to a hundred-and-eighty-line
model is a habit worth resisting.

The lesson's Alternatives section covers what you would reach for when a model
outgrows this: `sqlite3` (also standard library, so still nothing to install),
SQLAlchemy for a real relational schema, and pydantic for validating untrusted
input at a boundary. Both of those last two are free and open source and
install with `pip`. None is needed here, and the point of the repository class
is that adopting one later means rewriting one file.

Check your Python is present and new enough:

```bash
python3 --version
```

If that prints `Python 3.8` or higher, you are ready.

Windows users: run the commands inside WSL, or use `python` in place of
`python3` if that is how Python is exposed on your system. One cosmetic
difference is worth knowing: the roster header printed by `demo.py` contains
an em dash, so a terminal set to a legacy code page may show a replacement
character there. The numbers, the errors, and the JSON are byte-identical
everywhere.
starter/demo.py (3965 bytes)
"""Northside Gym — the CLI ADAPTER (the outermost ring).

Run it:  python3 starter/demo.py [path-to-json]   (default: club.json)

This file does every messy thing the core refuses to do: it prints, it takes
a command-line argument, it decides an exit code, and it turns domain errors
into sentences a human can act on. It holds no rules of its own — every rule
it appears to apply is really enforced one layer in, by the model.

Provided complete — you do not need to edit this file. It is here so that the
moment your core and repository are finished, you have a working program. If
you ever feel tempted to put a rule in here, that is the signal the rule
belongs in `gym_core.py` instead.
"""

import sys
from datetime import date

from gym_core import (
    Club,
    GymError,
    Member,
    MembershipNumber,
    Money,
    Plan,
    PlanTier,
)
from gym_repository import JsonClubRepository

BASIC = Plan(PlanTier.BASIC, Money(2900, "EUR"))
PLUS = Plan(PlanTier.PLUS, Money(4900, "EUR"))


def build_club():
    """Create a small, fixed club. Pure: no files, no clock, no randomness."""
    club = Club("Northside Gym")
    club.enroll(Member(MembershipNumber("GYM-0001"), "Ada", date(2026, 1, 15), BASIC))
    club.enroll(Member(MembershipNumber("GYM-0002"), "Grace", date(2026, 2, 1), PLUS))
    club.enroll(Member(MembershipNumber("GYM-0003"), "Ada", date(2026, 3, 9), BASIC))
    for day in (3, 5, 8, 10):
        club.check_in(MembershipNumber("GYM-0001"), date(2026, 4, day))
    for day in range(1, 16):
        club.check_in(MembershipNumber("GYM-0002"), date(2026, 4, day))
    return club


def print_roster(club):
    print(f"{club.name} — roster")
    for member in club.roster():
        print(
            f"  {member.number}  {member.name:<6} joined {member.joined_on}  "
            f"plan {member.plan}  April check-ins: {member.check_ins_in_month(2026, 4)}"
        )
    print(f"  monthly revenue: {club.monthly_revenue()}")


def show_rules_being_enforced(club):
    """Break each rule on purpose and print what the model says. Adapters do this."""
    print("Rules the model refuses to break")
    attempts = [
        ("a membership number that is not GYM-####", lambda: MembershipNumber("0007")),
        ("a negative price", lambda: Money(-100, "EUR")),
        ("adding dollars to euros", lambda: Money(100, "EUR") + Money(100, "USD")),
        ("enrolling a number twice", lambda: club.enroll(
            Member(MembershipNumber("GYM-0001"), "Impostor", date(2026, 5, 1), BASIC))),
        ("a member the club never met", lambda: club.find(MembershipNumber("GYM-9999"))),
        ("a 13th basic check-in in one month", lambda: [
            club.check_in(MembershipNumber("GYM-0003"), date(2026, 4, d)) for d in range(1, 14)
        ]),
    ]
    for label, attempt in attempts:
        try:
            attempt()
        except GymError as err:
            print(f"  {label}: {type(err).__name__}: {err}")
        else:
            print(f"  {label}: NOT REFUSED — the model has a hole")


def main(argv):
    path = argv[1] if len(argv) > 1 else "club.json"
    try:
        club = build_club()
        print_roster(club)
        print()

        repository = JsonClubRepository(path)
        repository.save(club)
        reloaded = repository.load()
        same = [
            reloaded.name == club.name,
            reloaded.roster() == club.roster(),
            reloaded.monthly_revenue() == club.monthly_revenue(),
            reloaded.find(MembershipNumber("GYM-0002")).check_ins_in_month(2026, 4) == 15,
        ]
        print(f"Saved to {path} and reloaded — round trip intact: {all(same)}")
        print()

        show_rules_being_enforced(club)
    except GymError as err:
        print(f"error: {err}", file=sys.stderr)
        return 1
    except OSError as err:
        print(f"error: {err}", file=sys.stderr)
        return 2
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
starter/domain-worksheet.md (4787 bytes)
# Domain worksheet — Northside Gym

Fill this in **before** you write any code, using `examples/domain-rules.md`
only. Modelling happens on this page; the classes are just the transcript.

Keep the owner's words. If you write "user record" where the owner wrote
"member", you have already started drifting away from the domain.

---

## Part 1 — Nouns and verbs

Read the eleven rules and list every noun and every verb you find. Do not
filter yet; a bad candidate costs nothing on paper and a missed one costs an
afternoon.

### Nouns (candidate entities and value objects)

| Noun | Which rule(s) | First guess: entity, value object, or neither? |
| --- | --- | --- |
| club | 1, 10 |  |
| member | 2, 3 |  |
| membership number | 2, 3 |  |
|  |  |  |
|  |  |  |
|  |  |  |
|  |  |  |
|  |  |  |
|  |  |  |
|  |  |  |
|  |  |  |

### Verbs (candidate behaviours) — and which object should own each one

| Verb | Which rule(s) | Owner (which class should have this method?) |
| --- | --- | --- |
| enrol a member | 2 |  |
| check in | 6, 7 |  |
| switch plans | 8 |  |
|  |  |  |
|  |  |  |
|  |  |  |

---

## Part 2 — Entities versus value objects

For each noun you kept, decide and **justify in one sentence**. The two
questions that settle it every time:

- *Would I still call it the same thing after all its values changed?* If
  yes it has identity → **entity**.
- *Would two of them with identical values be interchangeable?* If yes →
  **value object**.

| Noun | Entity or value object | One-sentence justification |
| --- | --- | --- |
| Member |  |  |
| MembershipNumber |  |  |
| Money |  |  |
| Plan |  |  |
| CheckIn |  |  |
| DateRange |  |  |
| Club |  |  |

Anything you classified as a value object must end up as a **frozen**
dataclass. Anything you classified as an entity keeps an identity field and
compares on it.

---

## Part 3 — Invariants and where each one is enforced

An invariant is a sentence that must be true of a valid model at all times.
Write each one in **one sentence**, then say exactly which line of code
refuses to let it break. If you cannot name the enforcement point, the rule
is not enforced — it is a hope.

| # | Invariant (one sentence) | Enforced where (class + method) | Error raised |
| --- | --- | --- | --- |
| 1 | A membership number looks like GYM-#### |  |  |
| 2 | A price is never negative |  |  |
| 3 | Two currencies are never added |  |  |
| 4 | No two members share a number |  |  |
| 5 | A basic member checks in at most 12 times a month |  |  |
| 6 | A billing period never ends before it starts |  |  |
| 7 |  |  |  |

---

## Part 4 — Boundaries

Draw the line. Fill in which module each responsibility lives in.

| Responsibility | Module | Is it allowed to do file I/O? |
| --- | --- | --- |
| The rules of the gym | `gym_core.py` | No |
| Turning the club into JSON and back | `gym_repository.py` | Yes |
| Printing the roster, choosing an exit code | `demo.py` | Yes |

Now answer in your own words:

1. Which direction do the imports run, and why must they never run the other
   way?

2. What can you test about the rules of this gym without creating a single
   file?

---

## Part 5 — The anti-pattern checklist

Run this against **your own design**, honestly. Answer each with a short
sentence, not a single word — the sentence is where the learning is.

1. **God object.** Does any one class know about more than its own job? Which
   class in your design is largest, and can you say in one sentence what it is
   responsible for?

2. **Anemic model.** Do your classes hold data only, with all the rules living
   in loose functions elsewhere? Name one rule that lives *inside* an object
   in your design and say why that is the right home for it.

3. **Primitive obsession.** Is there anywhere you used a `float` for money, a
   `str` for a date, or a `str` for something with a fixed set of values? List
   every place, or write "none, and here is what I used instead: ...".

4. **Premature inheritance.** Did you create any base class with exactly one
   subclass? If so, what would you lose by deleting it and using a plain
   function or composition instead?

5. **Stringly typed.** Could a typo in a string sail through your code and
   fail somewhere far away? Which construct prevents that in your design?

6. **Leaky boundary.** Search your own `gym_core.py` for `json`, `open`,
   `print`, `Path`, and `input`. Record the result here. If the search finds
   anything, the boundary has leaked and the fix is to move that code out.

---

## Part 6 — Evidence

Paste the real output of these two commands after you finish the exercises.

```text
$ bash tests/run_tests.sh
(paste the final line here)

$ python3 starter/demo.py club.json
(paste the first four lines here)
```
starter/gym_core.py (9653 bytes)
"""Northside Gym — the PURE DOMAIN CORE (your working file).

Everything in this module comes from a sentence in `examples/domain-rules.md`.
Your job is to finish the six numbered exercises below. Each one is a *rule*
from that page, turned into code that refuses to be broken.

THE ONE RULE FOR THIS FILE: it must never touch the outside world. Do not add
`import json`, `import os`, `open()`, `print()`, or `input()` here. The test
suite checks that this file imports nothing that does I/O, and it runs your
core from an empty directory to prove it needs no files at all.

Run the tests at any time to see where you are:

    bash tests/run_tests.sh
"""

import re
from dataclasses import dataclass, field
from datetime import date
from enum import Enum

# ---------------------------------------------------------------------------
# Errors are part of the model (rule 11) — provided complete.
# ---------------------------------------------------------------------------


class GymError(Exception):
    """Base class for every rule this domain refuses to break."""


class InvalidMembershipNumber(GymError):
    """A membership number was not of the form GYM-#### (rule 2)."""


class InvalidMoney(GymError):
    """A money amount was negative or its currency code was malformed (rule 5)."""


class CurrencyMismatch(GymError):
    """Two money amounts in different currencies were combined (rule 5)."""


class InvalidDateRange(GymError):
    """A billing period ended before it started (rule 9)."""


class DuplicateMember(GymError):
    """A membership number already in the club was enrolled again (rule 2)."""


class UnknownMember(GymError):
    """A membership number that the club has never seen was used."""


class CheckInLimitExceeded(GymError):
    """A basic-tier member tried to check in a 13th time in one month (rule 6)."""


# ---------------------------------------------------------------------------
# Provided: the closed set of tiers and the limits table.
# ---------------------------------------------------------------------------


class PlanTier(Enum):
    """The only two tiers the rules allow (rule 4)."""

    BASIC = "basic"
    PLUS = "plus"


MEMBERSHIP_NUMBER_PATTERN = re.compile(r"^GYM-\d{4}$")
CURRENCY_PATTERN = re.compile(r"^[A-Z]{3}$")

#: How many check-ins each tier allows per calendar month. `None` = unlimited.
CHECKIN_LIMITS = {PlanTier.BASIC: 12, PlanTier.PLUS: None}


# ---------------------------------------------------------------------------
# EXERCISE 1 — the MembershipNumber value object (rule 2)
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class MembershipNumber:
    """A membership number, e.g. GYM-0007. Frozen: it can never drift."""

    value: str

    def __post_init__(self):
        # EXERCISE 1: raise InvalidMembershipNumber unless `self.value` is a
        # string matching MEMBERSHIP_NUMBER_PATTERN. Put the offending value
        # in the message with !r so the error names what was wrong.
        # Replace the next line with your check.
        raise NotImplementedError("Exercise 1: validate the membership number")

    def __str__(self):
        return self.value


# ---------------------------------------------------------------------------
# EXERCISE 2 — the Money value object and its refusal to mix currencies (rule 5)
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class Money:
    """An amount in whole cents, in one currency. Never a float."""

    cents: int
    currency: str

    def __post_init__(self):
        # EXERCISE 2a: raise InvalidMoney if `cents` is not an int (bools are
        # ints in Python — reject them too), if it is negative, or if
        # `currency` does not match CURRENCY_PATTERN.
        raise NotImplementedError("Exercise 2a: validate the money amount")

    @staticmethod
    def zero(currency):
        """The additive identity for a currency: Money.zero('EUR')."""
        return Money(0, currency)

    def __add__(self, other):
        # EXERCISE 2b: return NotImplemented if `other` is not Money; raise
        # CurrencyMismatch if the currencies differ; otherwise return a NEW
        # Money with the summed cents. Never mutate self — it is frozen.
        raise NotImplementedError("Exercise 2b: add two money amounts safely")

    def __str__(self):
        return f"{self.cents // 100}.{self.cents % 100:02d} {self.currency}"


# ---------------------------------------------------------------------------
# EXERCISE 3 — the DateRange value object (rule 9)
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class DateRange:
    """A billing period: start and end dates, both included."""

    start: date
    end: date

    def __post_init__(self):
        # EXERCISE 3a: raise InvalidDateRange if start is after end.
        raise NotImplementedError("Exercise 3a: reject a backwards billing period")

    def contains(self, day):
        # EXERCISE 3b: return True if `day` falls inside the period, ends
        # included. One comparison expression is enough.
        raise NotImplementedError("Exercise 3b: is this day inside the period?")


# ---------------------------------------------------------------------------
# Provided: Plan and CheckIn, two more value objects.
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class Plan:
    """A tier plus its monthly price (rule 4)."""

    tier: PlanTier
    monthly_price: Money

    @property
    def checkin_limit(self):
        """Check-ins allowed per calendar month; None means unlimited (rule 6)."""
        return CHECKIN_LIMITS[self.tier]

    def __str__(self):
        return f"{self.tier.value} ({self.monthly_price})"


@dataclass(frozen=True)
class CheckIn:
    """A member walked in on a day (rule 7). Frozen: history never changes."""

    member: MembershipNumber
    day: date


# ---------------------------------------------------------------------------
# EXERCISE 4 and 5 — the Member entity (rules 3, 6, 8)
# ---------------------------------------------------------------------------


@dataclass(eq=False)
class Member:
    """A person who belongs to the club. An ENTITY: identity, not values.

    `eq=False` switches off the dataclass's value equality deliberately, so
    you can define identity equality yourself in Exercise 4.
    """

    number: MembershipNumber
    name: str
    joined_on: date
    plan: Plan
    check_ins: list = field(default_factory=list)

    def __eq__(self, other):
        # EXERCISE 4a: two Members are equal when their membership NUMBERS are
        # equal — rule 3. Return NotImplemented for anything that is not a
        # Member.
        raise NotImplementedError("Exercise 4a: identity equality by membership number")

    def __hash__(self):
        # EXERCISE 4b: hash the identity, not the whole object, so a Member can
        # live in a set or a dict key and stay findable after their name or
        # plan changes.
        raise NotImplementedError("Exercise 4b: hash the identity")

    def switch_plan(self, new_plan):
        """Move this member onto another plan, keeping their history (rule 8)."""
        self.plan = new_plan

    def check_ins_in_month(self, year, month):
        """How many times this member checked in during one calendar month."""
        return sum(1 for c in self.check_ins if c.day.year == year and c.day.month == month)

    def check_in(self, day):
        # EXERCISE 5: enforce rule 6. Read the tier's limit from
        # `self.plan.checkin_limit`. If it is not None and this member already
        # has that many check-ins in `day`'s month, raise CheckInLimitExceeded
        # with a message naming the member, the tier, the limit, and the month.
        # Otherwise build a CheckIn, append it to self.check_ins, and return it.
        raise NotImplementedError("Exercise 5: enforce the monthly check-in limit")


# ---------------------------------------------------------------------------
# EXERCISE 6 — the Club entity, the one door into the model (rules 1, 10)
# ---------------------------------------------------------------------------


@dataclass
class Club:
    """The gym itself. Every change to a member goes through here."""

    name: str
    members: dict = field(default_factory=dict)

    def enroll(self, member):
        # EXERCISE 6a: store `member` under the key `member.number.value`.
        # Raise DuplicateMember if that key is already present (rule 2).
        # Return the member so callers can chain.
        raise NotImplementedError("Exercise 6a: enroll a member, refusing duplicates")

    def find(self, member_number):
        # EXERCISE 6b: accept either a MembershipNumber or a plain string,
        # look the member up, and raise UnknownMember if there is no such key.
        raise NotImplementedError("Exercise 6b: find a member or refuse")

    def check_in(self, member_number, day):
        """Record a visit for one member (rules 6, 7)."""
        return self.find(member_number).check_in(day)

    def monthly_revenue(self, currency="EUR"):
        """Sum every member's current monthly price into one amount (rule 10)."""
        total = Money.zero(currency)
        for key in sorted(self.members):
            total = total + self.members[key].plan.monthly_price
        return total

    def roster(self):
        """Members in membership-number order — deterministic, for reports."""
        return [self.members[key] for key in sorted(self.members)]
starter/gym_repository.py (2871 bytes)
"""Northside Gym — the PERSISTENCE ADAPTER (your working file).

This is the ONLY file in your program allowed to know that the club lives in
a JSON file. It imports `json` and `pathlib`; `gym_core.py` imports neither,
and that asymmetry is the whole design.

Finish Exercise 7 below. When you have, swapping JSON for a different format
later means editing this one class and nothing else.
"""

import json
from datetime import date
from pathlib import Path

from gym_core import (
    CheckIn,
    Club,
    Member,
    MembershipNumber,
    Money,
    Plan,
    PlanTier,
)


class JsonClubRepository:
    """Loads and saves a Club as a JSON file."""

    def __init__(self, path):
        self.path = Path(path)

    # -- domain -> plain data (provided) --------------------------------------

    @staticmethod
    def _member_to_dict(member):
        return {
            "number": member.number.value,
            "name": member.name,
            "joined_on": member.joined_on.isoformat(),
            "plan": {
                "tier": member.plan.tier.value,
                "price_cents": member.plan.monthly_price.cents,
                "currency": member.plan.monthly_price.currency,
            },
            "check_ins": [c.day.isoformat() for c in member.check_ins],
        }

    # -- plain data -> domain -------------------------------------------------

    @staticmethod
    def _member_from_dict(raw):
        # EXERCISE 7a: rebuild a Member from the dict `_member_to_dict` wrote.
        # Build the value objects FIRST — MembershipNumber(raw["number"]),
        # Money(raw["plan"]["price_cents"], raw["plan"]["currency"]),
        # Plan(PlanTier(raw["plan"]["tier"]), that money) — so a hand-edited
        # file with a bad number or a negative price is refused right here.
        # Use date.fromisoformat() for the dates, then attach the check-ins:
        #   member.check_ins = [CheckIn(number, date.fromisoformat(d))
        #                       for d in raw["check_ins"]]
        raise NotImplementedError("Exercise 7a: rebuild a Member from plain data")

    # -- the two operations the rest of the program is allowed to call --------

    def save(self, club):
        # EXERCISE 7b: write {"club": club.name, "members": [...]} to
        # self.path as JSON with indent=2 and a trailing newline, using
        # club.roster() so the order is deterministic. Return self.path.
        # Path.write_text(text, encoding="utf-8") is the whole write.
        raise NotImplementedError("Exercise 7b: save the club as JSON")

    def load(self):
        # EXERCISE 7c: read self.path with Path.read_text(encoding="utf-8"),
        # json.loads it, build a Club(raw["club"]), and enroll every member
        # rebuilt by _member_from_dict. Return the club.
        raise NotImplementedError("Exercise 7c: load the club back from JSON")
tests/run_tests.sh (14441 bytes)
#!/usr/bin/env bash
# Tests for the Day 070 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# What this suite is really testing is a DESIGN, not just some functions:
#
#   * the value objects are immutable and compare by value;
#   * the entities compare by identity and enforce their invariants;
#   * every broken rule raises a domain error from the GymError family;
#   * the domain core imports nothing that does I/O — proved twice, once by
#     reading its imports and once by running it from an EMPTY directory;
#   * the repository round-trips the club through JSON without losing a fact.
#
# No network, non-interactive, deterministic. Exits 0 only if every check
# passes.
set -u

export PYTHONDONTWRITEBYTECODE=1

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
failures=0
checks=0

check() {
  local label="$1" ok="$2"
  checks=$((checks + 1))
  if [ "${ok}" = "yes" ]; then
    echo "  ok: ${label}"
  else
    echo "  FAIL: ${label}"
    failures=$((failures + 1))
  fi
}

# check_core <label> <core_dir> <python-body>
# Runs an assertion body with the domain core importable from core_dir, from
# an EMPTY working directory. If the body needs a file to exist, it fails —
# which is exactly the property we want to prove about a pure core.
check_core() {
  local label="$1" core_dir="$2" body="$3" empty_dir
  empty_dir="$(mktemp -d "${TMPDIR:-/tmp}/gym-core.XXXXXX")"
  if (cd "${empty_dir}" && PYTHONPATH="${core_dir}" python3 -c "
import gym_core as g
${body}
" >/dev/null 2>&1); then
    check "${label}" "yes"
  else
    check "${label}" "no"
  fi
  rm -rf "${empty_dir}"
}

# check_repo <label> <core_dir> <python-body>
# Same, but the repository (which DOES do I/O) is importable too, and the
# working directory is a scratch dir the body may write into.
check_repo() {
  local label="$1" core_dir="$2" body="$3" work_dir
  work_dir="$(mktemp -d "${TMPDIR:-/tmp}/gym-repo.XXXXXX")"
  if (cd "${work_dir}" && PYTHONPATH="${core_dir}" python3 -c "
import gym_core as g
from gym_repository import JsonClubRepository
${body}
" >/dev/null 2>&1); then
    check "${label}" "yes"
  else
    check "${label}" "no"
  fi
  rm -rf "${work_dir}"
}

check_purity() {
  # Parse the file and inspect what it REALLY imports and calls, rather than
  # grepping text (a docstring that says "no open()" is not a violation).
  local core_file="$1" label="$2"
  if python3 - "${core_file}" <<'PY' 2>/dev/null
import ast
import sys

BANNED_MODULES = {
    "json", "os", "sys", "io", "pathlib", "shutil", "subprocess",
    "socket", "urllib", "http", "sqlite3", "csv", "pickle", "logging",
}
BANNED_CALLS = {"open", "print", "input", "eval", "exec"}

with open(sys.argv[1], encoding="utf-8") as handle:
    tree = ast.parse(handle.read(), sys.argv[1])

violations = []
for node in ast.walk(tree):
    if isinstance(node, ast.Import):
        for alias in node.names:
            if alias.name.split(".")[0] in BANNED_MODULES:
                violations.append(f"import {alias.name}")
    elif isinstance(node, ast.ImportFrom):
        if node.module and node.module.split(".")[0] in BANNED_MODULES:
            violations.append(f"from {node.module} import ...")
    elif isinstance(node, ast.Call):
        func = node.func
        if isinstance(func, ast.Name) and func.id in BANNED_CALLS:
            violations.append(f"{func.id}()")

for v in violations:
    print(v, file=sys.stderr)
sys.exit(1 if violations else 0)
PY
  then
    check "${label}" "yes"
  else
    check "${label}" "no"
    echo "    (the core reaches for the outside world — move that code to an adapter)"
  fi
}

run_model_checks() {
  local core_dir="$1"
  echo "Testing the domain core in ${core_dir} (no files, empty working directory) ..."

  check_core "MembershipNumber compares by value" "${core_dir}" \
    "assert g.MembershipNumber('GYM-0007') == g.MembershipNumber('GYM-0007')
assert g.MembershipNumber('GYM-0007') != g.MembershipNumber('GYM-0008')"

  check_core "MembershipNumber is immutable" "${core_dir}" \
    "import dataclasses
n = g.MembershipNumber('GYM-0007')
try:
    n.value = 'GYM-9999'
except dataclasses.FrozenInstanceError:
    pass
else:
    raise SystemExit(1)"

  check_core "MembershipNumber rejects a bad format" "${core_dir}" \
    "for bad in ['0007', 'GYM-7', 'gym-0007', 'GYM-00007', 7]:
    try:
        g.MembershipNumber(bad)
    except g.InvalidMembershipNumber:
        continue
    raise SystemExit(1)"

  check_core "Money compares by value and is immutable" "${core_dir}" \
    "import dataclasses
assert g.Money(2900, 'EUR') == g.Money(2900, 'EUR')
assert g.Money(2900, 'EUR') != g.Money(2900, 'USD')
try:
    g.Money(1, 'EUR').cents = 2
except dataclasses.FrozenInstanceError:
    pass
else:
    raise SystemExit(1)"

  check_core "Money rejects negatives, floats and bad currencies" "${core_dir}" \
    "for bad in [(-1, 'EUR'), (1.5, 'EUR'), (True, 'EUR'), (100, 'eur'), (100, 'EURO')]:
    try:
        g.Money(*bad)
    except g.InvalidMoney:
        continue
    raise SystemExit(1)"

  check_core "Money adds within a currency" "${core_dir}" \
    "assert g.Money(2900, 'EUR') + g.Money(4900, 'EUR') == g.Money(7800, 'EUR')
assert g.Money.zero('EUR') + g.Money(2900, 'EUR') == g.Money(2900, 'EUR')
assert str(g.Money(2900, 'EUR')) == '29.00 EUR'"

  check_core "Money refuses to add two currencies" "${core_dir}" \
    "try:
    g.Money(100, 'EUR') + g.Money(100, 'USD')
except g.CurrencyMismatch:
    pass
else:
    raise SystemExit(1)"

  check_core "DateRange rejects a backwards period and answers contains()" "${core_dir}" \
    "from datetime import date
period = g.DateRange(date(2026, 4, 1), date(2026, 4, 30))
assert period.contains(date(2026, 4, 1))
assert period.contains(date(2026, 4, 30))
assert not period.contains(date(2026, 5, 1))
try:
    g.DateRange(date(2026, 4, 30), date(2026, 4, 1))
except g.InvalidDateRange:
    pass
else:
    raise SystemExit(1)"

  check_core "PlanTier is a closed set" "${core_dir}" \
    "assert g.PlanTier('basic') is g.PlanTier.BASIC
try:
    g.PlanTier('pluss')
except ValueError:
    pass
else:
    raise SystemExit(1)"

  check_core "Member equality is identity, not values" "${core_dir}" \
    "from datetime import date
plan = g.Plan(g.PlanTier.BASIC, g.Money(2900, 'EUR'))
a = g.Member(g.MembershipNumber('GYM-0001'), 'Ada', date(2026, 1, 15), plan)
b = g.Member(g.MembershipNumber('GYM-0001'), 'Different Name', date(2026, 9, 9), plan)
c = g.Member(g.MembershipNumber('GYM-0002'), 'Ada', date(2026, 1, 15), plan)
assert a == b
assert a != c
assert len({a, b, c}) == 2"

  check_core "a Member stays the same member after every value changes" "${core_dir}" \
    "from datetime import date
basic = g.Plan(g.PlanTier.BASIC, g.Money(2900, 'EUR'))
plus = g.Plan(g.PlanTier.PLUS, g.Money(4900, 'EUR'))
m = g.Member(g.MembershipNumber('GYM-0001'), 'Ada', date(2026, 1, 15), basic)
before = hash(m)
m.name = 'Ada L.'
m.switch_plan(plus)
m.check_in(date(2026, 4, 2))
assert hash(m) == before
assert m.plan is plus
assert len(m.check_ins) == 1"

  check_core "a basic member gets 12 check-ins a month, not 13" "${core_dir}" \
    "from datetime import date
basic = g.Plan(g.PlanTier.BASIC, g.Money(2900, 'EUR'))
m = g.Member(g.MembershipNumber('GYM-0003'), 'Lin', date(2026, 1, 1), basic)
for day in range(1, 13):
    m.check_in(date(2026, 4, day))
assert m.check_ins_in_month(2026, 4) == 12
try:
    m.check_in(date(2026, 4, 13))
except g.CheckInLimitExceeded:
    pass
else:
    raise SystemExit(1)
m.check_in(date(2026, 5, 1))
assert m.check_ins_in_month(2026, 5) == 1"

  check_core "a plus member is unlimited" "${core_dir}" \
    "from datetime import date
plus = g.Plan(g.PlanTier.PLUS, g.Money(4900, 'EUR'))
m = g.Member(g.MembershipNumber('GYM-0002'), 'Grace', date(2026, 2, 1), plus)
for day in range(1, 29):
    m.check_in(date(2026, 4, day))
assert m.check_ins_in_month(2026, 4) == 28"

  check_core "a CheckIn is frozen history" "${core_dir}" \
    "import dataclasses
from datetime import date
visit = g.CheckIn(g.MembershipNumber('GYM-0001'), date(2026, 4, 3))
try:
    visit.day = date(2026, 4, 4)
except dataclasses.FrozenInstanceError:
    pass
else:
    raise SystemExit(1)"

  check_core "Club refuses duplicate numbers and unknown members" "${core_dir}" \
    "from datetime import date
basic = g.Plan(g.PlanTier.BASIC, g.Money(2900, 'EUR'))
club = g.Club('Northside Gym')
club.enroll(g.Member(g.MembershipNumber('GYM-0001'), 'Ada', date(2026, 1, 15), basic))
try:
    club.enroll(g.Member(g.MembershipNumber('GYM-0001'), 'Impostor', date(2026, 5, 1), basic))
except g.DuplicateMember:
    pass
else:
    raise SystemExit(1)
try:
    club.find(g.MembershipNumber('GYM-9999'))
except g.UnknownMember:
    pass
else:
    raise SystemExit(1)"

  check_core "Club sums monthly revenue into one Money" "${core_dir}" \
    "from datetime import date
basic = g.Plan(g.PlanTier.BASIC, g.Money(2900, 'EUR'))
plus = g.Plan(g.PlanTier.PLUS, g.Money(4900, 'EUR'))
club = g.Club('Northside Gym')
club.enroll(g.Member(g.MembershipNumber('GYM-0001'), 'Ada', date(2026, 1, 15), basic))
club.enroll(g.Member(g.MembershipNumber('GYM-0002'), 'Grace', date(2026, 2, 1), plus))
club.enroll(g.Member(g.MembershipNumber('GYM-0003'), 'Lin', date(2026, 3, 9), basic))
assert club.monthly_revenue() == g.Money(10700, 'EUR')
assert [m.number.value for m in club.roster()] == ['GYM-0001', 'GYM-0002', 'GYM-0003']"

  check_core "every refusal is a GymError the adapter can catch" "${core_dir}" \
    "for cls in [g.InvalidMembershipNumber, g.InvalidMoney, g.CurrencyMismatch,
            g.InvalidDateRange, g.DuplicateMember, g.UnknownMember,
            g.CheckInLimitExceeded]:
    assert issubclass(cls, g.GymError)"
}

run_repository_checks() {
  local core_dir="$1"
  echo "Testing the repository in ${core_dir} (JSON round trip) ..."

  check_repo "save then load returns an identical club" "${core_dir}" \
    "from datetime import date
basic = g.Plan(g.PlanTier.BASIC, g.Money(2900, 'EUR'))
plus = g.Plan(g.PlanTier.PLUS, g.Money(4900, 'EUR'))
club = g.Club('Northside Gym')
club.enroll(g.Member(g.MembershipNumber('GYM-0001'), 'Ada', date(2026, 1, 15), basic))
club.enroll(g.Member(g.MembershipNumber('GYM-0002'), 'Grace', date(2026, 2, 1), plus))
club.check_in(g.MembershipNumber('GYM-0001'), date(2026, 4, 3))
club.check_in(g.MembershipNumber('GYM-0001'), date(2026, 4, 5))
repo = JsonClubRepository('club.json')
repo.save(club)
back = repo.load()
assert back.name == club.name
assert back.roster() == club.roster()
assert back.monthly_revenue() == club.monthly_revenue()
ada = back.find(g.MembershipNumber('GYM-0001'))
assert ada.name == 'Ada' and ada.joined_on == date(2026, 1, 15)
assert ada.check_ins_in_month(2026, 4) == 2
assert ada.plan == basic"

  check_repo "loading validates: a hand-edited bad file is refused" "${core_dir}" \
    "import json
from datetime import date
basic = g.Plan(g.PlanTier.BASIC, g.Money(2900, 'EUR'))
club = g.Club('Northside Gym')
club.enroll(g.Member(g.MembershipNumber('GYM-0001'), 'Ada', date(2026, 1, 15), basic))
repo = JsonClubRepository('club.json')
repo.save(club)
raw = json.loads(open('club.json', encoding='utf-8').read())
raw['members'][0]['plan']['price_cents'] = -1
open('club.json', 'w', encoding='utf-8').write(json.dumps(raw))
try:
    repo.load()
except g.InvalidMoney:
    pass
else:
    raise SystemExit(1)"

  check_repo "the JSON on disk is plain, readable data" "${core_dir}" \
    "import json
from datetime import date
basic = g.Plan(g.PlanTier.BASIC, g.Money(2900, 'EUR'))
club = g.Club('Northside Gym')
club.enroll(g.Member(g.MembershipNumber('GYM-0001'), 'Ada', date(2026, 1, 15), basic))
JsonClubRepository('club.json').save(club)
raw = json.loads(open('club.json', encoding='utf-8').read())
assert raw['club'] == 'Northside Gym'
assert raw['members'][0]['number'] == 'GYM-0001'
assert raw['members'][0]['plan'] == {'tier': 'basic', 'price_cents': 2900, 'currency': 'EUR'}"
}

run_demo_check() {
  local demo="$1" label="$2" work_dir out code
  work_dir="$(mktemp -d "${TMPDIR:-/tmp}/gym-demo.XXXXXX")"
  out="$(cd "${work_dir}" && python3 "${demo}" club.json 2>&1)"
  code=$?
  if [ "${code}" -eq 0 ] \
    && printf '%s' "${out}" | grep -qF 'monthly revenue: 107.00 EUR' \
    && printf '%s' "${out}" | grep -qF 'round trip intact: True' \
    && printf '%s' "${out}" | grep -qF 'CheckInLimitExceeded' \
    && ! printf '%s' "${out}" | grep -qF 'NOT REFUSED'; then
    check "${label}" "yes"
  else
    check "${label}" "no"
    echo "    (exit ${code}; output: ${out})"
  fi
  rm -rf "${work_dir}"
}

# --- Reference: always tested strictly -------------------------------------
run_model_checks "${lab_dir}/examples"
run_repository_checks "${lab_dir}/examples"

echo "Testing the boundary (the core must not be able to do I/O) ..."
check_purity "${lab_dir}/examples/gym_core.py" "examples/gym_core.py imports nothing that does I/O"
run_demo_check "${lab_dir}/examples/demo.py" "examples/demo.py runs end to end and exits 0"

# --- Learner starter --------------------------------------------------------
echo "Testing starter/ ..."
starter_core="${lab_dir}/starter/gym_core.py"
starter_repo="${lab_dir}/starter/gym_repository.py"

for f in "${starter_core}" "${starter_repo}"; do
  if python3 -c "compile(open('${f}').read(), '${f}', 'exec')" 2>/dev/null; then
    check "$(basename "${f}") is valid Python" "yes"
  else
    check "$(basename "${f}") is valid Python" "no"
  fi
done

check_purity "${starter_core}" "starter/gym_core.py imports nothing that does I/O"

if grep -q 'NotImplementedError' "${starter_core}" || grep -q 'NotImplementedError' "${starter_repo}"; then
  echo "Note: starter/ still has unfinished exercises — testing structure only."
  for name in MembershipNumber Money DateRange Plan CheckIn Member Club; do
    if grep -q "^class ${name}\|^class ${name}(" "${starter_core}"; then
      check "starter defines ${name}" "yes"
    else
      check "starter defines ${name}" "no"
    fi
  done
  if grep -q 'class JsonClubRepository' "${starter_repo}"; then
    check "starter defines JsonClubRepository" "yes"
  else
    check "starter defines JsonClubRepository" "no"
  fi
else
  run_model_checks "${lab_dir}/starter"
  run_repository_checks "${lab_dir}/starter"
  run_demo_check "${lab_dir}/starter/demo.py" "starter/demo.py runs end to end and exits 0"
fi

echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]

Troubleshooting

Troubleshooting — Day 070 lab

python: command not found

Use python3 explicitly, as every command in this lab does. On macOS and most Linux systems, bare python may be missing or point to an old version. Check with python3 --version.

The starter raises NotImplementedError

Expected until you finish the exercises. Every unfinished method raises NotImplementedError on purpose so that an empty function can never be mistaken for a working one:

NotImplementedError: Exercise 2a: validate the money amount

Exercises 1–6 are in starter/gym_core.py; exercise 7 (in three parts) is in starter/gym_repository.py. starter/demo.py is provided complete and needs no editing — it will start working the moment the core and the repository do.

dataclasses.FrozenInstanceError: cannot assign to field 'value'

Your value object is working exactly as designed. A frozen dataclass refuses assignment after construction, which is what stops a MembershipNumber or a Money from drifting away from the value that was validated:

FrozenInstanceError | cannot assign to field 'value'

The fix is never to unfreeze it. Build a new value instead — that is what value objects are for. Where you were about to write an assignment to a price, write member.switch_plan(Plan(tier, Money(new_cents, "EUR"))) instead.

If you hit this on an entity, you have misclassified it: a Member has to change over time, so Member is a plain @dataclass, not a frozen one.

TypeError: ... unhashable type: 'Member'

You defined __eq__ on the entity and did not define __hash__. Python sets __hash__ to None for any class that defines __eq__ without it, on the reasoning that two objects which compare equal must hash equal, and it will not guess how. The object then cannot go into a set or be used as a dict key:

TypeError | cannot use 'E' as a set element (unhashable type: 'E')

(The exact wording varies between Python versions; the phrase unhashable type is the constant.) The fix is exercise 4b — hash the identity, not the whole object:

def __hash__(self):
    return hash(self.number)

That is also correct, not merely a workaround: the identity is the one thing about a member that never changes, so it is the only safe thing to hash.

RecursionError inside __eq__

You wrote something like return self == other.number or compared the whole object to itself inside its own equality method, so __eq__ calls __eq__ forever. Compare the identity fields, not the objects:

def __eq__(self, other):
    if not isinstance(other, Member):
        return NotImplemented
    return self.number == other.number

The isinstance guard matters too. Returning NotImplemented (not False) for an unrelated type lets Python try the other object's comparison before giving up, which is the documented protocol.

Money(True, "EUR") is accepted and it should not be

In Python, bool is a subclass of int, so isinstance(True, int) is True and a plain integer check lets True through as the number 1. Exercise 2a asks you to reject it explicitly:

if not isinstance(self.cents, int) or isinstance(self.cents, bool):
    raise InvalidMoney(...)

The reference refuses it with money must be a whole number of cents, got True. A float is refused the same way — Money(29.0, "EUR") gives money must be a whole number of cents, got 29.0 — because whole cents are the whole point.

ValueError: 'pluss' is not a valid PlanTier

Working as intended, and worth pausing on. This is the enum catching a typo at the moment it is written. Had the tier been a plain string, "pluss" would have been accepted, stored, written to the JSON file, and surfaced weeks later as a member whose check-in limit nobody could explain. Fix the spelling, and notice that the legal values live in exactly one place.

ModuleNotFoundError: No module named 'gym_core'

The core is not on the import path. Two situations:

  • Running a one-liner: set the path explicitly, as the README does — PYTHONPATH=examples python3 -c "import gym_core as g; ..." (or PYTHONPATH=starter to exercise your own).
  • Running a lab file: run it by path from the lab directory (python3 examples/demo.py club.json). Python puts the script's own directory on the path, so demo.py finds its siblings — but only if you gave it the path to the right copy. Mixing them (starter/demo.py expecting your core) is fine; mixing them across directories is not.

The purity check fails: starter/gym_core.py imports nothing that does I/O

The test suite read your core and found something that can touch the outside world. Search for the usual suspects and move each one out to gym_repository.py or demo.py:

grep -nE 'import json|import os|import sys|from pathlib|open\(|print\(|input\(' starter/gym_core.py

A print added for debugging is the most common cause. Delete it and raise a domain error instead — that is what the error hierarchy is for, and unlike a print it can be tested.

A model check fails with a missing-file error

The suite runs your core from a directory created with mktemp -d that contains nothing at all. If a rule fails there but works in the lab directory, the rule is quietly depending on a file — which means the boundary has leaked in a way the import scan did not catch. Trace what the failing method reads.

bash: tests/run_tests.sh: Permission denied

Run it through bash explicitly, as the README shows: bash tests/run_tests.sh. You do not need to chmod +x anything.

The check count is 33 and I expected 46

33 is correct while any exercise is unfinished: the suite detects NotImplementedError in your starter files and switches to structural checks only, printing Note: starter/ still has unfinished exercises. Finish all seven exercises — including the three parts of exercise 7 — and the suite runs the full model, repository and demo checks against your files, giving 46 checks, 0 failure(s).

I want to start over

Delete the generated file and restore the starter from git:

rm -f club.json
git checkout -- starter/

Security notes

Security notes — Day 070 lab

  • What the lab does: reads only the files that ship with it, and writes a single club.json into whatever directory you run a demo from. It makes no network connections, needs no privileges, and touches nothing outside the directory you are standing in. The test runner does its file work inside directories created with mktemp -d and removes each one as that check finishes.

  • Validation at construction is a security control. This is the security lesson of the day, and it is a design property rather than a checklist item. When the only way to obtain a MembershipNumber is through code that matches it against a pattern, and the only way to obtain a Money is through code that rejects negatives, non-integers and malformed currency codes, then no code path anywhere in the program can produce an invalid one. Compare that with validating in the import script: every future caller — a correction path, an admin command, a migration — is a fresh chance to forget, and the one that forgets is the one an attacker finds.

  • Trust boundaries are where objects are built, not where files are read. The repository's load deliberately rebuilds every value through the domain constructors instead of setting fields directly. That is why a hand-edited club.json with "price_cents": -1 is refused with InvalidMoney rather than silently loaded. Treat any file you did not write in this run as untrusted input, even one your own program wrote a minute ago — a file is a place other processes and other people can reach.

  • A core that cannot do I/O cannot be made to. gym_core.py imports dataclasses, datetime, enum and re, and nothing else. It has no open, no subprocess, no os, no socket. Whatever malformed input it is handed, the worst it can do is raise. Every dangerous capability in the lab is confined to two small, readable files at the edge — which are the files you audit. This is the practical payoff of the boundary, beyond testability.

  • Deserialised data is data, never code. json.loads is safe by design: it can only ever produce dicts, lists, strings, numbers, booleans and None, or raise. Python's pickle is the opposite — unpickling runs code — and must never be pointed at a file you did not create yourself in the same process. If you extend this lab with another storage format, keep that line: never eval() a value out of a file, and never use ast.literal_eval as a substitute for a real parser on untrusted input.

  • A closed set is a safety mechanism. PlanTier being an enum means an unknown tier from a tampered file is refused at load time with a ValueError, instead of becoming a member with an undefined check-in limit. Every closed set you leave as a bare string — a role, a permission level, an account status — is a place where a value nobody anticipated can flow straight through your checks. This is why "stringly typed" appears on the anti-pattern list next to genuine bugs.

  • Errors should say what happened, to you, and less to everyone else. The domain errors here name the rule and the offending value, which is right for a local tool. In a networked program the same message may not be safe to return to a caller: it can disclose membership numbers, whether a given record exists, or the shape of your storage. The pattern that scales is the one this lab sets up — the adapter, not the model, decides what the outside world is told, so you can log the detailed message and return a generic one.

  • Membership records are personal data. Names, join dates and attendance patterns are exactly the sort of content that turns a convenient JSON file into a privacy incident when it is copied to a laptop, pasted into a chat, or committed to version control. The club.json this lab writes contains invented people; keep real ones out of repositories, and delete extracts when the job they were made for is finished.

  • Reading before running: every file in this lab is short and commented. Read examples/gym_core.py, examples/gym_repository.py, examples/demo.py and tests/run_tests.sh before running them. Running unread scripts is one of the most common ways developers get compromised; the course's rule is that every lab script is small enough to read and understand first.