I started the avatar code the way the books suggest: an interface first, a concrete implementation behind it.
class AvatarStorage(Protocol):
def process_profile_image(self, content: bytes) -> tuple[bytes, str]: ...
async def upload_profile_image(self, file_bytes: bytes, filename: str) -> None: ...
async def delete_profile_picture(self, filename: str | None) -> None: ...
Then S3 behind it. Local disk later, maybe. Swappable, testable, textbook.
I deleted it a day later.
What the interface was actually doing
There was one implementation. There was no second one planned. And the protocol
was not an abstraction over anything — it was a copy of AWSAvatars's method
signatures, kept in a second file, that would have to be edited every time the
class was.
An interface with a single implementation does not decouple anything. It describes that implementation twice.
The tell was that I could not answer a simple question: what would the second implementation look like? Local disk has no bucket, no content type, no presigned URLs. Its shape would differ enough that this protocol — written from S3's shape — would need rewriting anyway. The interface was not derived from what callers need. It was derived from what S3 happened to offer.
Where I kept one
The same project has a protocol I did not delete:
class ResetMailer(Protocol):
def __call__(self, *, to_email: str, username: str, token: str) -> None: ...
Four words and no answer. It exists for a reason that has nothing to do with a possible second implementation.
The password service has to arrange for an email. The code that actually sends one lives in the presentation layer — and services are not allowed to import presentation. Without an abstraction the dependency simply points the wrong way. The protocol inverts it: the service names a shape, and structural typing means the sender never has to import anything back.
That is a dependency direction problem, not a swappability problem. The interface earns its file.
The rule I ended up with
An interface is worth introducing when one of two things is true:
- there are already two implementations, and the interface is what they have in common — discovered, not predicted
- the dependency would otherwise point in a direction the architecture forbids
"Might need to swap it someday" is neither. It is a prediction about a future that usually does not arrive, paid for in a file that has to be maintained the whole time.
Both decisions are written into the docstrings, next to the code they explain — the protocol says why it exists, and the storage module says why it has none. Deleting an abstraction without a note looks like something nobody thought about, which is the opposite of what happened.