Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 8 of 8

The Module 6 expense tracker works, but it's one long file mixing storage, validation, reporting, and the menu loop. Let's give it a real structure.

Refactoring means changing how code is organised without changing what it does. The behaviour stays identical; the layout gets better.

The target layout

expense-tracker/
    main.py
    requirements.txt
    expenses/
        __init__.py
        errors.py
        models.py
        storage.py
        reporting.py

Each file has one job:

| File | Responsibility | | --- | --- | | errors.py | the custom exception types | | models.py | creating and validating an expense | | storage.py | loading and saving JSON | | reporting.py | totals and category breakdowns | | main.py | the menu loop that wires it together |

Notice main.py sits outside the package. The package is the reusable library; main.py is one particular way to drive it. That split is what makes the logic testable in Module 9.

Deciding where things go

Work through the original file and ask, for each function, "what is this really about?"

  • load_expenses, save_expenses → touch the disk → storage
  • make_expense → builds and validates → models
  • InvalidExpenseError → errors (imported by models and main, so it needs its own home to avoid a circular import)
  • totals and category breakdown → reporting
  • the while True menu → main

That last one matters: main.py should contain almost no logic, just the flow of the program.

Watch the import direction

Dependencies should flow one way: main imports the package, and inside the package, models and storage import errors. Nothing imports main.

If two modules import each other you get a circular import and Python raises ImportError. The usual fix is exactly what we did with errors.py: pull the shared thing into a module that both can depend on.

Building it

The exercise below writes the whole package to disk and then uses it — the same technique as lessons 7.2 and 7.3.

Two things to notice. Because __init__.py re-exports everything, callers write expenses.make_expense(...) and never need to know it lives in models.py — you could move it tomorrow without breaking them. And load_expenses(path=DATA_FILE) takes the path as a parameter with a default, which means tests can point it at a temp file instead of the real one.

What main.py looks like

Almost nothing but flow:

from expenses import (
    InvalidExpenseError,
    by_category,
    load_expenses,
    make_expense,
    save_expenses,
    total,
)

def main():
    records = load_expenses()
    print(f"Loaded {len(records)} expenses.")

    while True:
        print("\n1) Add  2) Report  3) Quit")
        choice = input("Choose: ")

        if choice == "1":
            try:
                expense = make_expense(
                    input("Description: "), input("Amount: "), input("Category: ")
                )
            except InvalidExpenseError as e:
                print(f"Rejected: {e}")
            else:
                records.append(expense)
                save_expenses(records)
                print("Saved.")

        elif choice == "2":
            for category, amount in sorted(by_category(records).items()):
                print(f"{category}: {amount:.2f}")
            print(f"TOTAL: {total(records):.2f}")

        elif choice == "3":
            break

if __name__ == "__main__":
    main()

The if __name__ == "__main__": guard from lesson 7.2 means main.py can also be imported (by a test, say) without launching the menu.

Was it worth it?

Same behaviour, more files. The payoff:

  • Fixing a storage bug means opening storage.py, not scrolling a 200-line script.
  • reporting.py can be reused by a future web version with no changes.
  • Module 9's tests can import make_expense directly, with no menu in the way.
  • Two people can edit reporting.py and storage.py without conflicts.

Don't over-apply this. A 40-line script should stay one file. Split when navigating becomes annoying — that's the real signal.

Take it further

  • Add expenses/cli.py and reduce main.py to two lines.
  • Write a requirements.txt (this project needs no third-party packages, so it can be empty with a comment).
  • Add a tests/ folder — you'll fill it in Module 9.

Module 7 complete

You can now import from the standard library, write and import your own modules, group them into packages, install third-party code with pip, isolate projects with virtual environments, record dependencies in requirements.txt, and structure a project so each file has one job.

Next up: Module 8 — Working with Data & APIs, where your programs start talking to the internet.

Questions about this lesson

Log in to ask a question.