You have the contract (12-01); now comes the blueprint. Personal projects rarely fail for lack of knowledge — they fail for lack of structure: you start with the flashy part (the web), you discover halfway through that the core doesn't hold up, and the refactor demoralizes you. This lesson turns the requirements into an executable plan: six milestones in a deliberate order, a layered architecture, the definitive directory structure, the data schemas and — the most valuable part — the contracts of the key functions written before you program them. By the end you will have something very few people have when they start a project: clarity about what to do each day and how to know it is done.

Contents

  1. From requirement to task: the six milestones
  2. The build order and its whys
  3. Layered architecture: the web translates, the package solves
  4. Project directory structure
  5. Data design: the three files
  6. Contracts before code
  7. Design decisions and their whys
  8. Time management for self-taught learners

From requirement to task: the six milestones

You can't start a requirement ("catalog management") on a Tuesday afternoon; you can start a task ("implement CatalogRepository.load()"). The bridge between the two is the milestones: bundles of tasks that end in something demonstrable. Papyrus Online is built in six:

Milestone What it delivers Requirements it covers Relative effort "Demo" when it's done
H1 — Domain models.py, errors.py, coupons.py, clean and annotated FR1 (model), FR2, FR3 (rules), NFR2, NFR3 ★★ In a REPL: create books, compute 12.35 EUR
H2 — Services services.py with an integrated SalesService FR2, FR3, FR7 ★★★ Sell with member+coupon from a script
H3 — Persistence repositories.py (JSON/CSV, atomic save) FR4 ★★ Sell, restart, the stock persists
H4 — Interface Flask API or minimal Django site FR5 ★★★ (A) / ★★★★ (B) Sell from the browser/curl
H5 — Report report.py with numbers + PNG FR6 ★★ The month's PNG in data/
H6 — Polish Full logging, README, requirements FR7, NFR4 Someone else installs and uses it

Tests are not a milestone: they accompany every milestone (H1 closes with H1's tests green). Leaving the tests "for the end" is the recipe for an end that never arrives; you saw it in M9 and it applies here.

The stars are relative effort, not hours: if H1 takes you one session, expect about three for H2. Calibrate with your own speed after the first milestone — it is the most honest data point you will have.

The build order and its whys

The order is not negotiable on a whim: it follows the dependencies. You can't test the API without services, or the services without the domain. The report only needs the CSV, so it comes after persistence.

flowchart TD
    H1["H1 Domain<br/>Book, Member, errors, coupons"] --> H2["H2 Services<br/>SalesService"]
    H2 --> H3["H3 Persistence<br/>JSON/CSV repositories"]
    H3 --> H4["H4 Interface<br/>Flask or Django"]
    H3 --> H5["H5 Report<br/>pandas + Matplotlib"]
    H4 --> H6["H6 Polish<br/>logging, README"]
    H5 --> H6

Why core → persistence → interface → data, and not the other way around?

  • The domain first because everything else consumes it and because it is the cheapest thing to test: pure functions, no files, no HTTP. A rounding error caught in H1 costs a minute; caught in H4, an afternoon of chasing it through three layers.
  • Persistence before the interface because the interface needs real data to serve, and because the atomic save shapes the design of the service (who saves — the service or the endpoint? — we decide below).
  • The interface afterwards because it is the layer that translates, not the one that solves: by the time you get there, every endpoint will be a 10-line function calling an already-tested service. That is how you can tell the design is good: the "hard" part turns out easy.
  • The report almost last because it only depends on the CSV: it is independent of the interface (notice: H4 and H5 have no arrow between them — if you ever get stuck on the interface, push the report forward; having parallelizable work is gold for morale).

Layered architecture

The M10 rule — "the web translates, the package solves" — becomes explicit four-layer architecture here:

flowchart TD
    subgraph Interface["Interface layer (app.py / views.py)"]
        direction LR
        A["HTTP → service calls<br/>domain errors → HTTP status codes"]
    end
    subgraph Services["Service layer (services.py)"]
        B["SalesService: orchestrates rules,<br/>repositories and logging"]
    end
    subgraph Domain["Domain layer (models, errors, coupons)"]
        C["Pure rules: prices, discounts,<br/>validations. No files, no HTTP"]
    end
    subgraph Persistence["Persistence layer (repositories.py)"]
        D["Load/save JSON and CSV.<br/>No business rules"]
    end
    Interface --> Services
    Services --> Domain
    Services --> Persistence

Two dependency rules hold the whole thing up:

  • Downwards, never upwards: the domain doesn't know Flask exists; the repositories don't know what a discount is. If models.py imports anything from app.py, the design is broken (and you probably have a circular import, that old M3 trap).
  • The service layer is the only one that orchestrates: domain (compute the price) + persistence (save) + logging. Endpoints never touch a file or compute a price; they only translate HTTP ↔ service.

The practical prize: the tests. The domain is tested with no disk or network (fast); the repositories with tmp_path (M9); the interface with the test client. Each layer, with the tool that fits it — you will see it in 12-04.

Project directory structure

This is the tree for track A (track B replaces app.py with the Django project from 10-04, and the ORM takes over catalog.json):

papyrus_online/
├── papyrus/                  # the package: ALL the logic lives here
│   ├── __init__.py
│   ├── models.py             # Book, Member (dataclasses, M5)
│   ├── errors.py             # PapyrusError hierarchy (M7)
│   ├── coupons.py            # COUPONS, apply_coupon (M7)
│   ├── services.py           # SalesService (new, H2)
│   ├── repositories.py       # CatalogRepository, MembersRepository, SalesLog (H3)
│   └── report.py             # monthly report with pandas (H5)
├── app.py                    # Flask interface: translation only (H4, track A)
├── data/
│   ├── catalog.json          # the 4 canonical books
│   ├── members.json          # LUIS-001, MARTA-002, PAU-003
│   ├── sales.csv             # created on the first sale
│   └── papyrus.log           # FR7
├── tests/
│   ├── conftest.py           # canonical fixtures (the 4 books)
│   ├── test_models.py
│   ├── test_services.py
│   ├── test_repositories.py
│   └── test_app.py           # test client
├── DECISIONS.md              # your decision journal (milestone 0.1)
├── README.md                 # NFR4 (template in 12-05)
└── requirements.txt

Notice that warehouse.py disappears: its responsibilities split between services.py (the rules: selling, restocking, closing the till) and repositories.py (loading/saving). It is the natural evolution of the M5-M6 design now that you know how to separate layers.

Data design: the three files

Fixing the schemas before programming prevents the worst class of bug: two modules that understand the same file differently.

data/catalog.json — a list of objects, unique title:

[
  {"title": "The Odyssey", "author": "Homer", "price": 12.50, "stock": 4},
  {"title": "Hamlet", "author": "Shakespeare", "price": 9.95, "stock": 6},
  {"title": "Don Quixote", "author": "Cervantes", "price": 15.90, "stock": 8},
  {"title": "Faust", "author": "Goethe", "price": 21.00, "stock": 10}
]

data/members.json — the unique code as the natural key:

[
  {"code": "LUIS-001", "name": "Luis", "joined": "2025-03-12"},
  {"code": "MARTA-002", "name": "Marta", "joined": "2025-06-30"},
  {"code": "PAU-003", "name": "Pau", "joined": "2026-01-15"}
]

data/sales.csv — one row per sale, with a header:

Column Type Example Note
date YYYY-MM-DD 2026-07-13 ISO format, like the date used all course long
title str Faust must exist in the catalog at the time of the sale
units int ≥ 1 2
amount float, 2 decimals 37.34 total amount for the line, discounts already applied

Clear types = fewer surprises: price and stock are float and int in the JSON, not strings; units in the CSV will arrive as text and someone (the repository, not the report) must convert it. Deciding that now, here, is design.

Contracts before code

The type hints from 08-01 were never decoration: they are specification. Writing the signatures before the bodies forces you to decide inputs, outputs and errors while changing your mind is still cheap. These are the contracts at the heart of the system:

Function / method Signature (contract) Errors it raises
Book.final_price (self, member: bool = False) -> float
apply_coupon (amount: float, code: str) -> float InvalidCouponError
SalesService.sell (self, title: str, units: int, member_code: str | None = None, coupon: str | None = None) -> Sale BookNotFoundError, InsufficientStockError, InvalidMemberError, InvalidCouponError
SalesService.close_till (self, date: str) -> float
CatalogRepository.load (self) -> dict[str, Book] FileNotFoundError (a missing file is a technical error, not a business one)
CatalogRepository.save (self, catalog: dict[str, Book]) -> None — (atomic: all or nothing)
SalesLog.record (self, sale: Sale) -> None
generate_report (csv_path: Path, month: str, png_path: Path) -> MonthSummary FileNotFoundError

Sale and MonthSummary will be dataclasses (M5): a sale with date, title, units, amount; a summary with total_units, total_amount, top_titles, best_day. Defining these return types is already half the design of H2 and H5.

Look at what the table decides without writing a line of code: that sell receives the member's code (not a boolean — the service validates against members.json, something M5's final_price(member=True) could never do), and that it returns a Sale (not None), because the interface will want to show the amount.

Design decisions and their whys

Every decision discards an alternative. Documenting the why (in DECISIONS.md) is what separates a criterion from a coincidence:

Decision Discarded alternative Rationale
Catalog as dict[str, Book] (key = title) A list of books O(1) lookup and uniqueness for free (M4); a list forces scans and duplicate-watching
The service saves after every sale Save only on exit If the program dies, no sales are lost; the cost (writing a small JSON) is negligible
Coupon applied after the member discount Before, or mutually exclusive It is the M7 business rule and the one the canonical amounts reproduce (18.67 EUR)
Business errors = custom exceptions Returning None / status codes The interface can tell what failed and pick the right HTTP code (404 vs 409); None says nothing
Sales in CSV (not JSON) A sales JSON It is append-only (one line per sale, no file rewrite) and pandas reads it straight in for FR6
Dates as ISO text datetime objects in the files JSON has no date type; ISO sorts correctly as text and pandas parses it with parse_dates

When you hesitate between two options and both seem valid: choose the one that is easier to test. It is a tiebreaker that almost never fails.

Time management for self-taught learners

With no deadlines and no boss, the risk isn't doing it badly: it's not finishing it. Three practices that work:

  • Honest timeboxing: 60-90 minute sessions with a goal written down before you start ("today: CatalogRepository with its tests"). If the goal doesn't fit in the session, it was too big: split it.
  • A definition of "done" — a milestone is done when, and only when: (1) its demo from the milestone table works, (2) its tests pass, (3) the public functions have type hints, and (4) you have noted in DECISIONS.md any decision you made. Without all four, it is "almost done", which is the state where projects go to die.
  • The next-session rule: end each session by writing down the first step of the next one. Picking a project back up is the hardest part; gift yourself the start.

An indicative pace: H1 in 1-2 sessions, H2 in 2-3, H3 in 2, H4 in 2-3 (A) or 3-4 (B), H5 in 1-2, H6 in 1. Between 9 and 15 sessions: a two-to-four-week project at a self-taught pace. It is a plan, not a promise — adjust it after H1 with your real speed.

Common Mistakes and Tips

  • Overdesigning. You don't need abstract interfaces or patterns you don't understand "just in case". Four layers, eight contracts and three schemas: that is enough design for this size. Overdesign is procrastination with a clean conscience.
  • Skipping the contracts "because I have it all in my head". Your head holds two signatures; by the third they start contradicting each other. Write them down: the contracts table is the page you will consult most in 12-03.
  • Confusing layer with folder. You can have the four layers in four flat files (like our tree); what defines a layer is what imports what, not where the file sits.
  • Estimating in absolute hours. "This is two hours" always fails; "this is like H1, which took me one session" fails far less. Estimate by comparing, not by guessing.
  • Not deciding who converts the types. The classic: the CSV hands over "2" and the report adds strings. This lesson's rule: the persistence layer delivers correct types; from the repository onwards, everything is an int, a float or a Book.

Exercises

  1. Milestone 0.4 — Project skeleton. Create the full directory tree (with __init__.py, files empty or containing pass, and the three data files with the canonical content from this lesson). Verification: python -c "import papyrus" works from the project root, and pytest runs (even if it reports "no tests ran").
  2. Milestone 0.5 — Complete contracts. Copy the contracts table into DECISIONS.md and add the ones missing for your track: A) the 6 API routes with HTTP method, input and response codes; B) the views and forms. Implement nothing: only signatures and errors.
  3. Milestone 0.6 — Your plan. Write your milestone calendar (dates or number of sessions per milestone) and your personalized definition of "done". A realistic commitment: if you only have 3 sessions a week, the plan should say so.

Solutions

  1. Extra verification criterion: from the root, python -c "from papyrus import models, errors, services, repositories" must not fail. If it fails with ModuleNotFoundError, check that papyrus/__init__.py exists and that you are running from the root (the M3 classic).
  2. Example of a route contract (track A): POST /api/sales — JSON input {"title": str, "units": int, "member": str|null, "coupon": str|null} — responses 201 with the created sale, 404 if the title doesn't exist, 409 if there is no stock, 400 if the member or coupon is invalid or the JSON is malformed. If your table doesn't say which code each domain error returns, it isn't finished yet.
  3. There is no single solution; there is a check: show the plan to your skeptical self. If one week says "H2 + H3 + H4", your skeptical self is right.

Conclusion

You no longer have a wish ("build Papyrus Online"): you have a plan. Six milestones with a verifiable demo, an order that follows the real dependencies (domain → services → persistence → interface and report), a four-layer architecture where the web translates and the package solves, data schemas that remove ambiguity and type-hinted contracts that are specification, not decoration. And something just as important: a definition of "done" and an honest calendar. The next lesson is the one you have been waiting twelve modules for: opening the editor and building, milestone by milestone, with skeletons where you must do the thinking and a full solution where the integration justifies it. The blueprint is on the table; now, the bricks.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved