With the DEFINITION.md from Project definition and the DESIGN.md from Planning and design on the table, the part you have been waiting for begins: writing the program. And this is where most first projects are lost, not for lack of knowledge — you have all of it — but for lack of method. Beginners tend to open all five files at once, write four hundred lines without running anything and then find thirty chained errors they no longer know how to grab hold of. This lesson teaches the opposite: building in small increments, with the program always runnable, testing and committing at every step, and with a concrete procedure for the two difficult moments — when something fails and you do not know why, and when you get stuck and do not know how to carry on.

Contents

  1. The strategy: incremental and always runnable
  2. Build order
  3. Phase 1: the skeleton that starts
  4. Phase 2: the model and its tests
  5. Phase 3: storage and its round-trip test
  6. Phase 4: the collection logic
  7. Phase 5: the interface and its manual test
  8. When something does not work: debugging properly
  9. Handling user errors
  10. Commits during development
  11. When you get stuck
  12. The "done" checklist
  13. Common mistakes and tips
  14. Exercises
  15. Conclusion

  1. The strategy: incremental and always runnable

The rule is a single sentence: the program must be runnable at all times, from minute one — when it only shows an empty menu — through to v1.0. There is never an intermediate state of "it is half done, it does not start". Compare the two ways of working. In the "all at once" approach you write all five modules before running anything, the first startup throws thirty chained errors without you knowing which causes which, the commit is enormous and there is nothing to show until the end. In the incremental approach you write the minimum and run it, each error shows up on its own and over the code you have just touched, there is one commit per increment and something that works from day one.

The underlying difference is in the second point. When a failure appears right after you have written ten lines, the suspect is those ten lines. When it appears after four hundred, the suspect is the whole program, and debugging goes from five minutes to two hours.

The working cycle, then, is this short loop:

flowchart LR
    E["Write<br/>a small increment"] --> R["Run<br/>python -m myexpenses"]
    R --> P["Test<br/>pytest"]
    P --> C["Commit<br/>descriptive message"]
    C --> E
    R -->|fails| D["Debug"]
    D --> R
    P -->|fails| D

One full turn of that loop should take between fifteen minutes and an hour: if you have gone three hours without returning to the "Run" square, the increment was too big.

  1. Build order

The order is not arbitrary: you build from the inside out, because each layer depends on the previous one and because the inner layers are the ones that can be tested automatically.

Phase What you build How you check it Est.
1. Skeleton __main__.py and interface.py with the menu and the exit It starts and exits 1 h
2. Model model.py: entity, validation, exception pytest tests/test_model.py 2 h
3. Storage storage.py: save and load pytest with tmp_path 2 h
4. Logic ledger.py: add, find, filter, totals pytest tests/test_ledger.py 3 h
5. Interface The menu options, one by one Manual test script 6 h
6. Extras Shoulds, robustness, logging, formatting Script + final review 4 h

Two warnings about this order. The first: even though the interface is last on the list, the milestone of the first complete end-to-end feature comes before all the layers are finished. As soon as you have the model, storage and an add in the logic, do the "record" menu option in full; seeing the program do something real changes your motivation more than anything else. And the second: phases 2, 3 and 4 are tested automatically; phase 5 is not. Testing a command-line interface automatically is possible but expensive, and it is not what a first project calls for: for that there is the manual script in section 7.

  1. Phase 1: the skeleton that starts

The first goal is for python -m myexpenses to work. Nothing more.

# myexpenses/interface.py -- presentation layer
OPTIONS = {"1": "Record expense", "2": "Record income", "3": "Month transactions",
           "4": "Summary by category", "5": "Delete transaction", "0": "Exit"}


def run() -> None:
    """Main application loop."""
    while True:
        print("\n=== MyExpenses ===")
        for key, text in OPTIONS.items():
            print(f"  {key}. {text}")
        option = input("Option: ").strip()
        if option == "0":
            print("See you later.")
            break
        print(f"[pending] {OPTIONS[option]}" if option in OPTIONS else "Invalid option.")

# myexpenses/__main__.py  ->  from .interface import run; run()

That is twenty lines and none of them does anything useful yet, but they solve at a stroke the three problems that block beginners the most: getting the package to import properly, getting execution with -m to work, and having the main loop from Interactive menus. The [pending] on each option is deliberate: the complete menu exists from day one and the options get filled in one by one. Commit: Skeleton: main menu that starts and exits.

  1. Phase 2: the model and its tests

Now the entity, with all its validation inside. It is the most important piece of the program: if the model guarantees that an invalid transaction cannot exist, no other layer has to worry about checking.

# myexpenses/model.py -- domain entities and their validation rules
CATEGORIES = ("food", "transport", "leisure", "home", "health", "income", "other")


class InvalidTransaction(ValueError):
    """Raised when a transaction's data breaks the rules."""


@dataclass
class Transaction:
    """An income or expense. The sign of the amount tells them apart."""

    date: date
    description: str
    amount: float
    category: str
    id: int = 0

    def __post_init__(self) -> None:
        self.description = self.description.strip()          # normalise...
        self.category = self.category.strip().lower()
        if not self.description or len(self.description) > 60:  # ...then validate
            raise InvalidTransaction("The description must be 1 to 60 characters.")
        if self.amount == 0:
            raise InvalidTransaction("The amount cannot be zero.")
        if self.category not in CATEGORIES:
            raise InvalidTransaction(f"Unknown category: {self.category!r}.")
        if self.date > date.today():
            raise InvalidTransaction("The date cannot be in the future.")

    def is_expense(self) -> bool:
        return self.amount < 0

    def __str__(self) -> str:                     # ready-formatted table row
        return f"{self.id:>4}  {self.date}  {self.description:<30} {self.amount:>10.2f}"

The decisions in there, one by one. @dataclass (07-02) saves writing an __init__ that would only assign five attributes. __post_init__ is the method dataclass calls right after construction, and that is where the validation goes. It normalises before validatingstrip() and lower() — so that " Food " and "food" are the same thing, which was one of the edge cases anticipated in 09-01. The custom exception inherits from ValueError because it is a value error: anyone who does not know InvalidTransaction can catch ValueError and it will work just the same. And __str__ returns the formatted table row already, with the fixed widths from the screen sketch.

The tests, with the AAA pattern from Automated testing:

# tests/test_model.py
def test_normalises_description_and_category():
    t = Transaction(date(2026, 8, 1), "  Coffee  ", -1.5, " FOOD ")
    assert t.description == "Coffee" and t.category == "food" and t.is_expense()


@pytest.mark.parametrize("description, amount, category", [
    ("", -10.0, "food"),            # empty description
    ("Groceries", 0.0, "food"),     # zero amount
    ("Groceries", -10.0, "travel"), # unknown category
])
def test_invalid_data(description, amount, category):
    with pytest.raises(InvalidTransaction):
        Transaction(date(2026, 8, 1), description, amount, category)


def test_future_date_rejected():
    tomorrow = date.today() + timedelta(days=1)   # computed, never hard-coded
    with pytest.raises(InvalidTransaction):
        Transaction(tomorrow, "Groceries", -10.0, "food")

Notice what gets tested: one good case, the normalisation and every bad case. The three invalid ones go into a parametrize because they are the same test with different data, and pytest counts them as three separate tests, so if one fails you know which. The future-date one computes "tomorrow" instead of writing a fixed date: a test using date(2027, 1, 1) would start failing on its own in 2027, and a test that expires is worse than no test at all. Commit: Transaction model with validation and tests (FR-1).

  1. Phase 3: storage and its round-trip test

save is the easy part: it builds the dictionary with the structure you settled on in 09-02 — version, next_id and the list of transactions converted with t.date.isoformat() — and writes it with path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"). The part that has to be done properly is reading:

# myexpenses/storage.py   (PATH = Path("expenses.json"), logger = getLogger(__name__))

def load(path: Path = PATH) -> tuple[list[Transaction], int]:
    """Reads the saved transactions. Returns an empty list if there is no file."""
    if not path.exists():
        return [], 1                       # first startup: not an error
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError:
        logger.error("Corrupt data file: %s", path)
        return [], 1                       # logged and carry on, do not die
    transactions = [
        Transaction(date.fromisoformat(d["date"]), d["description"],
                    d["amount"], d["category"], id=d["id"])
        for d in data.get("transactions", [])
    ]
    return transactions, data.get("next_id", len(transactions) + 1)

What to look at here is the fault tolerance, exactly as in EasyTask's storage.py: a missing file is the first startup and a corrupt one is written to the log (08-02) instead of killing the program. And the path parameter with a default value is no whim: it is what lets the tests use a temporary file.

# tests/test_storage.py
def test_save_and_load_cycle(tmp_path):
    path = tmp_path / "data.json"
    originals = [Transaction(date(2026, 8, 1), "Groceries", -62.35, "food", id=1),
                 Transaction(date(2026, 8, 3), "Salary", 1450.0, "income", id=2)]
    save(originals, next_id=3, path=path)

    recovered, next_id = load(path)

    assert next_id == 3 and len(recovered) == 2
    assert recovered[0].amount == -62.35          # still a float
    assert recovered[1].date == date(2026, 8, 3)  # still a date


def test_missing_file_does_not_fail(tmp_path):
    assert load(tmp_path / "does_not_exist.json") == ([], 1)


def test_corrupt_file_does_not_fail(tmp_path):
    (tmp_path / "broken.json").write_text("{not json", encoding="utf-8")
    assert load(tmp_path / "broken.json")[0] == []

The first one is the round-trip test: I save, I load and I check that what comes back is identical to the original. It is the most profitable test in the whole persistence layer, because a single assert catches serialisation, type and structure failures. pytest's tmp_path fixture gives a different temporary directory per test and deletes it afterwards, so the tests never touch your real data nor get in each other's way. Commit: JSON storage tolerant of missing or corrupt file (FR-6).

  1. Phase 4: the collection logic

# myexpenses/ledger.py -- collection of transactions and operations on it


class Ledger:
    """Holds the transactions and answers queries about them."""

    def __init__(self, transactions: list[Transaction] | None = None, next_id: int = 1):
        self._transactions = transactions or []
        self.next_id = next_id

    def add(self, transaction: Transaction) -> Transaction:
        """Assigns an identifier to the transaction and appends it to the ledger."""
        transaction.id = self.next_id
        self.next_id += 1
        self._transactions.append(transaction)
        return transaction

    def for_month(self, month: str) -> list[Transaction]:
        """Transactions for a 'YYYY-MM' month, sorted by date."""
        of_month = [t for t in self._transactions if t.date.isoformat().startswith(month)]
        return sorted(of_month, key=lambda t: t.date)

    def total_by_category(self, month: str) -> dict[str, float]:
        """Adds up each category's amounts for the month, biggest expense first."""
        totals: dict[str, float] = {}
        for t in self.for_month(month):
            totals[t.category] = totals.get(t.category, 0.0) + t.amount
        return dict(sorted(totals.items(), key=lambda pair: pair[1]))

    def balance(self, month: str) -> float:
        """Difference between the month's income and expenses."""
        return round(sum(t.amount for t in self.for_month(month)), 2)

It is the literal translation of the pseudocode from 09-02 (still missing are __len__, __iter__, find — a next() over the list returning None when there is no match — and remove, which uses find and returns True or False), and three things are worth pointing out. total_by_category reuses for_month instead of repeating the filter: one responsibility, one place. The key=lambda pair: pair[1] sorts by value, and since expenses are negative, ascending order puts the biggest expense first, which is what the screen sketch wanted (06-02 and 04-05 working together). And the round(..., 2) in balance stops floating-point arithmetic from showing through: without it, -62.35 + 1450.0 can print 1387.6500000000001.

This layer's tests focus on the calculations, which is where an error can genuinely hide. And here there is nothing to invent, because every desk-check table from 09-02 turns into a def test_: the same four transactions from that table, the same month and the same expected result.

def test_total_by_category_groups_and_filters_the_month():
    led = Ledger()
    led.add(Transaction(date(2026, 7, 30), "July", -20.0, "food"))     # another month
    led.add(Transaction(date(2026, 8, 1), "Groceries", -62.35, "food"))
    led.add(Transaction(date(2026, 8, 4), "Dinner", -15.0, "food"))
    led.add(Transaction(date(2026, 8, 1), "Travel pass", -32.0, "transport"))

    totals = led.total_by_category("2026-08")

    assert totals == {"food": -77.35, "transport": -32.0}
    assert list(totals)[0] == "food"   # the biggest expense comes first

Commit: Ledger: add, remove, month filter and totals (FR-3, FR-4, FR-5).

  1. Phase 5: the interface and its manual test

The interface is filled in one option at a time, and each option is finished completely before the next one is started. Its rule is the one from 04-04: input and print happen here and nothing else; no domain calculations.

def register_expense(ledger: Ledger) -> None:
    """Menu option 1: records an expense."""
    description = input("Description: ")
    amount = ask_amount("Amount (EUR): ")
    category = input(f"Category {CATEGORIES}: ")
    date_text = input("Date YYYY-MM-DD (blank = today): ").strip()
    try:
        day = date.fromisoformat(date_text) if date_text else date.today()
        transaction = ledger.add(Transaction(day, description, -amount, category))
    except (ValueError, InvalidTransaction) as error:
        print(f"Not recorded: {error}")
        return
    print(f"Expense recorded (id {transaction.id}).")

Three deliberate details. ask_amount (a while True loop with try/except ValueError that only returns when the value is greater than zero) insists until it gets a valid number instead of giving up (02-04), and does a .replace(",", ".") to accept a comma as the decimal separator, which was another anticipated edge case. The amount is asked for as a positive number and stored as -amount: you cannot expect the user to understand the design's sign convention. And the try/except catches both ValueError (a badly written date) and InvalidTransaction (the model's rules) and returns to the menu with a message, satisfying NFR-1.

Since this is not tested automatically, it is tested with a written script that you run in full before signing off each working session:

# What I do What should happen OK?
1 I delete expenses.json and start Starts without error, empty ledger
2 Option 3 with no data "No transactions in 2026-08", not an empty table
3 I record a valid expense Confirms with id 1
4 I record with the amount twelve and then 12,50 Asks again without breaking; then accepts it as 12.50
5 I record with the category travel Unknown category message, back to the menu
6 I record with the date 2026-02-31 Invalid date message, back to the menu
7 I exit and come back in The transactions are still there
8 I delete id 99 and then a real one "No transaction 99" with no traceback; the real one disappears from the file
9 Option 7 and then abc "Invalid option" in both cases
10 Option 4 with data from two months Only adds up the requested month; percentages come to 100 %

That script is your safety net for what the automated tests do not cover. Keep it in tests/manual_script.md and run through it in full before every version tag.

  1. When something does not work: debugging properly

Sooner or later something fails and you do not know why. Apply the method from Debugging and error handling to a real case: the August summary shows a percentage of 137 %.

Step What you do In this specific failure
1. Reproduce Find the exact recipe that always triggers it With sample_data.json, option 4, month 2026-08
2. Read the error The traceback is read from the bottom up: type, file and line; the first line from your files is the suspect There is no traceback here: it is a wrong result, the hardest case
3. Concrete hypothesis A falsifiable sentence, not "something is failing" "The denominator includes the income, which is positive, and that is why the total is smaller than it should be"
4. Check it A breakpoint on the suspect line and an inspection of the variables totals includes "income": 1450.0 and total_expenses is 892.9: confirmed
5. Fix the cause Never the symptom sum(totals.values())sum(v for v in totals.values() if v < 0)
6. Regression test The test that would have failed before the fix assert total_expenses == -100.0 with two expenses and one income

Step 5 deserves emphasis: the bad fix would have been to subtract 1450 somewhere so that the number added up. It would have worked with that data and would have failed again with different data. And step 6 is not optional: a failure fixed without a test comes back, usually in the next refactoring. Commit: Fix the summary percentage, which included the income.

  1. Handling user errors

Before closing the implementation, do a systematic review: list every user input and decide what you do for each. There are two strategies and it pays not to confuse them. Validating means checking before acting and asking for the value again: it is for what the user can correct. Catching means letting the operation fail and trapping the exception with try/except: it is for what cannot be checked in advance without duplicating the work.

Input Risk Strategy Where
Menu option Text or a number out of range Validate against the dictionary interface.run
Amount Non-numeric, zero, negative, with a comma Validate in a loop interface.ask_amount
Date and month Impossible format or day Catch ValueError interface.register_expense
Category Unknown, upper case, spaces Normalise and validate model.Transaction
Id to delete Non-numeric or non-existent Catch + check for None interface + ledger.find
Data file Missing, corrupt, no permissions Catch and start empty storage.load

The golden rule is in the last column: domain-rule validation lives in the model, input-format validation lives in the interface. If you mix the two, you end up validating the category in three different places and forgetting one of them.

  1. Commits during development

A commit is a meaningful unit of work: something that works and that you could explain in one sentence. Not every Ctrl+S, nor once a week. In practice, one per turn of the loop in section 1.

$ git log --oneline
a91c02f Add CSV export for the month (FR-7)
7d4e1b8 Fix the summary percentage, which included the income
3f2a90c Add summary-by-category screen (FR-4)
c18b7e5 Ledger: add, remove, month filter and totals (FR-3, FR-5)
9e04a13 JSON storage tolerant of missing or corrupt file (FR-6)
2a1f6e0 Skeleton: main menu that starts and exits

That history reads like the project's diary: each line is an increment, each message starts with a verb and several cite the requirement they implement. Three practical rules: never commit with the tests red (if you need to save halfway, use a branch), do not mix refactoring with new functionality in the same commit (08-05), and do not store personal data or the virtual environment, which is what the .gitignore from 09-02 is for.

  1. When you get stuck

Getting stuck is normal and it happens to everybody. What sets apart the people who make progress is having a procedure instead of staring at the screen:

  1. The rubber duck. Explain the problem out loud, line by line, to an inanimate object. It sounds ridiculous and it works: by forcing yourself to put into words what you think the code does, you find the point where what you think and what happens do not match.
  2. Reduce to a minimal example and divide. Pull the suspect chunk out into a new ten-line file with made-up data: either the failure disappears — and the cause is in the context you removed — or it reproduces in ten lines, which you already know how to debug. With assert or print, locate the exact point where the data stops being what you expect; the failure is between the last correct point and the first incorrect one.
  3. Search for the exact message. Copy the error text without your variable names: TypeError: unsupported operand type(s) for -: 'str' and 'int', not error in my summary function. Searching for the literal message almost always finds the case.
  4. Read the official documentation. docs.python.org has the answer to any question about the standard library, with examples. A blog tutorial gives you a recipe; the documentation gives you the mental model, and that one serves the next twenty questions.
  5. Take a break. After forty minutes stuck, the chance of solving it drops off a cliff. Getting up for twenty minutes is the most effective debugging technique there is and the hardest one to apply.

On AI assistants: they are a legitimate tool and they will be part of your professional life, but in your first project they can steal precisely the learning you are after. Yes to having them explain an error message, to asking why a function behaves the way it does, to requesting a review of code you have already written and to clarifying a concept. No to "do my project for me" or to pasting code you do not understand: a project you cannot explain (09-04) is no use in an interview nor when it needs fixing. Rule of thumb: try the problem on your own for twenty minutes before asking, and never paste code you could not rewrite from memory in its essentials.

  1. The "done" checklist

Before calling the implementation closed and moving on to the presentation:

# Check Done?
1 Every Must requirement works end to end
2 The manual test script passes in full
3 pytest green, with tests for the model, the logic and persistence
4 First startup with no data file: works
5 No user input produces a traceback and the edge cases from 09-01 are checked
6 black . and ruff check . with no complaints
7 No dead code, no debugging print calls, no forgotten TODOs
8 Everything committed; git status clean
9 The program starts in a freshly cloned folder

Number 9 is the one that fails most often and the one that is most embarrassing in a demo: the program only worked because your folder had a file that was not in the repository. Test it for real: clone into another directory and run it.

Common Mistakes and Tips

  • Writing everything before running anything. The classic mistake. Thirty chained errors do not get debugged, they get abandoned. Run it every fifteen minutes.
  • Leaving validation until the end. "I will add the try/except once it works" ends with a program that breaks during the demo. Validation is written with the function, not afterwards.
  • Chasing the symptom. Subtracting a magic number so the total adds up covers up the failure, which will come back with different data: fix the cause and write the regression test.
  • Abandoning the tests halfway. When the urge to make progress bites, tests are the first thing to go, and that is exactly when they start being needed. Test at least the model and the calculations. And never mix refactoring with new functionality in the same commit: if something breaks, you will not know which of the two did it.
  • Tip: end every session with the program working and committed, and leave a two-line NEXT.md with what comes next. Coming back to a broken project is the best way never to come back, and those two lines save you twenty minutes of re-contextualising at every session.

Exercises

These are the next real tasks in your project. When you finish them you should have the implementation closed.

Exercise 1: Skeleton and tested model

Implement phase 1 (the program starts, shows the menu and exits) and phase 2 (your main entity with complete validation and its own exception). Write tests/test_model.py with at least six tests: a valid case, the data normalisation and four invalid cases, using parametrize for the ones that share a structure. Make one commit per phase.

Exercise 2: Persistence with a round-trip test

Implement the storage layer: saving and loading in the format you decided in 09-02, tolerant of a missing and a corrupt file. Write the three tests with tmp_path: the complete round trip, a non-existent file and a corrupt file. Then connect storage to the skeleton: load on startup, save on exit.

Exercise 3: One requirement end to end and its script

Choose your main functional requirement (the equivalent of FR-1) and implement it completely: menu option, validated input, call to the logic, persistence and on-screen confirmation. Then write your manual test script with a minimum of ten checks including the edge cases from 09-01, and run it in full, noting down whatever fails.

Solutions

Solution 1. Sections 3 and 4 contain the solution for MyExpenses. Contrast your model with these criteria: validation is inside the entity and not in the interface, normalisation happens before validation, the custom exception inherits from ValueError, and no test uses a fixed date that could expire. Rubric: does your model make it impossible to build an invalid object? Do you have at least one test per validation rule? Does pytest pass in under a second?

Solution 2. Section 5. The three points that usually go wrong: the path being a parameter with a default value (without that you cannot use tmp_path), the missing file returning empty instead of raising, and the types surviving the trip — a date must come back as a date and an amount as a float, not as strings. Rubric: does the round-trip test check concrete values and not just the length of the list? Do the tests use tmp_path and leave your real file alone? Does the first startup with no file work?

Solution 3. Section 7, with register_expense and the ten-check table. The essential thing is not the code but the division of labour: the interface asks and displays, the model validates, the ledger stores in memory, storage writes. If your recording function calculates something from the domain or your model prints, the separation of layers has broken down. Rubric: does your script include the empty-list case, the non-numeric input case and the non-existent identifier case? Does it pass in full, without a single traceback? Have you committed the requirement citing its number?

Conclusion

Implementing well is above all a question of method. The strategy is incremental and the program is always runnable: first the skeleton that starts and exits, then one complete end-to-end feature and only then the next, turning the write → run → test → commit loop every fifteen to sixty minutes. The build order goes from the inside out — model with its tests, storage with its round-trip test, collection logic, interface option by option and extras at the end — because the inner layers are the ones that get tested automatically and the ones that hold everything else up.

The tests are written with the code, not afterwards: one good case, the normalisation and every bad case in the model, with parametrize for the ones sharing a structure; the save/load cycle and the missing and corrupt files in storage with tmp_path; the calculations in the logic, turning every desk check from 09-02 into a def test_; and for the interface, a written manual script that gets run in full before every release. When something fails, the six-step method from 08-02 — reproduce, read, formulate a concrete hypothesis, check it with the debugger, fix the cause and write the regression test — turns a mystery into a twenty-minute task. User errors are split between validating (what is correctable, in the interface) and catching (what is unpredictable, with try/except), with the domain rules always in the model. Commits mark each increment with a verb and the requirement implemented, never red and never mixing refactoring with functionality. And for getting stuck there is a procedure: rubber duck, minimal example, searching for the exact message, official documentation, divide and test, and taking a break — with AI assistants used to understand and review, never to replace you, and always after twenty minutes on your own. The done checklist, with its final check of cloning into a clean folder and starting up, closes the implementation.

At this point you have a program that works, is tested and lives in a tidy repository. What is missing is what turns a project into something that counts for other people: knowing how to show it. In Project presentation we will make the repository presentable, write the README as a covering letter, prepare a five-minute demo and learn to defend the technical decisions and to talk about the limitations without selling ourselves short.

© Copyright 2026. All rights reserved