Практика 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;adapters—applicationи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). Реализации «в памяти», наследующие порты:
InMemoryCourseRepository—dict[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).
Вопросы для самопроверки
- В какую сторону направлены зависимости в исходном коде? Покажите это на
импортах вашего проекта: что вправе импортировать
application, а что — нет. - Почему
CourseEnrollmentServiceзависит отCourseRepository(ABC), а не отInMemoryCourseRepositoryнапрямую? Какие выгоды это даёт для тестирования? - Как порты и адаптеры вместе с принципом DIP позволяют сервису «сохранять в хранилище», не нарушая правило зависимостей?
- Что такое composition root? Почему важно, чтобы он был единственным местом сборки зависимостей, и какие файлы изменятся при переходе на реальную СУБД?
- Где в вашем проекте рождаются доменные исключения и где они перехватываются? Почему контроллер возвращает структурированный ответ, а не пробрасывает ошибку?
- К какому слою Clean Architecture относится контроллер? А InMemory-репозиторий?
- Чем сущность
Courseотличается от сценарияcreate_course? Почему валидация живёт в сервисе, а не в модели? - Для какого проекта такая архитектура была бы избыточной? Обоснуйте ответ.