Lesson 5 - Guided Project: Planning Ledgerly
Welcome to the Guided Project
You are joining Ledgerly, the three-person invoicing app you have followed through this module, as a new engineer. Lesson 3 built the day-after reminder, and Lesson 4 explained why the team runs two-week Scrum sprints for work like it. Now a real feature request lands in the backlog, and it is yours to take from a rough idea to running code, using everything the module has taught so far.
This project has four stages. First, you write user stories and acceptance criteria, the same way a product owner and an engineer would sketch a new feature together. Second, you choose a development methodology for the work and justify it against Lesson 4’s criteria, rather than assuming Scrum by default. Third, you trace the feature through all six SDLC phases from Lesson 3, end to end. Fourth, you build a first-pass code skeleton that respects core principles and SOLID, so it stays easy to extend and to test. By the end, you will have planned and built one small, real piece of Ledgerly.
By the end of this project, you will be able to:
- Write a short set of user stories and concrete acceptance criteria for a real feature
- Choose a development methodology for a piece of work and justify it using requirement stability, how the work arrives, and documentation needs
- Trace one feature through all six phases of the SDLC, from requirements to maintenance
- Design and implement a code skeleton that depends on abstractions rather than concrete classes
- Explain why dependency inversion makes a feature easier to test
Stage 1: Write the Requirements
Ledgerly’s day-after reminder, from Lesson 3, has a gap. Some customers see that first email and still do not pay. A beta customer asks for something stronger: a second, more urgent notice once an invoice has been unpaid for a full week. That request is a wish, not yet a requirement, so the first job is to turn it into something precise enough that two engineers would build the same thing.
A user story is a short sentence that states who wants something, what they want, and why, in the form “As a <role>, I want <goal>, so that <benefit>.” Writing the goal from a specific person’s point of view keeps the team focused on real value instead of a vague feature name. Here are four user stories for the escalation notice, each written from a different person’s point of view:
- As a freelancer using Ledgerly, I want the app to send a second, more urgent notice once an invoice is a week overdue, so that I do not have to remember to chase every late payment myself.
- As a customer who owes money, I want the escalation email to state exactly how many days overdue the invoice is and the amount due, so that I can pay quickly without confusion.
- As a Ledgerly engineer, I want the escalation feature to reuse the existing invoice-lookup logic from the day-after reminder, so that the two features do not duplicate the same database query.
- As a Ledgerly engineer, I want the notification channel to be swappable, email today, something else later, so that adding a new channel does not require rewriting the feature.
Turning those stories into acceptance criteria, precise statements of what the software must do, gives the team something a test can check directly:
- An invoice becomes eligible for an escalation notice once it has been unpaid for 7 or more days past its due date.
- Each eligible invoice gets exactly one escalation notice; unlike the day-after reminder, it does not repeat daily.
- The escalation notice states the exact number of days overdue and the amount due.
- The logic that decides who gets escalated must not depend on which channel delivers the notice.
Notice that last bullet again: it is a requirement about the shape of the solution, not just its behavior. It exists because the team already knows, from experience with the day-after reminder, that hardcoding “send an email” into the decision logic makes adding a second channel painful later. Stage 4 turns that requirement into a concrete design decision.
Stage 2: Choose and Justify a Methodology
Lesson 4 gave three questions for choosing a development methodology: how stable are the requirements, does the work arrive in a predictable batch or unpredictably, and how much formal documentation does it need? Running the escalation feature through those questions, rather than assuming Scrum by default, is what makes the choice a decision instead of a habit.
The requirements from Stage 1 are reasonably stable. The 7-day threshold might move a little after real usage, but the shape of the feature, one escalation notice per overdue invoice, is settled. The work also arrives as a planned product feature, not an unpredictable support ticket, so it fits neatly into a two-week planning horizon rather than needing Kanban’s continuous flow. And it needs no external audit or regulatory sign-off, so Waterfall’s heavy, phase-gated documentation would slow the team down for no real benefit.
All three answers point the same way: Scrum, in the same two-week sprints the team already runs for the rest of the product. The team estimates the work at about 5 story points, well inside their measured velocity of roughly 21.6 points per sprint from Lesson 4, so it comfortably fits into the upcoming sprint alongside other backlog items. It goes through Sprint Planning as a normal backlog item, gets built and tested during the sprint, and gets demonstrated to the same two beta customers at Sprint Review.
Definition of Done
Before a Scrum team calls any backlog item finished, it checks the item against a shared Definition of Done: a short, agreed checklist like “code reviewed, automated tests passing, deployed behind a feature flag.” For the escalation feature, the team’s Definition of Done means Stage 4’s code must have a passing test before anyone calls the story complete, not just before it merges.
Stage 3: Map the Feature Onto the SDLC
Choosing Scrum decides how the team organizes its time. It does not replace the six SDLC phases from Lesson 3, it just fits all of them inside a fixed two-week box. Here is the escalation feature traced through each phase.
Requirements is Stage 1 above: the user stories and acceptance criteria, written before any design or code exists. Design decides the structure that satisfies them: a new OverdueInvoiceNotifier class will ask an InvoiceRepository for invoices at least 7 days overdue, then hand each one to a NotificationService, exactly mirroring the day-after reminder’s shape but with its own threshold and its own one-notice-only rule. The team also decides, at this phase, that NotificationService will be an explicit abstraction rather than a concrete email class, satisfying the swappable-channel requirement before a line of code exists.
Implementation is Stage 4 below: the real Python classes that carry out the design. Testing checks that the code actually satisfies Stage 1’s acceptance criteria, using a fake notification service so no real email goes out during a test run. Deployment ships the feature behind a feature flag, the same staged rollout Lesson 3 used, so only the two beta customers see it before the whole flag opens up. Maintenance continues after the sprint closes: the team watches whether the 7-day threshold feels right in practice, and whether any beta customer says the two-email sequence, day-after and 7-day, feels excessive.
Stage 4: Build a SOLID-Respecting Code Skeleton
Design decided that OverdueInvoiceNotifier should depend on abstractions, not concrete classes. That decision is the Dependency Inversion Principle from Lesson 2: high-level logic, deciding who gets escalated and when, should not depend on low-level detail, like how a notice actually gets delivered or where invoices are actually stored. Python’s abc module lets you write an abstraction as a class that cannot be instantiated directly, only extended.
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import date
@dataclass
class Customer:
name: str
email: str
@dataclass
class Invoice:
id: str
customer: Customer
amount_due: float
due_date: date
paid: bool
class NotificationService(ABC):
"""An abstract way to reach a customer. Concrete channels plug in here."""
@abstractmethod
def send_escalation_notice(self, invoice: Invoice, days_overdue: int) -> None:
"""Send one customer an urgent notice about one overdue invoice."""
raise NotImplementedError
class InvoiceRepository(ABC):
"""An abstract source of invoices. Concrete storage plugs in here."""
@abstractmethod
def find_overdue_by(self, min_days_overdue: int, today: date) -> list[Invoice]:
"""Return unpaid invoices at least `min_days_overdue` days past due."""
raise NotImplementedError
try:
NotificationService()
except TypeError as e:
print(f"TypeError: {e}")TypeError: Can't instantiate abstract class NotificationService without an implementation for abstract method 'send_escalation_notice'That error is not a bug, it is the abstraction doing its job. NotificationService and InvoiceRepository describe a contract, not a working implementation, so Python refuses to create one directly until a concrete class fills in every abstract method. Here is the complete skeleton: the same abstractions, plus the concrete classes that fulfill them, plus the notifier itself, which only ever talks to the abstractions above it, never to a concrete class directly:
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import date
@dataclass
class Customer:
name: str
email: str
@dataclass
class Invoice:
id: str
customer: Customer
amount_due: float
due_date: date
paid: bool
class NotificationService(ABC):
"""An abstract way to reach a customer. Concrete channels plug in here."""
@abstractmethod
def send_escalation_notice(self, invoice: Invoice, days_overdue: int) -> None:
"""Send one customer an urgent notice about one overdue invoice."""
raise NotImplementedError
class InvoiceRepository(ABC):
"""An abstract source of invoices. Concrete storage plugs in here."""
@abstractmethod
def find_overdue_by(self, min_days_overdue: int, today: date) -> list[Invoice]:
"""Return unpaid invoices at least `min_days_overdue` days past due."""
raise NotImplementedError
class EmailNotificationService(NotificationService):
"""Sends escalation notices by email. Stands in for a real email provider."""
def __init__(self) -> None:
self.sent_log: list[str] = []
def send_escalation_notice(self, invoice: Invoice, days_overdue: int) -> None:
message = (
f"To: {invoice.customer.email} | Subject: {invoice.id} is "
f"{days_overdue} days overdue | Amount due: ${invoice.amount_due:.2f}"
)
self.sent_log.append(message)
print(message)
class InMemoryInvoiceRepository(InvoiceRepository):
"""Holds invoices in a plain list. Stands in for the real database."""
def __init__(self, invoices: list[Invoice]) -> None:
self._invoices = invoices
def find_overdue_by(self, min_days_overdue: int, today: date) -> list[Invoice]:
overdue = []
for invoice in self._invoices:
days_overdue = (today - invoice.due_date).days
if not invoice.paid and days_overdue >= min_days_overdue:
overdue.append(invoice)
return overdue
class OverdueInvoiceNotifier:
"""Finds severely overdue invoices and escalates each one, once.
This class only knows about NotificationService and InvoiceRepository,
never a concrete email sender or database. Swapping EmailNotificationService
for an SmsNotificationService later means writing one new class; this one
does not change at all.
"""
def __init__(
self,
invoice_repository: InvoiceRepository,
notification_service: NotificationService,
escalation_threshold_days: int = 7,
) -> None:
self._invoice_repository = invoice_repository
self._notification_service = notification_service
self._escalation_threshold_days = escalation_threshold_days
def send_escalation_notices(self, today: date) -> int:
"""Send an escalation notice for every invoice past the threshold.
Returns how many notices were sent.
"""
overdue_invoices = self._invoice_repository.find_overdue_by(
self._escalation_threshold_days, today
)
for invoice in overdue_invoices:
days_overdue = (today - invoice.due_date).days
self._notification_service.send_escalation_notice(invoice, days_overdue)
return len(overdue_invoices)
priya = Customer(name="Priya Shah", email="[email protected]")
marcus = Customer(name="Marcus Webb", email="[email protected]")
invoices = [
Invoice("INV-1001", priya, 450.00, date(2026, 6, 20), paid=False), # 15 days overdue
Invoice("INV-1004", marcus, 120.00, date(2026, 6, 30), paid=False), # 5 days overdue
Invoice("INV-1003", priya, 300.00, date(2026, 6, 1), paid=True), # already paid
]
repository = InMemoryInvoiceRepository(invoices)
email_service = EmailNotificationService()
notifier = OverdueInvoiceNotifier(repository, email_service, escalation_threshold_days=7)
today = date(2026, 7, 5)
sent_count = notifier.send_escalation_notices(today)
print(f"Escalation notices sent: {sent_count}")Running this against three sample invoices sends exactly one notice, which matches the acceptance criteria from Stage 1:
To: [email protected] | Subject: INV-1001 is 15 days overdue | Amount due: $450.00
Escalation notices sent: 1INV-1004 is skipped because it is only 5 days overdue, below the 7-day threshold, and INV-1003 is skipped because it is already paid. Notice what OverdueInvoiceNotifier never mentions anywhere in its own code: the word “email.” It only calls self._notification_service.send_escalation_notice(...), on whatever object was passed in.
That gap between the notifier and any concrete channel is exactly what makes the Stage 1 testing requirement realistic. A test double is a stand-in object used only in tests, built to look like the real dependency without doing what it does. Here, a fake notification service records what it was asked to send instead of sending anything real, so the test below checks the escalation logic with no real email involved:
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import date
@dataclass
class Customer:
name: str
email: str
@dataclass
class Invoice:
id: str
customer: Customer
amount_due: float
due_date: date
paid: bool
class NotificationService(ABC):
"""An abstract way to reach a customer. Concrete channels plug in here."""
@abstractmethod
def send_escalation_notice(self, invoice: Invoice, days_overdue: int) -> None:
"""Send one customer an urgent notice about one overdue invoice."""
raise NotImplementedError
class InvoiceRepository(ABC):
"""An abstract source of invoices. Concrete storage plugs in here."""
@abstractmethod
def find_overdue_by(self, min_days_overdue: int, today: date) -> list[Invoice]:
"""Return unpaid invoices at least `min_days_overdue` days past due."""
raise NotImplementedError
class InMemoryInvoiceRepository(InvoiceRepository):
"""Holds invoices in a plain list. Stands in for the real database."""
def __init__(self, invoices: list[Invoice]) -> None:
self._invoices = invoices
def find_overdue_by(self, min_days_overdue: int, today: date) -> list[Invoice]:
overdue = []
for invoice in self._invoices:
days_overdue = (today - invoice.due_date).days
if not invoice.paid and days_overdue >= min_days_overdue:
overdue.append(invoice)
return overdue
class OverdueInvoiceNotifier:
"""Finds severely overdue invoices and escalates each one, once."""
def __init__(
self,
invoice_repository: InvoiceRepository,
notification_service: NotificationService,
escalation_threshold_days: int = 7,
) -> None:
self._invoice_repository = invoice_repository
self._notification_service = notification_service
self._escalation_threshold_days = escalation_threshold_days
def send_escalation_notices(self, today: date) -> int:
"""Send an escalation notice for every invoice past the threshold."""
overdue_invoices = self._invoice_repository.find_overdue_by(
self._escalation_threshold_days, today
)
for invoice in overdue_invoices:
days_overdue = (today - invoice.due_date).days
self._notification_service.send_escalation_notice(invoice, days_overdue)
return len(overdue_invoices)
class FakeNotificationService(NotificationService):
"""A test double: records calls instead of sending anything real."""
def __init__(self) -> None:
self.notified_invoice_ids: list[tuple[str, int]] = []
def send_escalation_notice(self, invoice: Invoice, days_overdue: int) -> None:
self.notified_invoice_ids.append((invoice.id, days_overdue))
def test_only_invoices_at_or_past_threshold_are_escalated():
test_customer = Customer(name="Test Customer", email="[email protected]")
today = date(2026, 7, 5)
seven_days_overdue = Invoice("A", test_customer, 100.0, date(2026, 6, 28), paid=False)
five_days_overdue = Invoice("B", test_customer, 100.0, date(2026, 6, 30), paid=False)
paid_but_old = Invoice("C", test_customer, 100.0, date(2026, 6, 1), paid=True)
repository = InMemoryInvoiceRepository([seven_days_overdue, five_days_overdue, paid_but_old])
fake_notifications = FakeNotificationService()
notifier = OverdueInvoiceNotifier(repository, fake_notifications, escalation_threshold_days=7)
sent_count = notifier.send_escalation_notices(today)
assert sent_count == 1, "Only the invoice at least 7 days overdue should be escalated"
assert fake_notifications.notified_invoice_ids == [("A", 7)]
print("All tests passed.")
test_only_invoices_at_or_past_threshold_are_escalated()All tests passed.This test never imports EmailNotificationService and never touches a real inbox, yet it fully checks the decision logic Stage 1 cared about most: which invoices qualify, and how many notices go out. That is the payoff of dependency inversion: because OverdueInvoiceNotifier depends on an abstraction, any object that satisfies that abstraction, real or fake, can stand in for it.
Practice Exercises
Exercise 1: Write requirements for a snooze feature
A Ledgerly customer asks to pause reminders on a single invoice for three days, because they are waiting on their own client to pay them first. Write two user stories, one from the freelancer’s point of view and one from an engineer’s, plus two concrete acceptance criteria, in the style used in Stage 1.
Hint
A reasonable freelancer story: “As a freelancer, I want to snooze reminders on one invoice for a few days, so that I do not chase a customer I already know is waiting on their own payment.” A reasonable acceptance criterion: “A snoozed invoice sends no reminder or escalation notice until the snooze period ends, even if it crosses the 7-day escalation threshold during that time.” Notice the criterion names an edge case, what happens if the snooze and the escalation threshold overlap, the same way Stage 1’s bullets did.
Exercise 2: Choose a methodology for a contract-bound feature
A compliance customer now requires that the exact wording of every escalation email be pre-approved in a signed contract and never changed without a new sign-off. Run this requirement through Lesson 4’s three questions and decide which methodology fits best.
Hint
Waterfall fits best here, the same reasoning Lesson 4 used for a regulatory audit-trail feature. The wording is fixed by a signed contract, so requirements will not change mid-project; the work needs a formal, documented sign-off before anything ships; and getting it wrong after release means a costly re-approval. That is a different answer from Stage 2’s Scrum choice, which is exactly the point: the methodology follows the shape of the work, not a company-wide default.
Exercise 3: Add a second notification channel
Sketch a new SmsNotificationService class that implements NotificationService from Stage 4, sending the same escalation details over text message instead of email. Describe what, if anything, would need to change inside OverdueInvoiceNotifier to support it.
Hint
SmsNotificationService needs the same one method, send_escalation_notice(self, invoice, days_overdue), implemented to format a short text message instead of an email string. Nothing inside OverdueInvoiceNotifier needs to change; you would only pass a different object into its constructor. That is the Open/Closed Principle in action: the system is open to a new channel but closed to modification of the class that already works.
Summary
This guided project took one real Ledgerly feature, a 7-day overdue escalation notice, from a customer’s wish to tested code. Stage 1 turned that wish into user stories and concrete acceptance criteria. Stage 2 ran the work through Lesson 4’s three questions and chose Scrum, justified by stable requirements, planned arrival, and low documentation needs. Stage 3 traced the feature through all six SDLC phases from Lesson 3, showing that a sprint organizes the team’s time without replacing any phase. Stage 4 built OverdueInvoiceNotifier around two abstractions, NotificationService and InvoiceRepository, applying the Dependency Inversion Principle, and a real test proved that this design lets the escalation logic be checked with a fake notification service instead of a real inbox.
Key Concepts
- User story and acceptance criteria — “As a
<role>, I want<goal>, so that<benefit>,” followed by precise, checkable statements of required behavior. - Methodology justification — choosing Scrum, Kanban, or Waterfall by checking requirement stability, how work arrives, and documentation needs, not by default.
- Definition of Done — a shared checklist, like passing tests and a feature flag, that a team agrees defines “finished.”
- SDLC mapping — requirements, design, implementation, testing, deployment, and maintenance, all still present inside one Scrum sprint.
- Dependency Inversion Principle — high-level logic depends on an abstraction (
NotificationService,InvoiceRepository), not a concrete class. - Test double — a fake object, like
FakeNotificationService, that stands in for a real dependency during a test.
Why This Matters
Every real feature you build, at Ledgerly or anywhere else, starts as a rough request and ends as running code that a team can trust. The habits in this project, writing requirements before designing, choosing a methodology on purpose, tracing every phase, and designing around abstractions, are what keep that path from turning into guesswork. They also compound: a feature built on dependency inversion is not just easier to test today, it is easier to extend next quarter, when Ledgerly’s team wants to add SMS escalations without touching the class you just wrote. Module 2 picks up exactly there, with object-oriented design and the patterns that make abstractions like NotificationService easy to build well.
Next Steps
Module 2: Design, Architecture & OOP
Start the next module: software design, object-oriented programming, and design patterns.
Back to Course Overview
Review the full Software Engineering Fundamentals course.
Continue Building Your Skills
You have now planned and built a real Ledgerly feature from a customer’s wish to a tested, dependency-inverted code skeleton, closing out Module 1. The habits behind that work, sharp requirements, a deliberate methodology, a full SDLC trace, and code built around abstractions, are the foundation the rest of the course builds on. Next, in Module 2, you will go deeper into object-oriented design itself: the patterns that make an abstraction like NotificationService easy to design well in the first place.