The repository as a domain boundary

The domain owns the contract

Geschrieben von Timo Rieber am 2. September 2025

The first repositories in cloudapps combined interface and implementation in one class. user_with_email wasn’t a contract - it was a filter expression backed by a concrete storage mechanism. A UserRepository defined what queries existed and how they ran, all in the same inheritance chain.

That worked until User.change_email needed to verify that no other account already held a given email address. The check is a repository lookup. But the repository lived in the persistence layer, and having an entity import from there would flip the dependency - domain depending on infrastructure.

So the interface moved. Not into a separate file in the same layer, but into the domain module itself, as a Protocol next to the entity it serves.

One direction

class UserRepository(Protocol):
    def user_with_email_exist(self, email: str) -> bool: ...
    def user_with_email(self, email: str) -> User: ...
    def save(self, user: User) -> None: ...
    def find(self, user_id: str) -> User: ...
Python

This lives in idm/domain/user.py, right below User. The method names are domain vocabulary - user_with_email is a concept the domain needs, not a query someone decided to expose. The concrete SQLiteUserRepository imports this Protocol from the domain and satisfies it. The domain doesn’t know the implementation exists.

With the Protocol available, User.change_email validates uniqueness without touching infrastructure:

def change_email(self, new_email: str, user_repository: UserRepository) -> None:
    normalized_email = new_email.lower().strip()
    if user_repository.user_with_email_exist(normalized_email):
        raise DuplicateEmailError(normalized_email)
    self.email = normalized_email
Python

The entity calls a repository it defined. Testing it means passing a stub - no database, no setup. Commands receive all their repositories through a context object typed against Protocols, wired by a factory they never see.

cloudapps has 25 repositories following this shape. The concrete implementations delegate to a shared base class that handles JSON serialization and optimistic locking. A smaller project handling email attachments reached the same structure independently with two hand-rolled repositories and inline serialization. The infrastructure is completely different. The boundary is identical: the domain defines the contract, the implementation satisfies it from the outside, and imports flow in one direction.