Repository#

Repository define operation to retrieve and store data from a storage backend.

While creating the application, we create definition of those methods in an abstract class. This let the possibility to improve the testability of the application.

Lets define an operation to add a book in the service/repositories.py module.

import abc
import enum
from types import EllipsisType

from reading_club.domain.model import Book
from result import Result

from jeepito import AsyncAbstractRepository


class BookRepositoryError(enum.Enum):
    integrity_error = "integrity_error"
    not_found = "not_found"


BookRepositoryResult = Result[Book, BookRepositoryError]
BookRepositoryOperationResult = Result[EllipsisType, BookRepositoryError]


class AbstractBookRepository(AsyncAbstractRepository[Book]):
    @abc.abstractmethod
    async def add(self, model: Book) -> BookRepositoryOperationResult:
        ...

    @abc.abstractmethod
    async def by_id(self, id: str) -> BookRepositoryResult:
        ...

Dealing with errors#

First, we create types that describe results from the repository. This is a personal choice to avoid exceptions here to deal with error, the jeepito is not enforcing this kind of practice.

The BookRepositoryError describe all kind of errors the repository can encountered, for instance, the add method can raises integrity error in case of duplicate things, and, the get method can raises a not found error. Instead of raising exceptions, the repository wrap the result in an object that must be unwrap to get the response or the error.

The AbstractBookRepository inherits AsyncAbstractRepository and note that it manage Book models. It define two operations on book, an operation to add a book in the repository, and one operation to retrieve a book from the repository.

Note

At this point, we only create definitions, this is why, there is still no tests written.

Implementing a repository#

The first implementation of our repository is always the simplest one, we can create on in a tests/conftest.py to get a better view of what it looks like.

from reading_club.domain.model import Book
from reading_club.service.repositories import (
    AbstractBookRepository,
    BookRepositoryError,
    BookRepositoryOperationResult,
    BookRepositoryResult,
)
from result import Err, Ok


class InMemoryBookRepository(AbstractBookRepository):
    books = {}
    ix_books_isbn = {}

    async def add(self, model: Book) -> BookRepositoryOperationResult:
        if model.id in self.books:
            return Err(BookRepositoryError.integrity_error)
        if model.isbn in self.ix_books_isbn:
            return Err(BookRepositoryError.integrity_error)
        self.books[model.id] = model
        self.books[model.isbn] = model.id
        return Ok(...)

    async def by_id(self, id: str) -> BookRepositoryResult:
        if id not in self.books:
            return Err(BookRepositoryError.not_found)
        return Ok(self.books[id])

If you plan to store the models in a SQL database, the cool things is that, it can be done later. We can crunch the model, and a complete prototype of the app, without any sql migration noise and stay focus on the model itself.

This pattern comes from the Hexagonal Architecture, the storage backend is an implementation detail.