Abstraction and interfaces
Describing what an object does, not how, so callers depend on a promise.
01The intuition
The problem it solves
You are writing a program that sends a message to a student when their library book is due. The first version sends an email, so the reminder code calls the email class directly.
class EmailSender:
def send_email(self, address: str, subject: str, body: str) -> None:
print("email to", address)
class DueReminder:
def remind(self, student) -> None:
sender = EmailSender()
sender.send_email(student.email, "Book due", "Please return it")
A month later the school wants SMS as well, and some students want both. Now DueReminder needs an if-else on the student's preference, and it must know how to call an SMS class, which has different method names and different arguments. Every new channel means editing DueReminder, and testing it means really sending email, because the sender is built inside the method.
The deeper problem is that DueReminder depends on how messages are sent. It only needs to know that a message can be sent. Describing that promise on its own, separately from any one way of keeping it, is abstraction, and the written-down promise is an interface.
Build it one piece at a time
Step 1: write the promise as a class with no body. The reminder needs one ability: deliver some text to a student. Name it and give it one method.
from abc import ABC, abstractmethod
class Notifier(ABC):
@abstractmethod
def notify(self, student, text: str) -> None:
"""Deliver text to the student. Raise if it cannot be delivered."""
Step 2: make the existing email code keep that promise. The email class gains a notify method that translates the promise into its own vocabulary.
class EmailNotifier(Notifier):
def notify(self, student, text: str) -> None:
print("email to", student.email, ":", text)
class SmsNotifier(Notifier):
def notify(self, student, text: str) -> None:
print("sms to", student.phone, ":", text)
Step 3: let the reminder depend on the promise only. It receives a Notifier from outside and never says the word "email" again.
class DueReminder:
def __init__(self, notifier: Notifier):
self._notifier = notifier
def remind(self, student) -> None:
self._notifier.notify(student, "Your book is due")
Step 4: compose promises out of promises. "Both channels" is itself a notifier, so the reminder still has one dependency.
class MultiNotifier(Notifier):
def __init__(self, parts: list[Notifier]):
self._parts = parts
def notify(self, student, text: str) -> None:
for part in self._parts:
part.notify(student, text)
reminder = DueReminder(MultiNotifier([EmailNotifier(), SmsNotifier()]))
| Step | What DueReminder knows about | Adding SMS means | Testing needs |
|---|---|---|---|
| Start | EmailSender and its method names | editing DueReminder | a real email |
| 1 and 2 | still EmailSender | a new class only | a real email |
| 3 | the Notifier promise | a new class and one line of wiring | a fake Notifier |
| 4 | the Notifier promise | nothing in the reminder | a fake Notifier |
Why it works
An interface splits a program into two sides. The caller side knows the method names, the argument types and the meaning of the result, and nothing else. The implementer side can do anything it likes as long as it keeps that meaning. The invariant is the contract written in the docstring: "deliver text, or raise". As long as both sides respect the contract, either side can change without the other hearing about it.
This is also what makes tests possible. A FakeNotifier that records calls in a list satisfies the contract, so DueReminder can be tested in a millisecond with no network. In an interview, naming the fake is often the sentence that shows you understand why the interface is there.
Where it breaks: an interface that leaks its implementation. If Notifier.notify took an smtp_server argument, the SMS class would have to accept and ignore it, and the promise would be a lie. Also watch the fat interface: a Notifier with twelve methods, most of which only email needs, forces every implementation to fake the rest. Keep each promise small.
The trade-off you must say out loud
An interface is an extra file and an extra name to learn, and an interface with exactly one implementation that will never have a second is ceremony. The honest rule: introduce the abstraction when you have two implementations, or when the one you have must be swapped for a fake in tests. Expect the follow-up "which parts of your design are interfaces and why", and answer by pointing at the places that vary or that you need to fake.
Where it shows up in the 100 Google problems
- Design a boolean and arithmetic expression library: every node, from a constant to an AND, keeps one promise,
evaluate(variables), so the evaluator never asks what kind of node it holds. - Design a notification service: channels are implementations of one
Channelpromise; the service composes them and retries through the same method. - Design a logging framework: a sink is "something that accepts a formatted record"; console, file and network sinks differ only inside.
- Design a rate limiter class: the algorithm (token bucket, sliding window) sits behind
allow(client_id, now), so the limiter and the tests do not care which one is plugged in.