classDiagram
%% Entities
class Entity {
<<Entity>>
+UUID id
+__eq__(other) bool
+__hash__() int
}
class Task {
<<Entity>>
+str title
+str description
+Deadline? due_date
+Priority priority
+TaskStatus status
+start() None
+complete() None
+is_overdue() bool
}
class Project {
<<Aggregates>>
+str name
+str description
-dict~UUID,Task~ _tasks
+add_task(task) None
+remove_task(task_id) None
+get_task(task_id) Task?
+tasks list~Task~
}
%% Value Objects
class TaskStatus {
<<Value Object>>
TODO
IN_PROGRESS
DONE
}
class Priority {
<<Value Object>>
LOW
MEDIUM
HIGH
}
class Deadline {
<<Value Object>>
+datetime due_date
+is_overdue() bool
+time_remaining() timedelta
+is_approaching() bool
}
%% Domain Services
class TaskPriorityCalculator {
<<Domain Service>>
+calculate_priority(task)$ Priority
}
%% Relationships
Task --|> Entity : inherits
Project --|> Entity : inherits
Task o-- Deadline : has optional
Task --> Priority : uses
Task --> TaskStatus : uses
Project *-- Task : contains many
TaskPriorityCalculator ..> Task : operates on
TaskPriorityCalculator ..> Priority : returns
22 DDD (Core)
Example code: Clean Architecture TodoApp
22.1 Overview
Repository Structure:
(base) ➜ todo_app git:(main) tree -L 3
.
└── domain
├── entities
│ ├── entity.py
│ ├── project.py
│ └── task.py
├── services
│ └── task_priority_calculator.py
└── value_objects.pyDomain Model Relationships:
22.2 Entities
These are objects defined by their identity that persists even when their attributes change.
22.2.1 entity.py
from dataclasses import dataclass, field
from uuid import UUID, uuid4
@dataclass
class Entity:
# Automatically generates a unique UUID for the 'id' field;
# excluded from the __init__ method
id: UUID = field(default_factory=uuid4, init=False)
def __eq__(self, other: object) -> bool:
if not isinstance(other, type(self)):
return NotImplemented
return self.id == other.id
def __hash__(self) -> int:
return hash(self.id)22.2.2 task.py
from dataclasses import dataclass, field
from typing import Optional
from Chapter_4.TodoApp.todo_app.domain.entities.entity import Entity
from Chapter_4.TodoApp.todo_app.domain.value_objects import (
Deadline,
Priority,
TaskStatus,
)
@dataclass
class Task(Entity):
title: str
description: str
due_date: Optional[Deadline] = None
priority: Priority = Priority.MEDIUM
status: TaskStatus = field(default=TaskStatus.TODO, init=False)
def start(self) -> None:
if self.status != TaskStatus.TODO:
raise ValueError("Only tasks with 'TODO' status can be started")
self.status = TaskStatus.IN_PROGRESS
def complete(self) -> None:
if self.status == TaskStatus.DONE:
raise ValueError("Task is already completed")
self.status = TaskStatus.DONE
def is_overdue(self) -> bool:
return self.due_date is not None and self.due_date.is_overdue()22.3 Aggregates
Aggregates are a crucial pattern in DDD for maintaining consistency and defining transactional boundaries within the domain. An aggregate is a cluster of domain objects that we treat as a single unit for data changes. Each aggregate has a root and a boundary. The root is a single, specific entity contained in the aggregate, and the boundary defines what is inside the aggregate.
22.3.1 project.py
from dataclasses import dataclass, field
from typing import Optional
from uuid import UUID
from Chapter_4.TodoApp.todo_app.domain.entities.entity import Entity
from Chapter_4.TodoApp.todo_app.domain.entities.task import Task
@dataclass
class Project(Entity):
name: str
description: str = ""
_tasks: dict[UUID, Task] = field(default_factory=dict, init=False)
def add_task(self, task: Task) -> None:
self._tasks[task.id] = task
def remove_task(self, task_id: UUID) -> None:
self._tasks.pop(task_id, None)
def get_task(self, task_id: UUID) -> Optional[Task]:
return self._tasks.get(task_id)
@property
def tasks(self) -> list[Task]:
return list(self._tasks.values())22.4 Value Objects
These are immutable objects defined by their attributes rather than identity. Two Money objects with the same currency and amount are considered equal.
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
class TaskStatus(Enum):
TODO = "TODO"
IN_PROGRESS = "IN_PROGRESS"
DONE = "DONE"
class Priority(Enum):
LOW = 1
MEDIUM = 2
HIGH = 3
# frozen=True makes this immutable as it should be for a Value Object
@dataclass(frozen=True)
class Deadline:
due_date: datetime
def __post_init__(self):
if not self.due_date.tzinfo:
raise ValueError("Deadline must use timezone-aware datetime")
if self.due_date < datetime.now(timezone.utc):
raise ValueError("Deadline cannot be in the past")
def is_overdue(self) -> bool:
return datetime.now(timezone.utc) > self.due_date
def time_remaining(self) -> timedelta:
return max(timedelta(0), self.due_date - datetime.now(timezone.utc))
def is_approaching(self, warning_threshold: timedelta = timedelta(days=1)) -> bool:
return timedelta(0) < self.time_remaining() <= warning_threshold22.5 Domain services
These represent stateless operations that don’t naturally belong to a single entity or value object. They handle domain logic that spans multiple objects, like calculating shipping costs based on an order’s items and a customer’s location.
from datetime import timedelta
from Chapter_4.TodoApp.todo_app.domain.entities.task import Task
from Chapter_4.TodoApp.todo_app.domain.value_objects import Priority
class TaskPriorityCalculator:
@staticmethod
def calculate_priority(task: Task) -> Priority:
if task.is_overdue():
return Priority.HIGH
elif task.due_date and task.due_date.time_remaining() <= timedelta(days=2):
return Priority.MEDIUM
else:
return Priority.LOW22.6 Usage Examples
# Create a task
from datetime import datetime, timedelta
from todo_app.domain.entities.task import Task
from todo_app.domain.value_objects import Deadline, Priority
task = Task(
title="Complete project proposal",
description="Draft and review the proposal for the new client project",
due_date=Deadline(datetime.now() + timedelta(days=7)),
priority=Priority.HIGH,
)
# Start the task
task.start()
print(task.status) # TaskStatus.IN_PROGRESS
# Complete the task
task.complete()
print(task.status) # TaskStatus.DONE
# Try to start a completed task
try:
task.start() # This will raise a ValueError
except ValueError as e:
print(str(e)) # "Only tasks with 'TODO' status can be started"
# Check if the task is overdue
print(task.is_overdue()) # False