Programming with Python › Files, Errors, and Object-Oriented Python › Day 70
Hands-on lab — Day 70: Modeling a Domain with Objects
- ← Back to the Day 70 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/programming-with-python/day-070-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
- Lesson title: Modeling a Domain with Objects
- Day number: 70 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-070-modeling-a-domain-with-objects
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-070-modeling-a-domain-with-objectswhen the site is running.
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
pythonforpython3. 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 usesdataclasses(frozen=True),enum.Enum,re,datetime,jsonandpathlib— all standard library.bashfor 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 singleMoney, 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=examplesone-liners — import the domain core with no adapter, no file and no program around it, and add two money amounts. The first succeeds and prints78.00 EUR; the second raisesCurrencyMismatch. 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.pyinstarter/is provided complete on purpose: if you ever feel tempted to put a rule in it, that is the signal the rule belongs ingym_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
python3 examples/demo.py club.jsonexits 0 and reportsround trip intact: True.- The roster shows two members named Ada with different membership numbers, treated as two different people — rule 3, visible in the output.
- 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. - 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. head -18 club.jsonshows plain JSON withprice_cents: 2900— an integer, never a float.- The second
PYTHONPATH=examplesone-liner ends ingym_core.CurrencyMismatch: cannot add USD to EUR. - Every row of
starter/domain-worksheet.mdis filled in — including part 5, the anti-pattern checklist, in sentences rather than single words. grep -nE 'import json|import os|from pathlib|open\(|print\(|input\(' starter/gym_core.pyfinds nothing. The suite checks this too, but do it yourself once.bash tests/run_tests.shreports0 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
- Add a third tier. Introduce a
STUDENTtier 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. - Add a second repository. Write a
CsvClubRepositorywith the samesaveandloadmethods, 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. - Model billing. Add a report that uses the existing
DateRangeto produce the amount due for one member over one billing period. Decide deliberately — writing your reasoning in the worksheet — whether it belongs onMember, onClub, or in a separate report class. Then state the invariant your answer implies, in one sentence, and name the line that enforces it. - Break the boundary on purpose. Add
print("saving...")tostarter/gym_core.pyand 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. - 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.
Navigation
- 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:Moneyas a frozen value object,Categoryas an enum,ExpenseandLedgeras 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; ..."(orPYTHONPATH=starterto 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, sodemo.pyfinds its siblings — but only if you gave it the path to the right copy. Mixing them (starter/demo.pyexpecting 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.jsoninto 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 withmktemp -dand 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
MembershipNumberis through code that matches it against a pattern, and the only way to obtain aMoneyis 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
loaddeliberately rebuilds every value through the domain constructors instead of setting fields directly. That is why a hand-editedclub.jsonwith"price_cents": -1is refused withInvalidMoneyrather 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.pyimportsdataclasses,datetime,enumandre, and nothing else. It has noopen, nosubprocess, noos, 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.loadsis safe by design: it can only ever produce dicts, lists, strings, numbers, booleans andNone, or raise. Python'spickleis 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: nevereval()a value out of a file, and never useast.literal_evalas a substitute for a real parser on untrusted input. -
A closed set is a safety mechanism.
PlanTierbeing an enum means an unknown tier from a tampered file is refused at load time with aValueError, 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.jsonthis 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.pyandtests/run_tests.shbefore 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.