Building Blocks

In this section we will define the basic building blocks that will be used by the core module of the application, namely the todos module.

Domain model

Since the todos module is responsible for adding and completing todos, we need to define a TodoModel that will encapsulate all logic related to a todo:

@dataclass
class TodoModel:
    """Model representing a todo"""

    id: UUID
    title: str
    description: str = ""
    due_at: Optional[datetime] = None
    completed_at: Optional[datetime] = None

    @property
    def is_completed(self) -> bool:
        return self.completed_at is not None

    def is_due(self, now: datetime) -> bool:
        if self.due_at is None or self.is_completed is False:
            return False
        return self.due_at < now

    def mark_as_completed(self, when: datetime) -> None:
        self.completed_at = when

From this point we will refer to TodoModel instances as todo entities (or entities in short).

Read model

In addition to a TodoModel, we will also create a corresponding TodoReadModel:

@dataclass
class TodoReadModel:
    """Read model exposed to the external world"""

    id: UUID
    title: str
    description: str
    is_due: bool
    is_completed: bool

Why do we need 2 models? TodoModel serves as a domain model that encapsulates the business logic of a todo entity. This model will be modified by command handlers defined within the module. In other words, only the todos module has the authority to change the state of TodoModel other modules are not permitted to access it.

On the other hand, TodoReadModel is exposed outside the todos module. Other modules can query the todos module and receive the read model. If other modules need to make changes, they should not directly access it. Instead, they should request the modification by sending a message to the todos module.

Todo repository

Our design pattern of choice for storing and retrieving entities is the repository pattern.

Note

The repository pattern encapsulates the logic for accessing data within an application, providing a layer of abstraction between the data access code and the business logic. By abstracting away the details of data storage and retrieval, it promotes modularity, testability, and flexibility in software design.

In real applications, a repository is commonly associated with a fully-fledged database. This includes managing DB connections, managing transactions with commit and rollback operations, etc. However, for the sake of simplicity, we will implement the in-memory TodoRepository as follows:

class TodoRepository:
    """A repository of todos"""

    def __init__(self):
        self.items: list[TodoModel] = []

    def add(self, item: TodoModel) -> None:
        self.items.append(item)

    def get_by_id(self, todo_id: UUID) -> TodoModel:
        for item in self.items:
            if item.id == todo_id:
                return item
        raise ValueError(f"Todo with id {todo_id} does not exist")

    def get_all(self) -> list[TodoModel]:
        return self.items

    def get_all_completed(self):
        return [todo for todo in self.items if todo.is_completed]

    def get_all_not_completed(self):
        return [todo for todo in self.items if not todo.is_completed]

Having the basic building blocks in place, we are now ready to implement the todos module.