MSBA-304 · Lesson 12 of 12 · 19 min
Case study: modernizing a legacy module
▶ Video coming soon A video version of this lesson is on the way.
The last session, on Friday, August 20, 2027, put the whole course to work on one real job. TrailCheck's oldest module, trails/legacy_import.py, read the trail spreadsheet that the editors kept and loaded it into the app. It had been written for an older version of Python, nobody had changed it in three years, and it was the reason new trail fields took a week to add. The partner publisher wanted two new fields by October: elevation gain and whether a trail was suitable for wheelchairs. Priya, Dmitri and Lena worked through the modernization in the Moncton boardroom as a rehearsal on a branch, following the course step by step: understand, pin behaviour, plan, refactor in small steps, add the feature, document, review and measure. The real merge was planned for the week after Dmitri's exam; everything after August 20 here is a plan.
Step 1: understand the module
Dmitri started with explanation, as in lesson 5. He selected the whole file and asked chat to explain it, list every input and side effect, and flag anything surprising. The answer described a 160-line function, load_trails, that opened a CSV file, skipped the header, split each line on commas by hand, converted lengths from miles to kilometres for some rows and not others, and wrote the result into a global dictionary. It flagged three surprises: the hand-written comma split, which would break on any trail name containing a comma; a bare except that silently skipped any row with an error; and the unit conversion, which depended on whether the length column ended in "mi".
Each claim was checked. Lena opened the spreadsheet export and found two trails whose names contained commas, both missing from the app, which nobody had noticed. The silent skip had hidden them for three years.
| Finding | Checked how | Result |
|---|---|---|
| Hand-written comma split | Searched the data for commas in names | Two trails affected, missing from the app |
| Bare except skips rows silently | Ran the loader with logging on | 3 of 214 rows skipped without a message |
| Unit conversion depends on a suffix | Read the data | 41 rows in miles, 173 in kilometres |
| Writes to a global dictionary | Searched for uses | Read by two other modules at import time |
Step 2: pin the current behaviour
Before changing anything, Dmitri asked Copilot to write characterization tests, as lesson 5 described, using a small invented CSV file with the same columns as the real one. He used the write-tests prompt file from lesson 3 with an extra instruction: "record current behaviour, including the skipped rows; do not correct anything". The tests captured the output for twelve rows, including one with a comma in the name and one with a bad length, so that both quirks were pinned.
#file:trails/legacy_import.py #file:tests/data/trails_sample.csv
Run the write-tests prompt for load_trails, but as characterization tests:
record exactly what load_trails returns today for trails_sample.csv,
including rows it skips. Do not fix or comment on bugs in the tests;
list them separately at the end of your answer.Step 3: plan with the team
The plan separated refactoring from behaviour changes, kept each pull request small and put the two bug fixes in their own steps so their effect would be visible. Priya asked Copilot Chat in plan mode to propose a sequence, then edited it with the team.
| Step | Change | Behaviour change? | Who and how |
|---|---|---|---|
| 1 | Characterization tests | No | Dmitri, chat with prompt file |
| 2 | Use the csv module instead of splitting by hand | Yes: fixes names with commas | Lena, chat; separate pull request |
| 3 | Replace bare except with explicit errors that are logged | Yes: skipped rows now reported | Dmitri, chat |
| 4 | Introduce a Trail dataclass and normalize units in one function | No | Agent mode, bounded to two files |
| 5 | Return trails instead of writing a global; update the two callers | No | Agent mode, tests must pass unchanged |
| 6 | Add elevation gain and wheelchair fields | Yes: the feature | Coding agent, from an issue |
| 7 | Documentation and changelog | No | Dmitri, chat and doctest |
Step 4: the core of the new module
Steps 2 to 5 replaced the hand-written parsing with Python's csv module, made errors explicit, introduced a small data class and removed the global. The cut-down version below shows the result. It reads the same sample data, keeps the two trails with commas in their names, reports the bad row instead of hiding it, and converts every length to kilometres in one place.
import csv, io
from dataclasses import dataclass
KM_PER_MILE = 1.609344
@dataclass
class Trail:
name: str
length_km: float
def parse_length(text: str) -> float:
text = text.strip().lower()
if text.endswith("mi"):
return round(float(text[:-2]) * KM_PER_MILE, 1)
if text.endswith("km"):
return round(float(text[:-2]), 1)
raise ValueError(f"unknown unit in {text!r}")
def load_trails(source):
trails, problems = [], []
for row_number, row in enumerate(csv.DictReader(source), start=2):
try:
trails.append(Trail(row["name"].strip(), parse_length(row["length"])))
except (ValueError, KeyError) as e:
problems.append((row_number, str(e)))
return trails, problems
sample = io.StringIO(
"name,length\n"
"Cove Path,3.2 km\n"
"\"Point, Upper and Lower Loop\",4.5 mi\n"
"Cape Trail,eight km\n"
"Headland Loop Trail,8.4 km\n"
)
trails, problems = load_trails(sample)
for t in trails:
print(t.name, t.length_km)
print("problems:", problems)Cove Path 3.2 Point, Upper and Lower Loop 7.2 Headland Loop Trail 8.4 problems: [(4, "could not convert string to float: 'eight '")]
What this code does
The loader reads a small table of trails with a name and a length, using Python's built-in CSV reader so that names containing commas stay in one piece. For each row it turns the length into kilometres, converting from miles where needed, and builds a simple trail record; if a length cannot be understood, it notes the row number and the reason instead of silently dropping it. The output lists three trails, including the one with a comma in its name, and one problem on row 4, where the length was written in words.
The row numbers start at 2 because row 1 of the spreadsheet is the header, so the problem report points the editors at exactly the line to fix. Lena suggested that detail after reading the first draft, which had started at 1. It is the kind of improvement that comes from someone who has talked to the people using the output.
Step 5: agents for the mechanical parts
Steps 4 and 5 were mechanical once the plan was clear: introduce the data class through the module, then replace the global with a return value and update the two callers. Dmitri gave both to agent mode as bounded tasks, using the framing from lesson 8: context, a definition of done, the files it could change, and "all existing tests must pass without modification". The agent finished step 4 in one pass. In step 5 it first tried to keep the global "for compatibility", which the task had not asked for; Dmitri rejected that change and restated the boundary, and the second attempt was clean.
The feature in step 6 was written as an issue for the coding agent, because it could be fully specified: two new columns, their types and allowed values, how missing values should be handled, tests to add and the API fields to expose. That pull request would be created and reviewed in September; for the rehearsal, the team wrote the issue and checked it against the review checklist before filing it.
Title: Add elevation gain and wheelchair suitability to trail import
Add two optional columns to the trail spreadsheet import:
- elevation_gain_m: whole number of metres, 0 or more; blank means unknown (None)
- wheelchair: "yes", "no" or blank; blank means unknown (None)
Expose both in GET /api/trails/{slug} as "elevation_gain_m" and "wheelchair".
Done means: tests for valid, blank and invalid values; invalid values reported
in problems with the row number; API docs updated; all existing tests unchanged.
Only change trails/legacy_import.py, trails/api.py, docs/api.md and tests/.Step 6: review
Each pull request went through the lesson 9 checklist. Two findings are worth recording. The csv module change in step 2 altered behaviour, as planned, and the characterization test for the comma row now failed, which was the point; the test was updated in the same pull request with a note explaining why, the one time an expected value should change. And in step 3, Copilot code review flagged that the new error messages included the raw row text, which for the volunteer columns could include a phone number. The messages were changed to show only the row number and the field name.
The review of the agent's pull request for step 5 used the session log, as in lesson 8. It showed that the agent had searched the whole repository for uses of the old global before changing the two callers, and that it had found a third use in a script under tools/ that nobody remembered. That script was outside the task's boundaries, so the agent had stopped and reported it rather than changing it, which was exactly the behaviour the boundaries were meant to produce. Dmitri added the script to a follow-up step. Lena, reviewing, added her own test case with a trail name in French with accents, following the rule from lesson 9 that reviewers of agent work add one test from their knowledge of the data. It passed, which was reassuring rather than wasted effort: the test now protects that case for the future.
Worked problem
Did the modernization pay off?
Before, adding a field to the import took about 5 working days, mostly because nobody understood the module. After the modernization, Priya estimates a new field takes about half a day. The modernization itself took about 4 days of team time across the steps. If the partner asks for 3 new fields a year, after how long does the work pay for itself?
Show solution
- Saving per field: 5 - 0.5 = 4.5 days.
- Saving per year: 3 x 4.5 = 13.5 days.
- Payback: 4 / 13.5 is about 0.3 years, roughly 3.5 months.
Answer: About three and a half months, if the partner's requests continue at that rate. The two bugs fixed along the way were a bonus that no estimate had counted.
Step 7: documentation and measurement
Dmitri asked chat to draft docstrings with doctest examples for the new functions and a short section in the developer guide describing the import format and the problem report. He checked every sentence against the code and ran the doctests. The changelog entry, drafted from the team's changelog prompt file, explained the two behaviour changes in plain words for the editors: trails with commas in their names now appear, and rows with problems are now listed instead of silently skipped.
| Measure | Before | After the rehearsal |
|---|---|---|
| Lines in the module | 160 | 74 |
| Rows silently skipped | 3 of 214 | 0; problems listed by row |
| Trails missing because of commas | 2 | 0 |
| Tests covering the import | 2 | 19 |
| Estimated time to add a field | About 5 days | About half a day |
What the case study shows
The modernization used almost every skill in the course: clear prompts and focused context, shared instructions and prompt files, characterization tests and edge cases, explanation and small refactors, debugging the silent skip, documentation checked against code, bounded agent tasks, a fully specified issue for the coding agent, careful review and honest measurement. Copilot did a large share of the typing. The team did the parts that decided whether the result was right: checking every explanation, choosing the order of steps, keeping behaviour changes separate, adding the row-number detail, rejecting the agent's unrequested compatibility layer and catching the personal data in error messages.
Dmitri's readiness week for the GH-300 exam would start on Monday, August 23, with the exam planned for the following Monday. Priya's advice for the exam matched the course: most questions describe a situation and ask what a careful developer or administrator would do. The careful answer is usually the one that keeps a person in control, keeps data safe and checks the result.
Common mistakes
MistakeStarting a legacy modernization by asking for a full rewrite.
InsteadUnderstand, pin behaviour, plan and change in small steps.
MistakeMixing bug fixes, refactors and features in one pull request.
InsteadKeep each kind of change in its own step.
MistakeAccepting an agent's extra changes because the tests pass.
InsteadHold agents to the boundaries in the task.
MistakeWriting error messages that echo raw rows containing personal data.
InsteadReport the row number and field name only.
MistakeClaiming a payoff without measuring before and after.
InsteadRecord baseline figures and compare them honestly.
Summary and key terms
- Begin with explanation and verify every claim against the code and data.
- Characterization tests pin current behaviour, including bugs, before changes.
- Plan steps that separate refactors, bug fixes and features, each in its own pull request.
- Use chat for judgment-heavy steps, agent mode for bounded mechanical ones and the coding agent for fully specified issues.
- Review every step with the checklist; watch for personal data and unrequested changes.
- Document with checked docstrings and a plain changelog, and measure the result against the baseline.
- Legacy code
- Older code that is still in use but hard to understand or change.
- Modernization
- Improving legacy code's structure and idioms so it is easier to change, usually in steps.
- Data class
- A Python class that mainly holds data, declared with the dataclass decorator.
- Plan mode
- A chat mode in some editors that proposes steps for approval before changes are made.
- Payback period
- The time it takes for savings to repay the effort of a change.
Practice problems
Work each problem on paper first, then reveal the answer to check.
Why were the comma fix and the error-reporting fix done as separate steps from the refactor?
Show answer
They change behaviour; keeping them separate makes their effect visible and keeps the refactor checkable by unchanged tests.
When is it right to change an expected value in a characterization test?
Show answer
When a deliberate, planned behaviour change makes the old value wrong, with a note in the same pull request explaining why.
Why was the new feature a good task for the coding agent?
Show answer
It could be fully specified in an issue with a definition of done, boundaries and tests.
Lesson quiz · 5 questions
Score 60% or more to complete this lesson.
Create a free account to save your progress, take quizzes and earn a certificate.