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.

Medium Python 3.9+ Aug 9, 2026 Microservices patterns 14 views 0 copies

Python code

65 lines
Python 3.9+
from 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

stdout
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

  1. Implement read repository with its own database connection or materialized view for better read scalability
  2. 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

Run this sample

Open the browser IDE to tweak the example and see results without installing anything.

Open editor

More from Microservices patterns

Related tutorials and quizzes for this topic.