Planning before you code
Most people open an editor and start typing. For anything past fifty lines that costs more time than it saves, because the decisions you make in the first ten minutes shape everything after them — and the cheapest moment to change your mind is before any code exists.
This lesson is the plan. It takes about twenty minutes.
Decide the data shape first
Everything else follows from this, so it goes first.
An expense needs: an id, an amount, a category, a date, and an optional note.
{
"next_id": 3,
"expenses": [
{
"id": 1,
"amount_paise": 25000,
"category": "food",
"date": "2026-09-27",
"note": "lunch"
},
{
"id": 2,
"amount_paise": 1500000,
"category": "rent",
"date": "2026-09-27",
"note": ""
}
]
}
Four decisions are embedded there, each worth stating.
amount_paise, an integer. Module 2 said never store money as a float.
₹250.00 is 25000 paise. All arithmetic is exact, and we divide by 100 only when
displaying. The field name says the unit so nobody has to guess — amount: 25000
would be a bug waiting to happen.
Dates as ISO strings. Module 7: JSON has no date type, "2026-09-27" sorts
correctly as text, and date.fromisoformat() reads it back.
A top-level object, not a bare array. An array of expenses would be simpler
today and leaves nowhere to put next_id, or a schema version, or any setting
added later. An object costs one level of nesting and leaves room.
next_id stored explicitly rather than computed as "highest id plus one" —
which would reuse an id after a deletion, so a deleted expense's id could come
back attached to something else.
Choose the operations
From the brief, each with its input and output:
| Command | Takes | Returns |
|---|---|---|
add |
amount, category, note | the created expense |
list |
optional category, optional month | matching expenses |
summary |
nothing | totals and a breakdown |
delete |
id | the deleted expense, or an error |
Writing these down as a table is the point: it is the shape of your code before you write any.
Decide the structure
Module 9's division — data is a class, process is functions:
expense-tracker/
src/expense_tracker/
__init__.py
models.py the Expense type and its rules
storage.py reading and writing the JSON file
reporting.py filtering, totals, formatting
cli.py argument parsing and output
__main__.py entry point
tests/
test_models.py
test_reporting.py
test_storage.py
pyproject.toml
README.md
.gitignore
The rule behind that split is one reason to change per file. Change the
storage format and only storage.py moves. Change how a summary looks and only
reporting.py does.
cli.py is the only file that prints. Everything else returns values, which is
module 10's requirement for testable code — the layer that touches the outside
world is thin and sits at the edge.
Decide the dependencies
argparse standard library — command parsing
json standard library — storage
pathlib standard library — file location
dataclasses standard library — the Expense type
pytest development only
ruff development only
Nothing to install for the program to run. That was not a goal, but module 8's question — does this dependency earn its place? — answers itself here.
Decide the failure cases
Listing these now means handling them rather than discovering them:
| Situation | Response |
|---|---|
| Data file missing | Treat as empty; create on first save |
| Data file corrupt | Clear message, do not overwrite it |
| Amount not a number | Error: amount must be a number |
| Amount zero or negative | Error: amount must be positive, got X |
| Unknown category | Error naming the valid ones |
| Delete an id that does not exist | Error: no expense with id X |
| Note extremely long | Truncate at 200 characters |
"Do not overwrite it" matters. If the file will not parse, the worst response is to start fresh and destroy whatever was there. Report the problem and stop; the user may be able to fix it by hand.
Decide where the data lives
Module 7's warning: a relative path resolves against wherever the program was started.
DATA_FILE = Path.home() / ".expenses.json"
The user's home directory means expenses works from anywhere. An environment
variable makes it overridable and — usefully — lets tests point it somewhere
disposable:
DATA_FILE = Path(os.environ.get("EXPENSES_FILE", Path.home() / ".expenses.json"))
Write the interface before the code
Sketch the function signatures. No bodies:
# models.py
@dataclass(frozen=True)
class Expense:
id: int
amount_paise: int
category: str
date: date
note: str = ""
@property
def rupees(self) -> float: ...
@classmethod
def from_dict(cls, data: dict) -> "Expense": ...
def to_dict(self) -> dict: ...
# storage.py
def load(path: Path) -> tuple[list[Expense], int]: ...
def save(path: Path, expenses: list[Expense], next_id: int) -> None: ...
# reporting.py
def filter_expenses(expenses, category=None, month=None) -> list[Expense]: ...
def total_paise(expenses) -> int: ...
def by_category(expenses) -> dict[str, int]: ...
def format_table(expenses) -> str: ...
Twenty minutes, and the whole program is visible. Two things usually surface here that would otherwise surface in an hour's time:
load returning a tuple of expenses and next_id is slightly awkward.
Worth noticing now — the alternative is a small container class, and either is
defensible. Deciding deliberately beats discovering.
frozen=True on the dataclass, from module 9. An expense does not change
after creation; editing means replacing. That removes a category of bug outright.
The order to build in
Thin slices, each ending somewhere runnable:
Expensewith validation, and its testsstorage.loadandsave, with a temporary hard-coded listaddend to end — data survives between runslist, unfilteredlistwith filterssummarydelete- Errors and messages
- Polish and README
Step 3 is the milestone. Once something is saved and read back, the program is real and everything after is addition rather than construction.
Check your work
Your JSON shape. If it differs from the lesson's, that is fine provided you can defend it. The three decisions worth having reached:
Amounts as integer paise, because module 2 established that money must not be a float.
Dates as ISO strings, because JSON has no date type and ISO sorts correctly as text.
A top-level object rather than a bare array, so there is somewhere to put
next_id — and later a schema version, or any setting.
next_id stored rather than computed. Computing it as "highest id plus one"
reuses an id after a deletion, so a deleted expense's id can come back attached
to something else.
The tuple return. load() returning (expenses, next_id) is slightly
awkward, and the alternative is a small container class. Either is defensible —
noticing it at the planning stage rather than an hour into building is the
point of writing signatures before bodies.
The failure table. At least eight rows: missing file, corrupt file,
non-numeric amount, zero or negative amount, unknown category, unknown id on
delete, an over-long note, and a data directory that does not exist. If you
missed the last, you will meet it when save first runs on a fresh machine.
Disagreeing with a decision. All three named are genuinely arguable. Having an opinion and being able to say what the alternative would cost is the skill — "because the lesson said so" is not a reason you can use at work.
Practice
Write down, before the next lesson:
- Your JSON shape. If it differs from the one above, note why — there is more than one reasonable answer.
- Your file layout.
- Every function signature with a type hint and a one-line docstring, and no body.
- Your failure table, with at least eight rows.
- Your build order.
Then a harder one:
- Find one decision above you disagree with. Write down what you would do
instead and what it would cost.
next_id, storing paise, and the tuple return are all defensible either way — having an opinion and being able to justify it is the skill.
Next: building it.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship