Перейти к содержимому

Практика 16. Практическая работа 16. Комплексный проект на Clean Architecture

Цель

  • Спроектировать и собрать небольшое целостное приложение по принципам Clean Architecture, объединив всё изученное в разделе: сущности, сервисы, репозитории (порты + адаптеры), DI и доменные исключения.
  • Закрепить правило зависимостей: исходные зависимости направлены только внутрь, внутренние слои ничего не знают о внешних.
  • Отработать инверсию зависимостей через порты (ABC) и адаптеры (реализации в памяти), а также сборку слоёв в единой точке композиции (composition root) с внедрением зависимостей.
  • Реализовать единообразную обработку ошибок доменными исключениями, которые рождаются в ядре и переводятся в ответ пользователю на границе.

Краткая теория

  • Слои Clean Architecture (от центра наружу): Entities (доменные модели) → Use Cases / Services (сценарии приложения) → Interface Adapters (контроллеры, реализации репозиториев) → Frameworks & Drivers (CLI, web, БД, файлы).
  • Правило зависимостей. Имя класса из внешнего слоя не должно упоминаться во внутреннем коде. domain не импортирует ничего из проекта; application знает только domain; adaptersapplication и domain; main знает всё.
  • Порты и адаптеры. Сервис зависит не от конкретного хранилища, а от абстрактного интерфейса (порт, abc.ABC). Конкретная реализация (адаптер) «реализует» порт — стрелка реализации направлена внутрь, хотя поток вызова во время работы идёт наружу. Это и есть разрешение противоречия через DIP.
  • Composition root. Единственное место (main.py), где создаются адаптеры и внедряются внутрь через конструкторы. Смена технологии хранения затрагивает только адаптеры и эту точку сборки.
  • Доменные исключения. Все ошибки предметной области наследуются от одного базового класса, несут контекст в атрибутах (а не в тексте) и перехватываются точечно. Подробности и сквозной пример — в lecture_16.md.

Предметная область проекта — «Система записи на курсы»: каталог курсов с ограничением по местам, регистрация студентов, запись и отмена записи, отчёты.


Этапы работы

Проект выполняется поэтапно. Каждый этап опирается на предыдущий; итог — работающее приложение с консольной точкой входа. Целевое дерево каталогов:

course_enrollment/
├── domain/ # Entities — центр, ничего не импортирует из проекта
│ ├── __init__.py
│ ├── models.py # Course, Student, Enrollment
│ └── exceptions.py # иерархия доменных исключений
├── application/ # Use Cases + порты
│ ├── __init__.py
│ ├── ports.py # CourseRepository, StudentRepository, EnrollmentRepository (ABC)
│ └── services.py # CourseEnrollmentService
├── adapters/ # Interface Adapters
│ ├── __init__.py
│ ├── repositories.py # InMemory*Repository (реализации портов)
│ └── controllers.py # CourseEnrollmentController
├── main.py # Composition root: сборка слоёв + DI
└── tests/
└── test_service.py # не менее 6 тестов (pytest)

Импорты разрешены только «внутрь». Обратный импорт (например, domain, ссылающийся на adapters) — сигнал нарушения архитектуры, такой код не принимается.

Этап 1. Domain — сущности и доменные исключения

Слой domain/. Никаких импортов из других слоёв проекта.

Сущности (models.py). Реализуйте Course, Student, Enrollment (удобно через @dataclass). Поля:

  • Course(id: int | None, title: str, description: str, max_seats: int, start_date: date).
  • Student(id: int | None, first_name: str, last_name: str, email: str).
  • Enrollment(id: int | None, student_id: int, course_id: int, enrolled_at: datetime).

Требования:

  • id при создании может быть None — присваивается репозиторием при сохранении.
  • Реализовать __repr__ и сравнение __eq__ по id (если оба заданы).
  • Сущности хранят данные и инварианты предметной области; правила приложения (валидация при создании) живут в сервисе, а не в моделях.

Исключения (exceptions.py). Базовый класс CourseEnrollmentError(Exception) и наследники с контекстом в атрибутах:

  • CourseNotFoundError(course_id), StudentNotFoundError(student_id), EnrollmentNotFoundError(enrollment_id) — информативный __str__.
  • CourseValidationError(field, message, value=None), StudentValidationError(field, message, value=None).
  • EnrollmentError(reason) — общая ошибка записи («нет мест», «уже записан»).
  • (По желанию) MultipleValidationErrors(errors: list[Exception]) либо ExceptionGroup для накопления ошибок валидации.

Этап 2. Application — порты и сервис

Слой application/. Импортирует только domain.

Порты (ports.py). Абстрактные интерфейсы через abc.ABC и @abstractmethod. Минимальный набор методов (сигнатуры — ориентир):

class CourseRepository(ABC):
@abstractmethod
def find_by_id(self, course_id: int) -> Course | None: ...
@abstractmethod
def find_all(self) -> list[Course]: ...
@abstractmethod
def save(self, course: Course) -> Course: ...
@abstractmethod
def delete(self, course_id: int) -> None: ...
class StudentRepository(ABC):
# find_by_id, find_by_email, find_all, save, delete
...
class EnrollmentRepository(ABC):
# find_by_id, find_by_course_id, find_by_student_id,
# find_by_student_and_course, save, delete, count_by_course
...

Сервис (services.py). Класс CourseEnrollmentService — сценарии использования. Все три репозитория передаются в конструктор (DI); сервис зависит только от портов, не от их реализаций.

Реализуйте методы:

  • create_course(title, description, max_seats, start_date) -> Course — валидация (title не пустой, ≤ 200; description ≤ 2000; max_seats ≥ 1; start_date не в прошлом), накопление ошибок, сохранение.
  • get_course(course_id) / list_courses(only_available=False) / delete_course(course_id) (с каскадным удалением записей курса).
  • register_student(first_name, last_name, email) — валидация + проверка уникальности email; get_student(student_id); list_students().
  • enroll(student_id, course_id) — проверка существования студента и курса, отсутствия дубля записи и наличия свободных мест.
  • cancel_enrollment(enrollment_id), get_course_enrollments(course_id), get_student_enrollments(student_id).

Требования: ошибки «не найдено» и нарушение правил выражаются доменными исключениями из этапа 1; все публичные методы аннотированы типами и снабжены короткими docstring (что делает, что возвращает, какие исключения бросает).

Этап 3. Adapters — репозитории и контроллер

Слой adapters/. Импортирует application и domain.

Репозитории (repositories.py). Реализации «в памяти», наследующие порты:

  • InMemoryCourseRepositorydict[int, Course] + счётчик id.
  • InMemoryStudentRepository — плюс индекс по нормализованному email.
  • InMemoryEnrollmentRepository — плюс индекс по паре (student_id, course_id).

При обращении к несуществующему id в delete бросайте доменные исключения (CourseNotFoundError и т.д.), а не голый KeyError. В save при id is None присваивайте новый id и возвращайте сохранённый объект.

Контроллер (controllers.py). CourseEnrollmentController принимает сервис в конструкторе. Методы-обработчики перехватывают доменные исключения и возвращают структурированный результат:

def handle_create_course(self, ...) -> dict:
try:
course = self.service.create_course(...)
return {"success": True, "message": "...", "data": course.id}
except CourseEnrollmentError as e:
return {"success": False, "message": str(e), "data": None}

Контроллер не содержит бизнес-логики — только перевод запроса в вызов сценария и упаковку ответа/ошибки.

Этап 4. Composition root, тесты и (по желанию) CLI

main.py — точка композиции. Единственное место сборки слоёв:

def create_app() -> CourseEnrollmentController:
course_repo = InMemoryCourseRepository()
student_repo = InMemoryStudentRepository()
enrollment_repo = InMemoryEnrollmentRepository()
service = CourseEnrollmentService(course_repo, student_repo, enrollment_repo)
return CourseEnrollmentController(service)

main.py знает обо всех слоях, но ни один слой не знает о main.py. Продемонстрируйте сквозной сценарий: создать курс → зарегистрировать студента → записать на курс → показать записи → отменить запись.

Тесты (tests/test_service.py). Не менее 6 тестов (pytest), покрывающих: создание курса с валидацией (успех и ошибка), регистрацию студента и уникальность email, успешную запись, ошибку «уже записан», ошибку «нет свободных мест», отмену записи и каскадное удаление курса. В тестах подставляйте InMemory-адаптеры — без реальной БД и файлов.

(Бонус) CLI. Простой цикл чтения команд (create_course "..." "..." 30 2025-09-01, enroll <student_id> <course_id>, list_courses и т.д.) с выводом результата контроллера в консоль.


Критерии оценки

КритерийВесЧто проверяется
Domain (этап 1)15%Сущности с __repr__/__eq__; полная иерархия исключений с контекстом в атрибутах.
Application (этап 2)30%Порты как ABC; сервис с полной бизнес-логикой, валидацией и корректными исключениями; зависимость только от портов.
Adapters (этап 3)20%InMemory-реализации портов; контроллер с перехватом доменных ошибок и структурированным ответом.
Composition root + DI (этап 4)10%Единая точка сборки; внедрение через конструкторы; рабочий сквозной сценарий.
Соблюдение правила зависимостей10%Импорты только «внутрь»; отсутствие обратных зависимостей; чистота границ слоёв.
Тесты и качество кода15%≥ 6 осмысленных тестов; типизация и docstrings публичных методов; читаемость.

Бонус до +10%: рабочий CLI с парсингом команд или файловая реализация репозиториев как альтернативный адаптер (подключается заменой строк в main.py).


Вопросы для самопроверки

  1. В какую сторону направлены зависимости в исходном коде? Покажите это на импортах вашего проекта: что вправе импортировать application, а что — нет.
  2. Почему CourseEnrollmentService зависит от CourseRepository (ABC), а не от InMemoryCourseRepository напрямую? Какие выгоды это даёт для тестирования?
  3. Как порты и адаптеры вместе с принципом DIP позволяют сервису «сохранять в хранилище», не нарушая правило зависимостей?
  4. Что такое composition root? Почему важно, чтобы он был единственным местом сборки зависимостей, и какие файлы изменятся при переходе на реальную СУБД?
  5. Где в вашем проекте рождаются доменные исключения и где они перехватываются? Почему контроллер возвращает структурированный ответ, а не пробрасывает ошибку?
  6. К какому слою Clean Architecture относится контроллер? А InMemory-репозиторий?
  7. Чем сущность Course отличается от сценария create_course? Почему валидация живёт в сервисе, а не в модели?
  8. Для какого проекта такая архитектура была бы избыточной? Обоснуйте ответ.