Lab 1 — OOP, Interfaces, and Dependency Injection
Time: ~3.5 hrs · Difficulty: Core · Builds on: Month 3 (functions, modules), Month 4 (the GitHub Pulse tool)
Objective
Learn the building blocks of engineered software by modeling a small domain — a tiny “notifications” system — three ways, each better than the last. You will start with classes and pure functions, then make a key collaboration swappable behind a Protocol, then wire it together with dependency injection so it is trivially testable. By the end you will feel why an interface plus injection is the move that the whole rest of the course depends on. These are small, focused exercises; the payoff is the instinct, not the line count.
Setup
brew install uv # if not already installed
uv python install 3.12 # if not already installed
mkdir -p ~/agentic/month-05 && cd ~/agentic/month-05
uv init oop-drills --package --python 3.12
cd oop-drills
uv add --dev mypy
You now have a src/oop_drills/ package. We will add a module per part. Keep a terminal open for uv run python (the REPL) and uv run mypy --strict src (the type checker) — run the checker after every part.
Background
Recall first (from memory): (1) In Month 4, what made your GitHub Pulse tool hard to change without breaking something? (2) What does a Python function return, and how is that different from print? Answer in your head before reading on — this lab is the fix for problem (1).
In Month 4 your tool was a flat pile of functions that each did several things. This lab introduces the units you will reorganize that pile into: classes (state plus behavior), pure functions (logic with no side effects), Protocols (interfaces — a named set of methods callers can depend on), and dependency injection (passing collaborators in rather than constructing them inside). Read Core Concepts §1–§7 in the README first; this lab is where those ideas become muscle memory.
Steps
1. A class with state, behavior, and dunders
Create src/oop_drills/inbox.py:
from dataclasses import dataclass, field
@dataclass
class Message:
sender: str
body: str
urgent: bool = False
class Inbox:
"""Holds messages for one user. Has identity and changing state, so it earns a class."""
def __init__(self, owner: str) -> None:
self.owner = owner
self._messages: list[Message] = []
def add(self, message: Message) -> None:
self._messages.append(message)
def unread_count(self) -> int:
return len(self._messages)
def __repr__(self) -> str:
return f"Inbox(owner={self.owner!r}, messages={self.unread_count()})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Inbox):
return NotImplemented
return self.owner == other.owner and self._messages == other._messages
Try it in the REPL (uv run python):
from oop_drills.inbox import Inbox, Message
box = Inbox("ada")
box.add(Message("github", "Your build passed"))
box # uses __repr__
box.unread_count()
Checkpoint: Evaluating box prints Inbox(owner='ada', messages=1) — your __repr__, not a <object at 0x...>. box.unread_count() returns 1. Note that Message got its __init__, __repr__, and __eq__ for free from @dataclass, while Inbox — which has real behavior — is a hand-written class. That is the dataclass-vs-class judgment call from §3.
If not: if you see <oop_drills.inbox.Inbox object at 0x...>, your __repr__ isn’t being found — check it’s indented as a method of Inbox and spelled with double underscores both sides. If import fails, you launched a bare python; quit and relaunch with uv run python from the project root (see Troubleshooting).
2. A pure function next to the impure shell
The decision “is this message worth a desktop alert?” is pure logic. The act of sending the alert is a side effect. Keep them apart. Add to inbox.py:
def should_alert(message: Message, quiet_hours: bool) -> bool:
"""Pure: depends only on its arguments, returns a bool, touches nothing else."""
if quiet_hours:
return message.urgent
return True
Checkpoint: In the REPL, should_alert(Message("x", "y", urgent=True), quiet_hours=True) returns True, and with urgent=False it returns False. You tested a piece of real logic with no setup, no mocking, no network — because it is pure. Hold onto that feeling; it is the whole argument of §2.
If not: if you get the wrong boolean, re-read the logic — during quiet hours only urgent messages alert; outside quiet hours everything does. If the REPL doesn’t see should_alert, you edited the file after importing; relaunch uv run python (Troubleshooting: REPL caches modules).
The next three steps teach the load-bearing new skill of this lab — an interface plus dependency injection — using gradual release: you study a complete worked example (Stage 1), fill in a faded skeleton (Stage 2), then extend it with no scaffolding (Stage 3). Here is the shape you are building toward:
flowchart TD
Service["AlertService"] -->|"depends on"| Iface["Notifier interface"]
Console["ConsoleNotifier"] -->|"satisfies"| Iface
Counting["CountingNotifier (test double)"] -->|"satisfies"| Iface
Prefix["PrefixNotifier"] -->|"satisfies"| Iface
Notice: AlertService points only at the interface; each notifier satisfies it structurally. Swapping notifiers never touches AlertService.
3. Define an interface with Protocol — Stage 1: Worked example (I do)
Read and run this; you are not inventing anything yet. Sending an alert could go to the terminal, to a log, to a desktop notification, to a fake during tests. They share a shape. Create src/oop_drills/notifier.py:
from typing import Protocol
from oop_drills.inbox import Message
class Notifier(Protocol):
def send(self, message: Message) -> None: ...
Now two real implementations and one test double — note that none of them inherit from Notifier:
class ConsoleNotifier:
def send(self, message: Message) -> None:
print(f"[ALERT] {message.sender}: {message.body}")
class CountingNotifier:
"""A test double that records calls instead of doing I/O."""
def __init__(self) -> None:
self.sent: list[Message] = []
def send(self, message: Message) -> None:
self.sent.append(message)
Checkpoint: Run uv run mypy --strict src. It passes, and it considers both ConsoleNotifier and CountingNotifier valid Notifiers purely because they have a matching send method — structural typing in action (§5). Try deleting the body of ConsoleNotifier.send and renaming it to dispatch; re-run mypy after Step 4 wires it in and watch it complain that the shape no longer matches.
If not: a mypy error here usually means a send signature doesn’t match the protocol exactly — check the parameter name (message), its type (Message), and the -> None return. If mypy isn’t found, run uv add --dev mypy (see Setup).
4. Dependency injection: pass the collaborator in — Stage 2: Faded practice (we do)
Here is the payoff, and now you fill in the gaps. Create src/oop_drills/alerts.py — a service that decides and sends, but does not build its own notifier. Type the skeleton and complete the two TODOs yourself before checking against the full version below:
from oop_drills.inbox import Inbox, Message, should_alert
from oop_drills.notifier import Notifier
class AlertService:
def __init__(self, notifier: Notifier) -> None:
self._notifier = notifier # injected, not constructed here
def process(self, inbox: Inbox, messages: list[Message], quiet_hours: bool) -> int:
sent = 0
for message in messages:
inbox.add(message)
# TODO 1: only alert when should_alert(...) says so, given quiet_hours
# TODO 2: when you do alert, call the INJECTED notifier and count it
return sent
Expected behavior: process adds every message to the inbox, but only calls self._notifier.send(message) for messages that pass should_alert, returning how many it sent. The full version:
from oop_drills.inbox import Inbox, Message, should_alert
from oop_drills.notifier import Notifier
class AlertService:
def __init__(self, notifier: Notifier) -> None:
self._notifier = notifier # injected, not constructed here
def process(self, inbox: Inbox, messages: list[Message], quiet_hours: bool) -> int:
sent = 0
for message in messages:
inbox.add(message)
if should_alert(message, quiet_hours):
self._notifier.send(message)
sent += 1
return sent
from oop_drills.inbox import Inbox, Message, should_alert
from oop_drills.notifier import Notifier
class AlertService:
def __init__(self, notifier: Notifier) -> None:
self._notifier = notifier # injected, not constructed here
def process(self, inbox: Inbox, messages: list[Message], quiet_hours: bool) -> int:
sent = 0
for message in messages:
inbox.add(message)
if should_alert(message, quiet_hours):
self._notifier.send(message)
sent += 1
return sent
AlertService depends on the Notifier interface, never on ConsoleNotifier. Wire the real one together in a tiny main, and the fake one in a check:
def main() -> None:
service = AlertService(ConsoleNotifier()) # production wiring
box = Inbox("ada")
service.process(box, [Message("ci", "build failed", urgent=True)], quiet_hours=True)
if __name__ == "__main__":
main()
Now exercise it with the fake, no I/O required, in the REPL:
from oop_drills.inbox import Inbox, Message
from oop_drills.notifier import CountingNotifier
from oop_drills.alerts import AlertService
spy = CountingNotifier()
service = AlertService(spy) # inject the fake
box = Inbox("ada")
sent = service.process(box, [
Message("ci", "build failed", urgent=True),
Message("news", "weekly digest", urgent=False),
], quiet_hours=True)
sent # 1 -- only the urgent one alerted during quiet hours
len(spy.sent) # 1
spy.sent[0].sender # 'ci'
Checkpoint: sent is 1 and spy.sent holds exactly the urgent message. You just verified AlertService’s entire behavior with zero mocking and zero side effects — because the collaborator was injected. Compare this in your head to how you’d test the same logic if AlertService.__init__ had hard-coded ConsoleNotifier(): you would have to capture stdout. Injection is what made it easy.
If not: if sent is 2, your if should_alert(...) guard is missing or always true — both messages got sent. If len(spy.sent) is 0, you called a freshly-built ConsoleNotifier() instead of the injected spy, or forgot to pass spy into AlertService(...).
5. Make the injection swappable end-to-end — Stage 3: Independent (you do)
No skeleton this time — only the goal. Prove the Open/Closed payoff in miniature by adding a third notifier without touching AlertService. Your PrefixNotifier should take a prefix string and an inner: Notifier, and on send forward a copy of the message (with the prefix prepended to sender) to inner. Write it yourself, then compare to this reference:
class PrefixNotifier:
def __init__(self, prefix: str, inner: Notifier) -> None:
self._prefix = prefix
self._inner = inner
def send(self, message: Message) -> None:
tagged = Message(f"{self._prefix}{message.sender}", message.body, message.urgent)
self._inner.send(tagged)
PrefixNotifier is a Notifier and has a Notifier — composition (§4) and a structural interface (§5) at once. Wrap one around another and inject the result:
service = AlertService(PrefixNotifier("prod/", ConsoleNotifier()))
Checkpoint: You added new behavior (prefixing) by writing a new class and changing only the wiring line, never AlertService itself. That is Open/Closed (§7) at the smallest possible scale. Run uv run mypy --strict src once more — still clean.
If not: if mypy rejects PrefixNotifier as a Notifier, its send signature drifted (it must be send(self, message: Message) -> None). If you found yourself editing AlertService to make the prefix work, stop — that is exactly the violation this step is teaching you to avoid; the new behavior belongs entirely in the new class.
Definition of Done
A self-verifiable checklist:
src/oop_drills/containsinbox.py,notifier.py, andalerts.py.Inboxhas a hand-written__repr__and__eq__;Messageis a@dataclass.should_alertis a pure function (no I/O, no globals, no mutation of arguments).Notifieris aProtocol;ConsoleNotifier,CountingNotifier, andPrefixNotifierall satisfy it without inheriting from it.AlertServicereceives its notifier via__init__and never constructs one internally.uv run mypy --strict srcreports zero errors.- Run
uv run python -c "from oop_drills.alerts import main; main()"and see one[ALERT]line for the urgent message.
Self-verify the type contract in one command:
uv run mypy --strict src
Self-explain: in one sentence, why can you add PrefixNotifier and swap it in without ever editing AlertService?
Stretch Goals
- An ABC version. Re-define
Notifieras anabc.ABCwith an@abstractmethod send. Make the notifiers inherit from it and try to instantiate one that’s missingsend— observe the runtimeTypeError. Write one sentence on when you’d prefer this over aProtocol. - Compose two collaborators. Give
AlertServicea second injected dependency — aClockprotocol with anow()method — and use it to setquiet_hoursbased on the hour. Inject aFakeClockto test “is it quiet hours?” deterministically. - A
FanOutNotifier. Write a notifier that holds alist[Notifier]and forwardssendto all of them. Prove you can alert the console and aCountingNotifierat once, still without changingAlertService. - Find the SRP violation. Add an
unread_summary()method toInboxthat formats a string for display. Then argue (in a comment) why formatting probably belongs in a separate function or class, not onInbox.
Troubleshooting
ModuleNotFoundError: No module named 'oop_drills'. Run code withuv run python ...from inside the project root, oruv run python -c "...". The--packagelayout puts your code insrc/, whichuvadds to the path; a barepythonwon’t know that.mypysaysCannot instantiate abstract class(stretch 1). That is the point of an ABC — you left an@abstractmethodunimplemented. Implementsendin the subclass.mypycomplains a notifier isn’t aNotifier. Itssendsignature doesn’t match the protocol exactly — check the parameter name, the type, and the-> Nonereturn. Structural typing is strict about the shape.__eq__returnsNotImplementedand comparisons behave oddly. Make sure youreturn NotImplemented(the singleton), notraiseit, whenotherisn’t anInbox. Python uses that signal to try the reflected comparison.- REPL doesn’t see your edits. The REPL caches imported modules. Quit and relaunch
uv run pythonafter editing a file, or useimportlib.reload.