Skip to content

The Repository Pattern

Defining objects using data schemas is very useful to do field validations and be consistent along the code. Next step is to define a place where to store collections of objects, and this is done in repositories.

Repository Pattern

The basics for the Repository Pattern are:

  • Define an interface for the repository.
  • And then you could have as many implementations as you like

The Contract

With this pattern, the important part is not how we implement the repository. The important thing is that all repositories and the implementations respect the defined contract (interface) and all use the same methods. Then we can implement controllers that no longer depend on a specific repository implementation.

Now all we have to do is set up a dependency injection framework to use the desired repository implementation. The dependency injection framework manages the instances of the repository classes and "injects" them into the controller constructor. And simply by modifying these settings we could change our data access technology without touching a single line of code in your controller.

Complete Isolation.

And this is where unit testing comes in. Using this pattern allows us to isolate the different layers. Since the controller code is loosely coupled to the repository, all we have to do in unit tests is provide a mock implementation in the repository that allows you to define its behavior. This gives us the ability to unit test controller actions without relying on a database or whatever.

Advantages

Using the Repository Pattern has many advantages:

  • Your business logic can be unit tested without data access logic.
  • The database access code can be reused and is managed centrally, making it easy to implement any database access policy.
  • It is easy to implement the domain logic.

Example

Let's see it with a simple example:

First of all, let's create a new test file:

touch tests/test_02_repositories.py 

Create the Abstract Class

tests/test_02_repositories.py

@@ -0,0 +1,22 @@

+from abc import ABC, abstractmethod
+from typing import List
+from uuid import UUID
+
+
+class Book(object):
+    pass
+
+
+class BookRepository(ABC):
+
+    @abstractmethod
+    def add(self, book: Book) -> Book:
+        raise NotImplementedError
+
+    @abstractmethod
+    def get(self, book_id: UUID) -> Book:
+        raise NotImplementedError
+
+    @abstractmethod
+    def list(self) -> List[Book]:
+        raise NotImplementedError

For this example, we'll create a very simple repository, that stores all the information in an in-memory dictionary.

Write a Basic Test and Define Empty In-Memory Repository

The first step is to create the initial test and the initial repository implementation, defining all methods:

tests/test_02_repositories.py

@@ -13,10 +13,26 @@

     def add(self, book: Book) -> Book:
         raise NotImplementedError

     @abstractmethod
     def get(self, book_id: UUID) -> Book:
         raise NotImplementedError

     @abstractmethod
     def list(self) -> List[Book]:
         raise NotImplementedError
+
+
+class InMemoryBookRepository(BookRepository):
+    def add(self, new_book: Book) -> Book:
+        pass
+
+    def get(self, book_id: UUID) -> Book:
+        pass
+
+    def list(self) -> List[Book]:
+        pass
+
+
+def test_in_memory_repository_created_successfully():
+    repo = InMemoryBookRepository()
+    assert type(repo) == InMemoryBookRepository

Then, we add the book dictionary variable to store all books:

tests/test_02_repositories.py

@@ -16,23 +16,31 @@

     @abstractmethod
     def get(self, book_id: UUID) -> Book:
         raise NotImplementedError

     @abstractmethod
     def list(self) -> List[Book]:
         raise NotImplementedError


 class InMemoryBookRepository(BookRepository):
+    books = dict()  # this sets a class-level attribute, common to all instances of `InMemoryBooksRepository`
+
     def add(self, new_book: Book) -> Book:
         pass

     def get(self, book_id: UUID) -> Book:
         pass

     def list(self) -> List[Book]:
-        pass
+        return [book for book_id, book in self.books.items()]


 def test_in_memory_repository_created_successfully():
     repo = InMemoryBookRepository()
     assert type(repo) == InMemoryBookRepository
+
+
+def test_in_memory_repository_initially_empty_successfully():
+    repo = InMemoryBookRepository()
+    items = repo.list()
+    assert len(items) == 0

Implement all Methods from the Contract

tests/test_02_repositories.py

@@ -1,46 +1,71 @@

 from abc import ABC, abstractmethod
-from typing import List
-from uuid import UUID
+from typing import List, Optional
+from uuid import UUID, uuid4
+
+import pytest
+from pydantic import BaseModel, Field


-class Book(object):
-    pass
+class Book(BaseModel):
+    title: str
+    author: str
+
+
+class BookInDB(Book):
+    id: UUID = Field(default_factory=uuid4)


 class BookRepository(ABC):

     @abstractmethod
-    def add(self, book: Book) -> Book:
+    def add(self, book: Book) -> BookInDB:
         raise NotImplementedError

     @abstractmethod
-    def get(self, book_id: UUID) -> Book:
+    def get(self, book_id: UUID) -> BookInDB:
         raise NotImplementedError

     @abstractmethod
-    def list(self) -> List[Book]:
+    def list(self) -> List[BookInDB]:
         raise NotImplementedError


 class InMemoryBookRepository(BookRepository):
     books = dict()  # this sets a class-level attribute, common to all instances of `InMemoryBooksRepository`

-    def add(self, new_book: Book) -> Book:
-        pass
+    def add(self, new_book: Book) -> BookInDB:
+        book = BookInDB(**new_book.dict())
+        self.books[book.id] = book
+        return book

-    def get(self, book_id: UUID) -> Book:
-        pass
+    def get(self, book_id: UUID) -> Optional[BookInDB]:
+        return self.books.get(book_id, None)

-    def list(self) -> List[Book]:
+    def list(self) -> List[BookInDB]:
         return [book for book_id, book in self.books.items()]


 def test_in_memory_repository_created_successfully():
     repo = InMemoryBookRepository()
     assert type(repo) == InMemoryBookRepository


 def test_in_memory_repository_initially_empty_successfully():
     repo = InMemoryBookRepository()
     items = repo.list()
     assert len(items) == 0
+
+
+@pytest.fixture()
+def a_book() -> Book:
+    return Book(title="A nice title", author="John Smith")
+
+
+def test_add_valid_data_to_in_memory_repository_successfully(a_book):
+    repo = InMemoryBookRepository()
+    repo.add(a_book)
+    items = repo.list()
+    assert len(items) == 1
+    assert isinstance(items[0], Book)
+    assert items[0].title == a_book.title
+    assert items[0].author == a_book.author

Check that Added Books are Valid

We need to be sure that the implementation is as robust as possible. To do it, we write a test forcing to add a data structure different than a Book, and we see that fails. Then we need to add some extra validation to the add method, just to check that the new_book is a Book type instance.

tests/test_02_repositories.py

@@ -27,20 +27,22 @@


     @abstractmethod
     def list(self) -> List[BookInDB]:
         raise NotImplementedError


 class InMemoryBookRepository(BookRepository):
     books = dict()  # this sets a class-level attribute, common to all instances of `InMemoryBooksRepository`

     def add(self, new_book: Book) -> BookInDB:
+        if not isinstance(new_book, Book):
+            raise ValueError("This repository only accepts Book objects.")
         book = BookInDB(**new_book.dict())
         self.books[book.id] = book
         return book

     def get(self, book_id: UUID) -> Optional[BookInDB]:
         return self.books.get(book_id, None)

     def list(self) -> List[BookInDB]:
         return [book for book_id, book in self.books.items()]

@@ -62,10 +64,20 @@



 def test_add_valid_data_to_in_memory_repository_successfully(a_book):
     repo = InMemoryBookRepository()
     repo.add(a_book)
     items = repo.list()
     assert len(items) == 1
     assert isinstance(items[0], Book)
     assert items[0].title == a_book.title
     assert items[0].author == a_book.author
+
+
+def test_add_invalid_data_to_n_memory_repository_raises_exception():
+    repo = InMemoryBookRepository()
+    a_dictionary = dict(title="Foo", author="Boo")
+    with pytest.raises(ValueError):
+        repo.add(a_dictionary)
+
+    items = repo.list()
+    assert len(items) == 0

At this point, we get a new error, because we are using a class-level variable to store the books. This means that all the of InMemoryBooksRepository shares the same variable. To solve this, we can do an optional initialization of the self.books variable in the __init__ method.

tests/test_02_repositories.py

@@ -1,83 +1,60 @@

-from abc import ABC, abstractmethod
-from typing import List, Optional
-from uuid import UUID, uuid4
+from uuid import UUID

 import pytest
-from pydantic import BaseModel, Field

-
-class Book(BaseModel):
-    title: str
-    author: str
-
-
-class BookInDB(Book):
-    id: UUID = Field(default_factory=uuid4)
-
-
-class BookRepository(ABC):
-
-    @abstractmethod
-    def add(self, book: Book) -> BookInDB:
-        raise NotImplementedError
-
-    @abstractmethod
-    def get(self, book_id: UUID) -> BookInDB:
-        raise NotImplementedError
-
-    @abstractmethod
-    def list(self) -> List[BookInDB]:
-        raise NotImplementedError
-
-
-class InMemoryBookRepository(BookRepository):
-    books = dict()  # this sets a class-level attribute, common to all instances of `InMemoryBooksRepository`
-
-    def add(self, new_book: Book) -> BookInDB:
-        if not isinstance(new_book, Book):
-            raise ValueError("This repository only accepts Book objects.")
-        book = BookInDB(**new_book.dict())
-        self.books[book.id] = book
-        return book
-
-    def get(self, book_id: UUID) -> Optional[BookInDB]:
-        return self.books.get(book_id, None)
-
-    def list(self) -> List[BookInDB]:
-        return [book for book_id, book in self.books.items()]
+from tests.repositories import InMemoryBookRepository
+from tests.schemas import Book


 def test_in_memory_repository_created_successfully():
     repo = InMemoryBookRepository()
     assert type(repo) == InMemoryBookRepository


 def test_in_memory_repository_initially_empty_successfully():
     repo = InMemoryBookRepository()
     items = repo.list()
     assert len(items) == 0


-@pytest.fixture()
-def a_book() -> Book:
-    return Book(title="A nice title", author="John Smith")
-
-
 def test_add_valid_data_to_in_memory_repository_successfully(a_book):
-    repo = InMemoryBookRepository()
+    repo = InMemoryBookRepository(initial_books=dict())
     repo.add(a_book)
     items = repo.list()
     assert len(items) == 1
     assert isinstance(items[0], Book)
     assert items[0].title == a_book.title
     assert items[0].author == a_book.author


 def test_add_invalid_data_to_n_memory_repository_raises_exception():
-    repo = InMemoryBookRepository()
+    repo = InMemoryBookRepository(initial_books=dict())
     a_dictionary = dict(title="Foo", author="Boo")
     with pytest.raises(ValueError):
         repo.add(a_dictionary)

     items = repo.list()
     assert len(items) == 0
+
+
+def test_get_book_by_id_from_in_memory_repository_successfully(a_book, another_book):
+    repo = InMemoryBookRepository(initial_books=dict())
+    book1 = repo.add(a_book)
+    book2 = repo.add(another_book)
+
+    response1 = repo.get(book_id=book1.id)
+    assert response1 == book1
+    assert response1.title == a_book.title
+
+    response2 = repo.get(book_id=book2.id)
+    assert response2 == book2
+    assert response2.title == another_book.title
+
+
+def test_get_book_by_id_from_in_memo_repository_with_invalid_id_returns_none(a_book):
+    repo = InMemoryBookRepository(initial_books=dict())
+    repo.add(a_book)
+
+    invalid_id = UUID('00000000-0000-0000-0000-000000000000')
+    response1 = repo.get(book_id=invalid_id)
+    assert response1 is None

Add some extra tests to check the get function

tests/test_02_repositories.py

@@ -1,60 +1,112 @@

-from uuid import UUID
+from abc import ABC, abstractmethod
+from typing import List, Optional
+from uuid import UUID, uuid4

 import pytest
+from pydantic import BaseModel, Field

-from tests.repositories import InMemoryBookRepository
-from tests.schemas import Book
+
+class Book(BaseModel):
+    title: str
+    author: str
+
+
+class BookInDB(Book):
+    id: UUID = Field(default_factory=uuid4)
+
+
+class BookRepository(ABC):
+
+    @abstractmethod
+    def add(self, book: Book) -> BookInDB:
+        raise NotImplementedError
+
+    @abstractmethod
+    def get(self, book_id: UUID) -> BookInDB:
+        raise NotImplementedError
+
+    @abstractmethod
+    def list(self) -> List[BookInDB]:
+        raise NotImplementedError
+
+
+class InMemoryBookRepository(BookRepository):
+    def __init__(self):
+        self.books = dict()
+
+    def add(self, new_book: Book) -> BookInDB:
+        if not isinstance(new_book, Book):
+            raise ValueError("This repository only accepts Book objects.")
+        book = BookInDB(**new_book.dict())
+        self.books[book.id] = book
+        return book
+
+    def get(self, book_id: UUID) -> Optional[BookInDB]:
+        return self.books.get(book_id, None)
+
+    def list(self) -> List[BookInDB]:
+        return [book for book_id, book in self.books.items()]


 def test_in_memory_repository_created_successfully():
     repo = InMemoryBookRepository()
     assert type(repo) == InMemoryBookRepository


 def test_in_memory_repository_initially_empty_successfully():
     repo = InMemoryBookRepository()
     items = repo.list()
     assert len(items) == 0


+@pytest.fixture()
+def a_book() -> Book:
+    return Book(title="A nice title", author="John Smith")
+
+
+@pytest.fixture()
+def another_book() -> Book:
+    return Book(title="Another nice title", author="Jane Boo")
+
+
 def test_add_valid_data_to_in_memory_repository_successfully(a_book):
-    repo = InMemoryBookRepository(initial_books=dict())
+    repo = InMemoryBookRepository()
     repo.add(a_book)
     items = repo.list()
     assert len(items) == 1
     assert isinstance(items[0], Book)
     assert items[0].title == a_book.title
     assert items[0].author == a_book.author


 def test_add_invalid_data_to_n_memory_repository_raises_exception():
-    repo = InMemoryBookRepository(initial_books=dict())
+    repo = InMemoryBookRepository()
     a_dictionary = dict(title="Foo", author="Boo")
     with pytest.raises(ValueError):
         repo.add(a_dictionary)

     items = repo.list()
     assert len(items) == 0


 def test_get_book_by_id_from_in_memory_repository_successfully(a_book, another_book):
-    repo = InMemoryBookRepository(initial_books=dict())
+    repo = InMemoryBookRepository()
     book1 = repo.add(a_book)
     book2 = repo.add(another_book)

     response1 = repo.get(book_id=book1.id)
     assert response1 == book1
     assert response1.title == a_book.title

     response2 = repo.get(book_id=book2.id)
     assert response2 == book2
     assert response2.title == another_book.title


 def test_get_book_by_id_from_in_memo_repository_with_invalid_id_returns_none(a_book):
-    repo = InMemoryBookRepository(initial_books=dict())
+    repo = InMemoryBookRepository()
     repo.add(a_book)

     invalid_id = UUID('00000000-0000-0000-0000-000000000000')
     response1 = repo.get(book_id=invalid_id)
     assert response1 is None

Improving Tests: Fixtures

For those tests, we have defined a fixture function to create new Book instances for test purposes. Given that we know that we'll need to do this again and again in future tests, what we can do is to move those fixtures from test_02_repositories.py to conftest.py. conftest.py file is loaded everytime we run a test.

tests/test_02_repositories.py

@@ -52,30 +52,20 @@

     repo = InMemoryBookRepository()
     assert type(repo) == InMemoryBookRepository


 def test_in_memory_repository_initially_empty_successfully():
     repo = InMemoryBookRepository()
     items = repo.list()
     assert len(items) == 0


-@pytest.fixture()
-def a_book() -> Book:
-    return Book(title="A nice title", author="John Smith")
-
-
-@pytest.fixture()
-def another_book() -> Book:
-    return Book(title="Another nice title", author="Jane Boo")
-
-
 def test_add_valid_data_to_in_memory_repository_successfully(a_book):
     repo = InMemoryBookRepository()
     repo.add(a_book)
     items = repo.list()
     assert len(items) == 1
     assert isinstance(items[0], Book)
     assert items[0].title == a_book.title
     assert items[0].author == a_book.author

tests/conftest.py

+import pytest
+
+from tests.test_02_repositories import Book
+
+
+@pytest.fixture()
+def a_book() -> Book:
+    return Book(title="A nice title", author="John Smith")
+
+
+@pytest.fixture()
+def another_book() -> Book:
+    return Book(title="Another nice title", author="Jane Boo")

Code Refactor

Data Schemas

And we also know that we'll use the Book data type in future tests, so we can refactor the data schemas from test_02_repositories.py to schemas.py, to clean the code and make more usable.

tests/test_02_repositories.py

@@ -1,25 +1,17 @@

 from abc import ABC, abstractmethod
 from typing import List, Optional
-from uuid import UUID, uuid4
+from uuid import UUID

 import pytest
-from pydantic import BaseModel, Field

-
-class Book(BaseModel):
-    title: str
-    author: str
-
-
-class BookInDB(Book):
-    id: UUID = Field(default_factory=uuid4)
+from tests.schemas import Book, BookInDB


 class BookRepository(ABC):

     @abstractmethod
     def add(self, book: Book) -> BookInDB:
         raise NotImplementedError

     @abstractmethod
     def get(self, book_id: UUID) -> BookInDB:

tests/schemas.py

+from uuid import UUID, uuid4
+
+from pydantic import BaseModel, Field
+
+
+class Book(BaseModel):
+    title: str
+    author: str
+
+
+class BookInDB(Book):
+    id: UUID = Field(default_factory=uuid4)

tests/conftest.py

@@ -1,13 +1,13 @@

 import pytest

-from tests.test_02_repositories import Book
+from tests.schemas import Book


 @pytest.fixture()
 def a_book() -> Book:
     return Book(title="A nice title", author="John Smith")


 @pytest.fixture()
 def another_book() -> Book:
     return Book(title="Another nice title", author="Jane Boo")

Repository

tests/test_02_repositories.py

@@ -1,94 +1,60 @@

-from abc import ABC, abstractmethod
-from typing import List, Optional
 from uuid import UUID

 import pytest

-from tests.schemas import Book, BookInDB
-
-
-class BookRepository(ABC):
-
-    @abstractmethod
-    def add(self, book: Book) -> BookInDB:
-        raise NotImplementedError
-
-    @abstractmethod
-    def get(self, book_id: UUID) -> BookInDB:
-        raise NotImplementedError
-
-    @abstractmethod
-    def list(self) -> List[BookInDB]:
-        raise NotImplementedError
-
-
-class InMemoryBookRepository(BookRepository):
-    def __init__(self):
-        self.books = dict()
-
-    def add(self, new_book: Book) -> BookInDB:
-        if not isinstance(new_book, Book):
-            raise ValueError("This repository only accepts Book objects.")
-        book = BookInDB(**new_book.dict())
-        self.books[book.id] = book
-        return book
-
-    def get(self, book_id: UUID) -> Optional[BookInDB]:
-        return self.books.get(book_id, None)
-
-    def list(self) -> List[BookInDB]:
-        return [book for book_id, book in self.books.items()]
+from tests.repositories import InMemoryBookRepository
+from tests.schemas import Book


 def test_in_memory_repository_created_successfully():
     repo = InMemoryBookRepository()
     assert type(repo) == InMemoryBookRepository


 def test_in_memory_repository_initially_empty_successfully():
     repo = InMemoryBookRepository()
     items = repo.list()
     assert len(items) == 0


 def test_add_valid_data_to_in_memory_repository_successfully(a_book):
-    repo = InMemoryBookRepository()
+    repo = InMemoryBookRepository(initial_books=dict())
     repo.add(a_book)
     items = repo.list()
     assert len(items) == 1
     assert isinstance(items[0], Book)
     assert items[0].title == a_book.title
     assert items[0].author == a_book.author


 def test_add_invalid_data_to_n_memory_repository_raises_exception():
-    repo = InMemoryBookRepository()
+    repo = InMemoryBookRepository(initial_books=dict())
     a_dictionary = dict(title="Foo", author="Boo")
     with pytest.raises(ValueError):
         repo.add(a_dictionary)

     items = repo.list()
     assert len(items) == 0


 def test_get_book_by_id_from_in_memory_repository_successfully(a_book, another_book):
-    repo = InMemoryBookRepository()
+    repo = InMemoryBookRepository(initial_books=dict())
     book1 = repo.add(a_book)
     book2 = repo.add(another_book)

     response1 = repo.get(book_id=book1.id)
     assert response1 == book1
     assert response1.title == a_book.title

     response2 = repo.get(book_id=book2.id)
     assert response2 == book2
     assert response2.title == another_book.title


 def test_get_book_by_id_from_in_memo_repository_with_invalid_id_returns_none(a_book):
-    repo = InMemoryBookRepository()
+    repo = InMemoryBookRepository(initial_books=dict())
     repo.add(a_book)

     invalid_id = UUID('00000000-0000-0000-0000-000000000000')
     response1 = repo.get(book_id=invalid_id)
     assert response1 is None

tests/repositories.py

+from abc import ABC, abstractmethod
+from typing import List, Optional
+from uuid import UUID
+
+from tests.schemas import Book, BookInDB
+
+
+class BookRepository(ABC):
+
+    @abstractmethod
+    def add(self, book: Book) -> BookInDB:
+        raise NotImplementedError
+
+    @abstractmethod
+    def get(self, book_id: UUID) -> BookInDB:
+        raise NotImplementedError
+
+    @abstractmethod
+    def list(self) -> List[BookInDB]:
+        raise NotImplementedError
+
+
+class InMemoryBookRepository(BookRepository):
+    books = dict()
+
+    def __init__(self, initial_books: dict = None):
+        if initial_books is not None:
+            self.books = initial_books
+
+    def add(self, new_book: Book) -> BookInDB:
+        if not isinstance(new_book, Book):
+            raise ValueError("This repository only accepts Book objects.")
+        book = BookInDB(**new_book.dict())
+        self.books[book.id] = book
+        return book
+
+    def get(self, book_id: UUID) -> Optional[BookInDB]:
+        return self.books.get(book_id, None)
+
+    def list(self) -> List[BookInDB]:
+        return [book for book_id, book in self.books.items()]