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 → storagemake_expense→ builds and validates → modelsInvalidExpenseError→ errors (imported by models and main, so it needs its own home to avoid a circular import)- totals and category breakdown → reporting
- the
while Truemenu → 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.pycan be reused by a future web version with no changes.- Module 9's tests can import
make_expensedirectly, with no menu in the way. - Two people can edit
reporting.pyandstorage.pywithout 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.pyand reducemain.pyto 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.