The Protocol I wrote and then deleted

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.

All posts