CQRS with Separate Read and Write Repositories in Python
Implement CQRS in Python with separate write and read repositories, using commands for mutations and frozen DTOs for queries.
Python code
65 linesfrom dataclasses import dataclass
from typing import Dict, List, Optional
# --- Write side: commands mutate state ---
@dataclass
class CreateUserCommand:
id: int
name: str
class UserWriteRepository:
def __init__(self) -> None:
self._store: Dict[int, Dict[str, object]] = {}
def create(self, command: CreateUserCommand) -> None:
self._store[command.id] = {"id": command.id, "name": command.name}
def update_name(self, user_id: int, new_name: str) -> None:
if user_id in self._store:
self._store[user_id]["name"] = new_name
def delete(self, user_id: int) -> None:
self._store.pop(user_id, None)
# --- Read side: queries return DTOs, never mutate ---
@dataclass(frozen=True)
class UserReadModel:
id: int
name: str
class UserReadRepository:
def __init__(self, write_repo: UserWriteRepository) -> None:
self._write_repo = write_repo
def get_by_id(self, user_id: int) -> Optional[UserReadModel]:
data = self._write_repo._store.get(user_id)
if data is None:
return None
return UserReadModel(id=data["id"], name=data["name"])
def list_all(self) -> List[UserReadModel]:
return [
UserReadModel(id=data["id"], name=data["name"])
for data in self._write_repo._store.values()
]
if __name__ == "__main__":
write_repo = UserWriteRepository()
read_repo = UserReadRepository(write_repo)
# Write operations
write_repo.create(CreateUserCommand(id=1, name="Alice"))
write_repo.create(CreateUserCommand(id=2, name="Bob"))
write_repo.update_name(1, "Alicia")
# Read operations — pure queries
user = read_repo.get_by_id(1)
all_users = read_repo.list_all()
print(f"User 1: {user}")
print(f"All: {all_users}")
Output
User 1: UserReadModel(id=1, name='Alicia')
All: [UserReadModel(id=1, name='Alicia'), UserReadModel(id=2, name='Bob')]
How it works
CQRS separates commands (writes) from queries (reads). The write repository mutates an internal dict through methods like create, update_name, and delete. The read repository never mutates state; it returns frozen UserReadModel DTOs, ensuring reads are side-effect-free. Sharing the same underlying store here simulates a common data source, but in production, reads might hit a separate optimized database or cache. This separation clarifies intent and lets each side scale independently.
Common mistakes
- Letting the read repository mutate state instead of returning immutable DTOs
- Using the same model class for both commands and read models, mixing concerns
- Exposing the write repository's internal dict publicly instead of encapsulating it
- Forgetting to handle missing keys gracefully in read methods
Variations
- Implement read repository with its own database connection or materialized view for better read scalability
- Use an event bus to update read models asynchronously after commands execute
Real-world use cases
- Scaling a microservice where reads are frequent but writes are rare, so separate read replicas improve performance.
- Maintaining a denormalized query layer for projections, updated by events after writes in an event-sourced system.
- Isolating volatile write operations from read-heavy APIs to reduce lock contention and improve consistency guarantees.
Sponsored
More from Microservices patterns
- BFF aggregation pattern: combine multiple service responses in Python easy
- Backward Compatible Schema Evolution in Python medium
- Bulkhead Thread Pool per Service Mock in Python medium
- Cache-Aside Pattern in Python: Per-Service Mock easy
- Consumer Driven Contract Pact Mock in Python medium
- Correlation ID HTTP header mock in Python easy
Keep learning
Related tutorials and quizzes for this topic.