Hello! Let's continue our exploration of Domain-Driven Design.
Introduction
In our last lesson, we focused on defining the repository interface—the "Port" in our Hexagonal Architecture. We created an abstract contract in the domain layer that specifies what persistence operations our application needs (like add and get) without committing to how they are performed.
Today, we'll build our first concrete implementation of that interface: an in-memory repository. This is a simple yet powerful "Adapter" that lives in the infrastructure layer. Its purpose is not for production use but to serve as a fast, reliable stand-in for a real database during unit testing.
Lesson Goal: By the end of this 60-minute lesson, you will be able to implement an in-memory repository in the infrastructure layer for unit testing purposes.
1. The "Fake" Repository: A Test-Friendly Adapter
The core idea behind the Repository pattern is to decouple our domain logic from persistence details. This decoupling is what allows us to swap different persistence strategies. For production, we might use a PostgreSQL database. For integration tests, maybe SQLite. And for fast unit tests of our domain and application logic, we can use a simple in-memory collection.
This in-memory version is often called a "fake" or "mock" repository. It fulfills the exact same interface contract as the "real" repository, but its implementation is trivial: it just stores objects in a Python list or dict.
Let's start by reading a section from the book "Cosmic Python," which provides a clear and concise implementation of this idea.
The following reading from 'Cosmic Python' by Harry Percival and Bob Gregory demonstrates just how simple and powerful an in-memory repository can be. It shows a FakeRepository that implements the abstract interface we've discussed.
Please read the section titled 'Building a Fake Repository for Tests Is Now Trivial!'. It's a short section with a clear code example using a Python set for storage. Note the tip provided: 'if it’s hard to fake, the abstraction is probably too complicated.'
As you saw, the FakeRepository is incredibly simple. It's just a thin wrapper around a native Python data structure. This simplicity is its greatest strength. It has no external dependencies, runs instantly, and allows us to test our application's logic in complete isolation.
2. Implementing an In-Memory Repository in Python
Let's make this concrete. In the previous lesson, we defined an IProductRepository interface. Now, we'll create an InMemoryProductRepository that implements it.
Recall our interface from the last lesson:
# Located in: src/domain/product/repository.py
from abc import abstractmethod
from typing import Optional, List
from .model import Product
from ..repository import IRepository # The generic base interface
class IProductRepository(IRepository[Product]):
"""
A specific repository interface for the Product aggregate.
"""
@abstractmethod
def find_by_sku(self, sku: str) -> Optional[Product]:
raise NotImplementedError
@abstractmethod
def list(self) -> List[Product]:
raise NotImplementedError
(Note: The base IRepository provides add and get methods.)
Now, let's create the in-memory implementation. We will place this file in the infrastructure layer, as it's a concrete implementation detail. A dictionary is a great choice for storage, as it provides fast lookups by ID, which is exactly what our get method needs.
# Located in: src/infrastructure/persistence/in_memory_product_repository.py
from typing import Dict, List, Optional
from uuid import UUID
from src.domain.product.model import Product
from src.domain.product.repository import IProductRepository
class InMemoryProductRepository(IProductRepository):
"""
An in-memory implementation of the product repository for testing purposes.
"""
def __init__(self, initial_products: Optional[List[Product]] = None):
# We use a dictionary for O(1) lookups for the get() method.
# The key is the product's ID (a UUID), and the value is the Product aggregate.
self._products: Dict[UUID, Product] = {}
if initial_products:
for product in initial_products:
self.add(product)
def add(self, product: Product) -> None:
"""Adds a new product to the repository."""
if product.id in self._products:
# You might want to raise a custom exception here
raise ValueError(f"Product with ID {product.id} already exists.")
self._products[product.id] = product
def get(self, product_id: UUID) -> Optional[Product]:
"""Retrieves a product by its ID."""
return self._products.get(product_id)
def find_by_sku(self, sku: str) -> Optional[Product]:
"""Finds a product by its SKU."""
for product in self._products.values():
if product.sku == sku:
return product
return None
def list(self) -> List[Product]:
"""Lists all products."""
return list(self._products.values())
This class fully implements the IProductRepository contract using a simple dictionary. It's now a fully functional "adapter" that we can plug into our application for testing.
3. The In-Memory Repository in Action
Seeing the code is one thing, but watching it used in a test really clarifies its value. The following video demonstrates a very similar pattern: creating a "mock" repository and then immediately using it to write a simple, effective unit test.
Deep Dive Into the Repository Design Pattern in Python
This clip from ArjanCodes' 'Deep Dive Into the Repository Design Pattern in Python' perfectly illustrates our goal. The presenter builds a MockPostRepository using a dictionary and then writes a test case that uses it.
Watch the segment from 08:02 to 09:21. Pay close attention to two things: 1) how the mock repository uses a dictionary for storage, and 2) how the test function test_add_post uses this mock repository to verify behavior without needing a database.
The test shown in the video is a perfect example of why we do this. Here’s how we could write a similar test for our InMemoryProductRepository using pytest:
# Located in: tests/unit/test_in_memory_product_repository.py
import uuid
from src.domain.product.model import Product
from src.infrastructure.persistence.in_memory_product_repository import InMemoryProductRepository
def test_add_and_get_product():
# Arrange
repo = InMemoryProductRepository()
product_id = uuid.uuid4()
product = Product(id=product_id, sku="TEST-SKU-001", name="Test Product", price=99.99)
# Act
repo.add(product)
retrieved_product = repo.get(product_id)
# Assert
assert retrieved_product is not None
assert retrieved_product.id == product_id
assert retrieved_product.sku == "TEST-SKU-001"
def test_find_by_sku():
# Arrange
product_id = uuid.uuid4()
product = Product(id=product_id, sku="UNIQUE-SKU-123", name="Another Product", price=10.00)
repo = InMemoryProductRepository(initial_products=[product])
# Act
found_product = repo.find_by_sku("UNIQUE-SKU-123")
not_found_product = repo.find_by_sku("NON-EXISTENT-SKU")
# Assert
assert found_product is not None
assert found_product.id == product_id
assert not_found_product is None
These tests are:
- Fast: They execute in milliseconds.
- Isolated: They don't require a database, network, or any other external service.
- Reliable: They won't fail due to network glitches or database configuration issues.
This enables a rapid and robust Test-Driven Development (TDD) workflow.
4. Ports and Adapters: Tying It All Together
Let's briefly revisit the "Ports and Adapters" terminology to solidify our understanding of where the in-memory repository fits.
This final reading from 'Cosmic Python' explicitly defines the terms 'Port' and 'Adapter' in the context of our repository example.
Read the section 'What Is a Port and What Is an Adapter, in Python?'. This will connect the abstract class and the concrete implementations we've built to the formal architectural terms.
To summarize in our own context:
- The Port: The
IProductRepositoryabstract base class. It defines the interface from the application's point of view. - The Adapters: The
InMemoryProductRepositoryis one adapter. A futureSqlAlchemyProductRepositorywill be another.
Our application's core logic will depend only on the port, remaining blissfully unaware of which adapter is currently plugged in.
Conclusion
Today, we built our first, fully functional repository. While it doesn't persist data to a disk, it perfectly fulfills the contract defined by our domain layer, enabling a powerful testing strategy.
Key Takeaways:
- An in-memory repository is a concrete implementation of the repository interface that uses a simple Python collection (like a
dictorlist) for storage. - It resides in the infrastructure layer because it's a specific implementation detail, separate from the domain's business rules.
- Its primary purpose is to facilitate fast, isolated unit tests of your domain and application services, forming a key part of a TDD workflow.
- The ability to easily create a "fake" repository is a strong indicator that your repository interface (the "port") is well-designed and properly decoupled.
Preview of the Next Lesson:
We now have a way to test our application logic without a database. The next step is to build an adapter for real-world persistence. In our next lesson, we will begin this process by learning how to set up a basic SQLAlchemy model to map an Aggregate Root to a database schema. This will lay the foundation for building our SqlAlchemyProductRepository.