О проекте
Теперь, когда мы прошли «курс молодого бойца» по архитектуре, самое время перейти к разбору проекта.
Исходный код проекта: тут или тут для более удобного ориентирования.
Основные используемые технологии: Python 3.13, FastAPI, Nginx, Uvicorn, PostgreSQL, Alembic, Celery, Redis, Pytest, FileBeat, LogStash, ElasticSearch, Kibana, Prometheus, Grafana, Docker, Docker Compose.
Идея проекта — создать относительно небольшой пример приложения, демонстрирующий распространённый функционал: логирование, мониторинг, хранение и обработку данных, интеграцию с внешними системами и работу с фоновыми задачами.
Функционально проект реализует систему сбора и анализа вакансий с агрегаторов вроде HeadHunter. Но гораздо важнее не то, какие задачи решает система, а то — как именно она это делает. Этот проект — прежде всего о структуре, архитектуре и принципах.
Ниже — обзор его ключевых функциональных особенностей.
JWT (ES256) авторизация
Поддержка cookie и header транспорта
Хранение и отзыв access/refresh токенов
Вся логика реализована через интерфейсы — адаптация под собственные нужды потребует минимальных усилий
Пользователь и контекст авторизации
Концепции User и AnonymousUser
Контроль доступа через middleware и декоратор
@access_control
Автообновление access_token при валидном refresh_token
Поддержка суперпользователей и публичных/секретных маршрутов
CRUD генератор
Универсальный инструмент для быстрого прототипирования и решения простых задач
Важно: нарушает принципы чистой архитектуры, объединяя реализацию и бизнес-логику
Централизованное логирование (ELK + FileBeat)
Сбор логов: FastAPI, PostgreSQL, nginx
Единый формат, единое хранилище — удобно для дебага и мониторинга
Метрики и мониторинг (Prometheus + Grafana)
Метрики для FastAPI, PostgreSQL, системных ресурсов и nginx
В комплекте — готовые Grafana-дэшборды
Фоновая и периодическая обработка задач (Celery + Redis)
Redis как in-memory хранилище
Удобный клиент с обёрткой для временного хранения данных и простого использования кеша.
Глоссарий
В прошлой статье мы уже разобрали значительную часть терминологии, а теперь познакомимся с некоторыми новыми определениями.
Идентификация (Indentification) — это действие или процесс, при котором подаётся идентификатор, чтобы система могла распознать субъекта и отличить его от других.
Аутентификация (Authentication) — это процесс проверки того, кто или что представляет собой отдельное лицо, сущность или сайт, путём проверки одного или нескольких доказательств (пароль, отпечаток, токен и т.д.). То есть аутентификация подтверждает, что вы – тот самый пользователь, за которого себя выдали.
Авторизация (Authorization) — право или разрешение, предоставленное системе или субъекту на доступ к ресурсу системы. Проще говоря, авторизация определяет, какие действия и данные доступны аутентифицированному пользователю (например, роли, права чтения/записи). Это может включать проверку прав в токене, ролей в системе, правил доступа (ACL) и т.п
Примечание: часто можно встретить, что под авторизацией имеют ввиду сразу идентификацию, аутентификацию и авторизацию, то есть весь процесс входа в систему. Именно так я буду делать, говоря о модуле авторизации.Ограниченный контекст (Bounded Context) — это логически изолированная часть предметной области со своими моделями, терминами и правилами, внутри которой термины и логика имеют чётко определённое и непротиворечивое значение.
Паттерн «Репозиторий» (Repository) — это абстракция поверх системы постоянного хранения. Он скрывает скучные детали доступа к данным, делая вид, что все данные находятся прямо в памяти.
Цель: изолировать слой работы с данными, чтобы остальная система не зависела от конкретной реализации хранилища.Паттерн «Сервисный слой» (Service layer) – это абстракция, в которой реализуется бизнес-логика, координирующая работу между сущностями, репозиториями и другими компонентами.
Цель: централизовать бизнес-логику и избежать её размазывания по всему проекту.Паттерн «Единица работы» (Unit of Work) - управляет атомарностью операций, следит за изменениями и обеспечивает согласованное сохранение или откат всех изменений в рамках одной транзакции.
Цель: гарантировать целостность данных и согласованное выполнение операций.Событие (Domain Event) — это вид объекта-значения. У событий нет поведения, потому что они представляют собой чистые структуры данных. Их всегда называют на языке предметной области и думают о них как о части модели предметной области. Они отражают факт, который уже произошёл. Событие уведомляет заинтересованные части системы о том, что что-то случилось, чтобы они могли отреагировать.
Команда (Domain Command) — подобно событиям, представляет собой тип сообщения, которое описывает намерение совершить действие в системе. Оно инициирует изменение состояния и отправляется в один конкретный обработчик. Их обычно представляют немыми (dumb) структурами данных.
Архитектура и структура проекта
Я предполагаю, что читатель уже так или иначе знаком с концепцией чистой архитектуры, поэтому не стану подробно расписывать каждый базовый принцип. Вместо этого — отмечу те идеи, которые я лично считаю важными при проектировании любого backend-приложения, и которые легли в основу структуры этого проекта:
Направление зависимостей всегда идёт «вглубь»
Зависимости всегда должны быть направлены «вглубь»: от внешнего слоя (например, API-интерфейса) к внутреннему (бизнес-логике). В проекте это означает, что слой presentation может зависеть от application, но не наоборот. application может знать о domain, но domain ничего не знает о внешних слоях.Поток управления идёт сначала «внутрь», затем «наружу»
Интересно, что поток управления отличается от направления зависимостей. Сначала внешний слой (presentation) инициирует вызов, внутренний (application) — обрабатывает, используя инверсию зависимостей, вызывая infrastructure, и возвращает результат обратно наружу (presentation).Изоляция бизнес-логики
Не менее важным принципом является то, что бизнес-правила, связанные с предметной областью полностью изолированы от технических деталей: они не знают о фреймворках, базах данных или внешних сервисах. Такая изоляция позволяет не только проще тестировать систему, но и легко адаптировать её под другие контексты.Тестируемость
Это прямое следствие архитектуры. Бизнес-правила можно тестировать без пользовательского интерфейса, базы данных, веб-сервера или любого другого внешнего элемента. Мы просто передаём нужные зависимости — и получаем предсказуемый результат.Четкие границы между модулями и компонентами
Границы определяются не механически, по количеству строк кода или файлам, а исходя из принципов модульности:REP (Reuse/Release Equivalence Principle) — переиспользуемое должно быть отдельно и выпускаться независимо.
CCP (Common Closure Principle) — всё, что меняется по одной причине, должно лежать рядом.
CRP (Common Reuse Principle) — ничего лишнего не должно подтягиваться «в нагрузку», если оно не нужно.
Согласованность по структуре, именованию, стилю кода и документации.
Это не просто про эстетику — это про скорость входа и чтения кода. Независимо от того, зашли вы в модуль авторизации, пользователей или интеграций — всё выглядит одинаково, читается одинаково и устроено одинаково.
Структура слоёв
Проект следует принципам чистой архитектуры, в которой каждый логический модуль (например, users
, auth
, vacancies
) представляет собой изолированный контекст, внутри которого выделены четыре слоя: от наиболее абстрактного (доменного) к техническому (инфраструктурному и пользовательскому).
Такая структура позволяет чётко разделить ответственность, сохранить прозрачность кода и упростить как сопровождение, так и развитие проекта.
Domain
Это сердце системы — самый стабильный и независимый слой. Здесь определяются сущности, правила, бизнес-исключения и интерфейсы, которые отражают предметную область и описывают, что должно быть реализовано, но не как.
Этот слой не зависит ни от чего, он абсолютно изолирован от технических деталей и внешних библиотек.
Основные компоненты:entities
– это ключевые бизнес-сущности, отражающие основные объекты предметной области. Они обладают идентичностью, внутренним состоянием и поведением, отражающим бизнес-логику.interfaces
– контракты взаимодействия, описывающие что нужно сделать, но не как.dtos
(Data Transfer Objects) – простые структуры данных, предназначенные для безопасной передачи информации между слоями. Они не содержат логики и обеспечивают предсказуемость и чистоту при сериализации и валидации.exceptions
– бизнес-исключения, отражающие ошибки специфичные для предметной области.
Application
Этот слой – дирижер системы. Он реализует прикладную бизнес-логику: use-case'ы, координаторы, сервисы, которые используют сущности и интерфейсы domain, оставаясь при этом изолированным от инфраструктурных деталей.
Основные компоненты:services
— логика многократного использования, реализующая (на уровне контрактов) конкретные бизнес-операции или вычисления, предназначенные для вызова в сценариях использования. Эти компоненты могут зависеть от интерфейсов, определяемых доменом, но не должны содержать код, специфичный для инфраструктуры.use_cases
— логика приложения, координирующая сущности, интерфейсы и сервисы для выполнения определенных бизнес-действий или сценариев.mappers
– не обязательны, но могут быть полезны, если есть необходимость преобразовывать DTO <-> Entity
Infrastructure
Это слой конкретики. Именно здесь мы «приземляем» абстракции из domain и application — реализуем интерфейсы, настраиваем подключения, определяем, как именно всё работает. Он никогда не определяет правила, лишь обслуживает бизнес-логику. Это слой, который может зависеть от внешних нестабильных реализаций: библиотек, фреймворков и т.п.
У меня нет чёткой структуры для этого слоя, она всегда индивидуальна под конкретный модуль. Но довольно часто могут встретиться:Реализации хранилищ (репозитории и unit of work)
Реализации сервисов
API адаптеры, различные клиенты (redis, elasticsearch)
Вспомогательные реализации: парсеры, рендереры PDF/Excel, файловые адаптеры, кеширующие прокси, системы геолокации и т.д.
Presentation
Этот слой — внешняя оболочка системы. В нем мы описываем всё взаимодействие с пользователем (или другими системами): API, админка, шаблоны, задачи и т.п. Здесь рождаются входные данные и обрабатываются выходные. Именно в этом слое собираются зависимости.Основные компоненты:
dependencies
– связывание интерфейсов и их реализаций в рамках текущего модуля для использования на уровне presentation слояadmin
– административный UI (sqladmin и т.п.)api
– роутеры FastAPI: принимают запрос, валидируют его, вызывают use-case и возвращают ответviews
— шаблоны и серверный рендеринг (если есть)middlewares
– дополнительная обработка запросов/ответовtasks
– периодические и отложенные задачи
Визуализация структуры
Для того чтобы наглядно представить архитектурную организацию слоёв, я подготовил схему. На ней отображено их внутреннее наполнение и направления зависимостей.
Схема не претендует на полную детализацию, но хорошо иллюстрирует общий архитектурный принцип: направление зависимостей строго внутрь, реализация — на внешнем уровне.
Стоить отметить как для этих схем, так и для следующих:
Простые стрелки соответствуют отношениям использования
Стрелки с треугольным наконечником соответствуют отношениям реализации или наследования
Примеры того, как читать схему:
Application -> Domain означает, что компоненты из Application могут использовать любые компоненты из Domain, но никогда не наоборот
Interfaces -> Entities означает, что Entities участвует в формировании/определении Interfaces, т.е. Entities импортируется в Interfaces
Presentation -> Infrastructure -> Application -> Domain. Любой внешний слой может использовать любой внутренний. Например, Presentation может использовать любой компонент из Infrastructure, Application, Domain.

Нюансы структуры
Некоторые элементы проекта не укладываются строго в один слой и могут встречаться на разных уровнях архитектуры. Я хотел бы разъяснить почему именно были приняты те или иные решения:
Data transfer objects & Mappers
Хотя в структуре они представлены на уровне domain/application, но это не единственное место, где они могут быть определены. Главная задача - не нарушать Dependency Rule.
Для себя я выделил 3 уровня для DTOs и их преобразователей (mappers), которые в теории могут появится в системе, и которые не нарушат правило зависимостей:На уровне domain/application – это DTOs, естественные для бизнес логики. Они находятся полностью под нашим контролем и определяются, исходя из конкретных сценариев использования и доменных сущностей.
Примером такого DTO может служитьUserUpdateDTO
и сущностьUserUpdate
, к которой он маппится.class UserUpdate(CustomModel): """ Domain model for updating user information. Represents a partial update operation for a user entity. Attributes: id: ID of the user to be updated. email: New email address (optional). is_active: Whether the user should be active (optional). is_superuser: Whether the user should be a superuser (optional). is_verified: Whether the user is verified (optional). """ id: int email: str | None = None is_active: bool | None = True is_superuser: bool | None = False is_verified: bool | None = False class UserUpdateDTO(CustomModel): """ Data Transfer Object for updating user information. This model is used in endpoints for partial updates to user data. Attributes: email: Updated email address (optional). is_active: Whether the user account is active. is_superuser: Whether the user has administrative privileges. is_verified: Whether the user has verified their email. """ email: str | None = None is_active: bool | None = True is_superuser: bool | None = False is_verified: bool | None = False def to_entity(self, user_id: int): return UserUpdate(id=user_id, **self.model_dump(exclude_unset=True))
На уровне infrastructure – это DTOs, структуру которых мы не контролируем, но которые нужны нам для определенных сценариев в системе.
Примером такого DTO может быть структура, возвращаемая внешним API, с которым интегрируется система. В проекте такую роль выполняетHHVacancy
— схема, описывающая вакансию, полученную от сервиса HeadHunter.Поскольку мы не контролируем формат этих данных, соответствующий DTO определяется в слое infrastructure. На этом же уровне происходит его преобразование (маппинг) в более универсальный формат — например, в доменную сущность или промежуточный DTO, пригодный для использования в application слое.
class HHVacancy(CustomModel): id: str accept_incomplete_resumes: bool | None = None address: HHAddress | None = None alternate_url: str | None = None apply_alternate_url: str | None = None archived: bool = False area: HHArea | None = None employer: HHEmployer | None = None employment: HHEmployment | None = None experience: HHExperience | None = None has_test: bool | None = None name: str | None = None published_at: str | None = None created_at: str | None = None response_letter_required: bool | None = None salary: HHSalary | None = None schedule: HHSchedule | None = None snippet: HHSnippet | None = None type: HHType | None = None url: str | None = None professional_roles: list[HHProfessionalRole] | None = None
На уровне presentation – это DTOs, которые определяются именно на этом уровне и не взаимодействуют с остальными слоями.
Например, это просто формат данных, который должен принять наш контроллер. Такие DTOs не попадают дальше в application или domain, так как сразу преобразуются в примитивы, которые непосредственно нужны use-case.
Примером такого DTO являетсяAuthUserDTO
, которое содержит email и password.class AuthUserDTO(CustomModel): """ Represents credentials submitted during login """ email: EmailStr password: str = Field(min_length=6, max_length=128) @classmethod def as_form( cls, email: EmailStr = Form(...), password: str = Form(..., min_length=6, max_length=128), ): return cls(email=email, password=password)
Exceptions
Подход к исключениям похож на работу с DTO — они также могут быть определены на разных уровнях, обеспечивая ясность и чёткое разделение обязанностей:На уровне application – ошибки специфичные для конкретного service/use-case
class UserAlreadyExists(AlreadyExists): detail = "User with this data already exists"
На уровне infrastructure – ошибки специфичные для конкретной реализации
class IntegrityError(DatabaseError): """Wraps a DB-API IntegrityError.""" code = "gkpj"
На уровне presentation – ошибки специфичные для контроллеров/презентеров. В нашем случае базовая ошибка универсальна и может быть преобразована в нужный формат через декоратор
@app.exception_handler(AppException) async def app_exception_handler(request: Request, exc: AppException): return JSONResponse( status_code=exc.status_code, content={ "detail": exc.detail, **(exc.extra or {}) } )
Interfaces
Аналогично предыдущим двум пунктам интерфейсы могут быть определены на уровне application, infrastructure, а не только в domain. Всё зависит от задачи, от ответственности, которую они описывают.
Например, если мы знаем, что интерфейс не выйдет за рамки infrastructure, то есть он описывает взаимодействие, ограниченное этим слоем, и будет использован только там, то там ему и место.class IAsyncHttpClient(abc.ABC): """ Interface for an asynchronous HTTP client. This interface defines a standard contract for making HTTP requests asynchronously. It abstracts over specific libraries (e.g. aiohttp, httpx) to allow interchangeable implementations. Methods: get(url: str, **kwargs): Perform an HTTP GET request. post(url: str, **kwargs): Perform an HTTP POST request. put(url: str, **kwargs): Perform an HTTP PUT request. delete(url: str, **kwargs): Perform an HTTP DELETE request. patch(url: str, **kwargs): Perform an HTTP PATCH request. """ @abc.abstractmethod async def get(self, url: str, **kwargs): ... @abc.abstractmethod async def post(self, url: str, **kwargs): ... @abc.abstractmethod async def put(self, url: str, **kwargs): ... @abc.abstractmethod async def delete(self, url: str, **kwargs): ... @abc.abstractmethod async def patch(self, url: str, **kwargs): ...
Модули проекта
Помимо слоёв и принципов чистой архитектуры, проект включает в себя логическую и техническую модульную структуру. В этом разделе я кратко опишу, как организованы основные директории.
Я условно делю модули на общие технические и прикладные функциональные. Первые обеспечивают инфраструктуру, конфигурации и утилиты, вторые реализуют предметную логику.
Общие (технические)
Эти модули не привязаны к какой-то одной предметной области. Они обеспечивают инфраструктурный фундамент проекта.
core
– это базовый модуль приложения. Здесь определяется конфигурация системы, базовая модель данных, базовые типы ошибок, константы, переиспользуемые клиенты (redis, elasticsearch), настройки логгеров.Это изолированный и независимый модуль, на который могут опираться другие.
db
– это модуль конфигурации базы данных, предназначенный для инициализации базы данных, управление сессиями и т.п.Он не является частью чистой архитектуры, но в большинстве FastAPI-проектов его наличие оправдано.
crud
– это вспомогательный модуль. Он реализует генератор CRUD-операций и FastAPI роутеров для них.Да, это компромисс, он явно нарушает принципы чистой архитектуры, так как объединяет реализацию и бизнес-логику. Однако может быть полезен для прототипирования или быстрого создания вспомогательных API. В рамках статьи не описывается, но вы можете самостоятельно с ним ознакомиться.
Пример реализации для вакансий:class VacancyService(CRUDBase, model=orm.VacancyDB): """ Infrastructure-level service for low-level CRUD operations on VacancyDB. """ class VacancyCRUDRouter(CRUDRouter): crud = VacancyService() create_schema = VacancyCreateDTO update_schema = VacancyUpdateDTO read_schema = VacancyReadDTO router = APIRouter()
utils
– это ещё один вспомогательный модуль, который не должен зависеть от других. Содержит утилиты, которые могут использоваться в любом месте проекта, и именно поэтому модуль должен оставаться как можно более независимым.
Прикладные (функциональные)
Эти модули реализуют ключевую бизнес-логику приложения. Они строго структурированы по слоям: domain, application, infrastructure, presentation.
users
– предназначен для управления пользователями. Он содержит минимальную функциональность, обеспечивающую CRUD операции: регистрацию, получение, обновление и удаление информации о пользователях.auth
– отвечает за идентификацию, аутентификацию и авторизацию пользователя в системе. Здесь реализована поддержка JWT (в том числе cookie/header), хранение (Redis), отзыв и автообновление токенов, а также контроль доступа через middlewares и декоратор@access_control
.integrations
– отвечает за взаимодействие с внешними источниками данных. Поскольку весь проект ориентирован на работу с вакансиями, этот модуль реализует адаптеры к API внешних сервисов.vacancies
– отвечает за работу с вакансиями: хранение, отображение, поиск. Содержит все необходимые use-case'ы для обработки вакансий внутри системы.
Далее в статье мы подробно разберём внутреннюю реализацию каждого прикладного модуля. Остальные технические (core
, db
, crud
и т.п.) – будут оставлены за рамками, но вы всегда можете изучить их в исходниках.
Разбор функциональных модулей
Эта глава посвящена прикладным модулям проекта — тем, что реализуют предметную логику. Мы посмотрим, какую роль играет каждый модуль, как он устроен внутри и как взаимодействует с другими.
Для каждого модуля я выберу один характерный use-case, подробно разберу его компоненты на всех архитектурных уровнях, опишу пошаговое выполнение сценария, а также объясню, как это решение соотносится с принципами чистой архитектуры.
Users
Модуль users
отвечает за базовую работу с пользователями системы: регистрацию, чтение, обновление и удаление. Он реализован в соответствии с принципами чистой архитектуры и хорошо демонстрирует, как может выглядеть изолированный модуль с полным набором слоёв.
Разбор процесса регистрации
Рассмотрим, как в этом модуле реализован сценарий регистрации нового пользователя.
async def register_user(
user_data: UserCreateDTO,
pwd_hasher: IPasswordHasher,
uow: IUserUnitOfWork,
) -> User:
"""
Register a new user in the system.
This function hashes the password, creates a new user entity,
saves it to the database, and commits the transaction.
:param user_data: Data transfer object containing user registration details.
:param pwd_hasher: Password hasher
:param uow: Unit of work instance for handling user repository operations.
:return: Newly created user.
"""
user_data = UserCreate(
**user_data.model_dump(mode='json'),
hashed_password=pwd_hasher.hash(user_data.password)
)
async with uow:
new_user = await uow.users.add(user_data)
await uow.commit()
return new_user
Ниже представлена UML схема процесса регистрации и взаимодействия компонентов:

В нём участвуют компоненты всех архитектурных уровней:
Domain:
User
– основная доменная сущность, представляющая пользователяUserCreate
– не сущность (в полном её понимании) и не DTO, скорее команда в каком-то смысле, необходимая для реализации сценария регистрацииUserCreateDTO
– DTO, содержащий данные регистрируемого пользователяIPasswordHasher
— интерфейс, определяющий контракт для хеширования и проверки пароляIUserRepository
,IUserUnitOfWork
— интерфейсы для работы с хранилищем и транзакциями
Application:
register_user
— use-case, реализующий логику регистрации
Infrastructure:
PGUserRepository
— реализация IUserRepository (PostgreSQL).PGUserUnitOfWork
— реализация IUserUnitOfWork (PostgreSQL).BcryptPasswordHasher
— реализация IPasswordHasherUserDB
— SQLAlchemy ORM-модель пользователя
Presentation:
dependencies.py
— связывает интерфейсы с реализациями через Dependsregister
endpoint – точка входа в API, принимает DTO, вызываетregister_user
, возвращаетUserReadDTO
Ниже описан пошаговый процесс исполнения:
Настройка зависимостей
На уровнеpresentation/dependencies.py
мы настраиваем Depends с нужными реализациями.Получение данных
Контроллер получает входные данные в видеUserCreateDTO
— это просто Pydantic-структура, которая валидирует JSON-данные и не содержит логики. Она объявляется на уровне domain/application, заполняется на уровне presentation и передаётся через слои для взаимодействия с use-case не нарушая Dependency Rule.
Примечание: это первый вариант использования DTO.Вызов use-case
Контроллер вызываетregister_user
, передавая DTO и зависимости. Сам use-case не знает, что он работает в контексте FastAPI — он изолирован и зависит только от интерфейсов, сущностей и DTO (в нашем случаеUserCreateDTO
).@user_api_router.post("", response_model=UserReadDTO) async def register( user_data: UserCreateDTO, pwd_hasher: PasswordHasherDep, uow: UserUoWDep ): """ Register a new user. """ return await register_user(user_data, pwd_hasher=pwd_hasher, uow=uow)
Бизнес-логика
Внутри use-case мы взаимодействуем с сущностями, поэтому
UserCreateDTO
преобразуется вUserCreate
, необходимый для регистрации. Для преобразования выполняем требование бизнес-логики - хешируем пароль.После,
UserCreate
передаётся в соответствующий репозиторий через Unit of Work для создания пользователя в базе.Сначала формируется
UserDB
, затем здесь же на уровне репозитория преобразуется в доменную сущностьUser
.Если будут нарушены какие-то требования, например, уникальность email, то будет выброшена ошибка.
Результат
СозданныйUser
может быть преобразован вUserReadDTO
и использован в presentation слое.
Ключевые принципы и замечания:
Зависимости направлены строго внутрь
presentation
->infrastructure
->application
->domain
Поток управления отличается от направления зависимостей
Поток управления начинается на уровне presentation, где происходит сбор зависимостей и вызов use-case.
Затем управление переходит в application, где use-case, используя абстракции, инициирует вызов инфраструктурных реализаций (
PGUserUnitOfWork
,BcryptPasswordHasher
), т.е. поток управления идёт из application в infrastructure, но application не зависит от infrastructure.В промежутках между вызовами infrastructure мы также взаимодействуем с domain, когда создаём
UserCreate
и преобразуемUserDB
вUser
.После поток управления возвращается на уровень presentaion
Use-case изолирован от реализаций
Он оперирует интерфейсами, сущностями и, при необходимости, примитивами или DTO из domain/application слоя. Это обеспечивает независимость и лёгкую подмену компонентов: например, при работе с базой не важно будет это in-memory, файл или что-то другое.Специализированный Value Object
В данном use-case взаимодействуют два объекта:UserCreateDTO
иUserCreate
.UserCreateDTO
представляет собой plain-данные, приходящие извне (например, с формы регистрации). Он включает пароль в открытом виде, проходит валидацию, но не содержит бизнес-логики.UserCreate
создаётся уже внутри use-case и содержит хешированный пароль — это уже часть бизнес-логики, а не просто передача данных.
UserCreate
не является сущностью в смысле чистой архитектуры, так как не обладает идентичностью. При этом его нельзя считать DTO, поскольку он воплощает конкретное бизнес-правило — необходимость хранения безопасного пароля.По сути,
UserCreate
— это специализированный Value Object, отражающий намерение создать пользователя. Его можно рассматривать как аналог команды (Command) из DDD: он содержит нужные данные и указывает на бизнес-действие, которое должно быть выполнено. Такой объект живёт коротко, но играет важную роль в логике use-case.Пересечение границ самой Entity.
Дядя Боб говорит:«Важно, чтобы через границы передавались простые, изолированные структуры данных. Не нужно хитрить и передавать объекты сущностей или записи из базы данных».
Возможно, это и правда очень важно, и не соблюдение этого принципа влечёт ошибки или каскадные изменения в дальнейшем. Но у меня есть аргумент в пользу нарушения этого принципа:
Существует понятие «устойчивого» компонента. Ранее я не давал определение этому термину, но оно довольно важно.
Устойчивость — это «способность сохранять свое состояние при внешних воздействиях». Устойчивость связана с количеством работы, которую требуется проделать, чтобы изменить состояние. Если от какого-то компонента зависят другие, то он считается устойчивым, так как если потребуется его изменить, придётся проделать много работы, чтобы достичь согласованного состояния. Чем меньше зависимостей у компонента, тем легче его изменить и тем менее он устойчив.Внимательный читатель сразу поймёт, что наиболее устойчивыми будут высокоуровневые компоненты, мы захотим менять их реже всего. Поэтому возможность передать
UserCreate
через границу в репозиторий и получить, опять же, через границу самUser
, а не использовать какие-то DTO для этого, мне не кажется ошибкой.
Аналогично этой ситуации, мне не кажется ошибкой вернуть из пользовательского сценария сущностьUser
, а неUserReadDTO
, который будет отображен на уровне presentation. Сам presentation сможет определить, как именно он хочет отображатьUser
и маппить его соответствующим образом.User
мы захотим менять реже всего, а на счётUserReadDTO
я не уверен. В дальнейшем мы рассмотрим ещё один пример, касающийся этой темы.
Главное помнить про Dependency Rule и не нарушать его. В прочем, я думаю, эта тема может породить не мало обсуждений.
Сравнительный анализ
Dependency Rule ✅
Зависимости направлены строго внутрь, в сторону высокоуровневых политик:
presentation
->infrastructure
->application
->domain
SPR/CCP/SoC/Low coupling/High cohesion ✅
Модуль прост, но структурирован. При этом каждый его компонент решает конкретные задачи, и взаимодействуют между собой в строго определенных границах.Но даже в настолько простом модуле возможны улучшения: структуру можно углубить, вынеся
dtos
,entities
,dependencies
в отдельные подкаталоги, аналогично тому, как разбитыuse_cases
иinterfaces
. Также можно выделитьvalue_objects
изentities
. В текущем виде это не критично (особенно для учебного проекта), но при масштабировании модуль может стать менее удобным для поддержки.OCP ✅
Наличие интерфейсов позволяет расширять поведение (IPasswordHasher
,IUserRepository
) без изменения логики use-case'а.LSP ✅
Контракты соблюдены: реализации интерфейсов полностью совместимы.BcryptPasswordHasher
,PGUserRepository
,PGUserUnitOfWork
можно безопасно подставить вместо абстракций — ничего не сломается.ISP/CRP ✅
Интерфейсы узкие и сфокусированы — каждый описывает строго ограниченный аспект поведения.
Каждый use-case зависит только от нужных абстракций — нет перегрузки зависимостями.DIP ✅
Use-case зависит от интерфейсов, а не от конкретных реализаций. Инфраструктура «подключается» в presentation слой с помощью DI.ADP ✅
Циклические зависимости отсутствуют.SDP/SAP ✅
Эти два принципа говорят «Зависимости должны быть направлены в сторону устойчивости» и «Устойчивость компонента пропорциональна его абстрактности», при этом вместе они соответствуют принципу инверсии зависимостей (DIP) для компонентов, который мы выполняем, соответственно и эти принципы соблюдены.
Auth
Модуль auth
отвечает за идентификацию, аутентификацию, авторизацию пользователей. В текущей реализации используется механизм JSON Web Tokens (JWT) с алгоритмом ES256.
Модуль обладает следующими возможностями:
Гибкое управление токенами: система поддерживает хранение, отзыв и валидацию access и refresh токенов с возможностью выбора стратегии хранения (например, Redis)
Гибкость транспортного уровня: токены могут передаваться через различные каналы — в заголовках (Authorization) или в cookie, либо сразу в обоих, в зависимости от требований
Безопасность и контроль доступа: предусмотрены средства ограничения и автоматизации — декоратор и middleware
Вспомогательные компоненты:
Декоратор
@access_control
, который позволяет ограничивать доступ к маршрутам в зависимости от роли пользователя (например, открытые маршруты, маршруты для авторизованных пользователей или суперпользователей).SecurityMiddleware
— ограничивает/разрешает доступ к маршрутам. Например, можно разрешить доступ к /api/, /admin/ только для superuserJWTRefreshMiddleware
— автоматически обновляет access_token, если он истёк, и у пользователя есть валидный refresh_tokenAuthenticationMiddleware
— добавляет информацию об аутентифицированном пользователе вrequest.state.user
или помечает его какAnonymousUser
Разбор процесса входа в систему
Рассмотрим, как реализован сценарий authenticate
— входа пользователя по email и паролю.
async def authenticate(
email: str,
password: str,
pwd_hasher: IPasswordHasher,
uow: IUserUnitOfWork,
auth: ITokenAuth
) -> User:
"""
Authenticates a user based on provided credentials.
Verifies the user's email and password combination, and if valid, sets access and refresh tokens.
:param email: User email.
:param password: User password.
:param pwd_hasher: Password hasher
:param uow: Unit of work to access user repository.
:param auth: JWT authentication service for setting tokens.
:raises InvalidCredentials: If the password is incorrect.
:return: User instance.
"""
async with uow:
user = await uow.users.get_by_email(email)
if not pwd_hasher.verify(password, user.hashed_password):
raise InvalidCredentials()
await auth.set_tokens(user)
return user
Ниже представлена UML схема процесса аутентификации и взаимодействия компонентов:

В нём задействованы все архитектурные уровни.
Domain
TokenType
— value object, представляющий тип токена (access / refresh)TokenData
– value object содержащий незакодированные данные токенаUser
– доменная сущность пользователя (импортируется из модуля users)AnonymousUser
– value object не аутентифицированного пользователяITokenProvider
– интерфейс, описывающий основной контракт для создания токенов с возможностью читать данные из закодированного токенаITokenStorage
– интерфейс, описывающий контракт для сохранения, отзыва и проверки токенаITokenAuth
– интерфейс, описывающий контракт авторизации: установку, удаление, чтение токенов
Application
IUserUnitOfWork
– интерфейс UoW (импортируется из domain-слоя модуля users)IPasswordHasher
– интерфейс, описывающий контракт операций хеширования и проверки хеша пароля (импортируется из domain слоя модуля users)authenticate
– use-case, реализующий логику аутентификации пользователя
Infrastructure
JWTProvider
– реализацияITokenProvider
для JWT (ES256)IAuthTransport
– интерфейс, описывающий контракт установки, удаления и получения токена для конкретного транспортаCookieTransport
,HeaderTransport
– конкретные реализацииIAuthTransport
JWTAuth
– реализацияITokenAuth
, поддерживающая множественные транспортыBcryptPasswordHasher
– реализацияIPasswordHasher
RedisTokenStorage
– реализацияITokenStorage
Presentation
dependencies.py
— связывает интерфейсы с реализациями с помощью DI (FastAPI Depends).AuthUserDTO
– DTO, содержащий данные необходимые для аутентификации.login
endpoint – получает DTO, использует DI, выполняетauthenticate
use-case с подставленными зависимостями. Возвращает результат или ошибку.
Ниже описан пошаговый процесс исполнения:
Настройка зависимостей
На уровнеpresentation/dependencies.py
мы конфигурируем зависимости через Depends, связывая интерфейсы (IPasswordHasher
,ITokenProvider
,ITokenAuth
,IUserUnitOfWork
и др.) с конкретными реализациями. Это позволяет инвертировать зависимости и сохранять слойapplication
изолированным от инфраструктуры.Получение данных от пользователя
Контроллер (login
endpoint) принимает входные данные в форматеAuthUserDTO
. Эта структура ограничена слоем presentation (не выходит за его границы) — она служит для валидации входных данных и извлечения примитивов (email и password), необходимых для вызова use-case.
Примечание: это второй вариант использования DTO. Но я бы предпочёл работать с первым, передавая весь DTO в use-case, текущий вариант привожу для демонстрации.Вызов use-case
Контроллер вызываетauthenticate
, передавая примитивы (в нашем случае это email и password, которые мы получаем из DTO) и зависимости. Use-case не знает о FastAPI или HTTP-контексте — он изолирован и работает исключительно с абстракциями (IUserUnitOfWork
,IPasswordHasher
,ITokenAuth
), сущностями и примитивами.@auth_api_router.post("/login") async def login( credentials: AuthUserDTO, pwd_hasher: PasswordHasherDep, uow: UserUoWDep, auth: TokenAuthDep ): """ Authenticate user and issue JWT tokens. """ await authenticate(credentials.email, credentials.password, pwd_hasher, uow, auth) return {"detail": "Tokens set"}
Бизнес-логика
Получаем пользователя по email через реализацию
IUserUnitOfWork
. Аналогично регистрации, здесь будет внутренний маппингUserDB
->User
.Если пользователь не найден — выбрасываем ошибку
Проверяем пароль: сравниваем хеш из хранилища с хешем от пользователя через реализацию
IPasswordHasher
Если пароль не совпадает — выбрасываем ошибку
Генерируем access и refresh токены, устанавливаем их в response (через
ITokenAuth
) и при необходимости сохраняем (черезITokenStorage
).
Результат:
Возвращаем сообщение об успешной аутентификации или соответствующую ошибку (например,InvalidCredentials
,UserNotFound
).
Ключевые принципы и замечания
Зависимости направлены строго внутрь
presentation
->infrastructure
->application
->domain
Поток управления отличается от направления зависимостей
Поток управления начинается на уровне presentation, где происходит сбор зависимостей и вызов use-case.
Use-case работает на уровне application и, опираясь на интерфейсы, вызывает реализации, которые находятся в infrastructure:
BcryptPasswordHasher
,PGUserUnitOfWork
,JWTAuth
Внутри
JWTAuth
происходит каскадный вызов других компонентов:JWTProvider
,RedisTokenStorage
, а также один или несколько транспортов (CookieTransport
,HeaderTransport
), соответствующих интерфейсуIAuthTransport
В процессе используются сущности/value objects из domain — например,
User
,AnonymousUser
,TokenType
Затем результат возвращается обратно в presentation
Use-case изолирован от реализаций
Он оперирует только примитивами, сущностями и абстракциями. Благодаря этому бизнес-логика остаётся независимой от технологий, деталей реализации и даже контекста исполнения.Текущий auth – учебный пример
Текущая реализация черезJWTAuth
— это не продакшн-решение, а учебный пример, демонстрирующий архитектурные принципы. Она не ставит цель покрыть все возможные сценарии использования. В реальных проектах вряд ли вы захотите одновременно использовать header и cookie-транспорт, а также, возможно, вам не подойдёт подход с хранением только одного access и одного refresh токена на пользователя. Скорее всего, вы обнаружите и другие ограничения.
Тем не менее, это осмысленный и полезный пример: он демонстрирует взаимодействие между слоями, использование инверсии зависимостей и реализацию сложной логики через гибкие интерфейсы. Вы легко сможете расширить или переопределить поведение под нужды своего проекта. Или попробовать самостоятельно с нуля реализовать что-то своё, не прибегая к сторонним решениям.@access_control и middlewares
Я сознательно не стал разбирать реализацию декоратора@access_control
иmiddlewares
— они выходят за рамки темы статьи. Тем не менее, они являются частью приложения и вполне заслуживают внимания. Рекомендую изучить их самостоятельно — принципы, заложенные в их реализации, легко масштабируются и применимы даже в проектах без явной архитектурной сегментации.
Сравнительный анализ
Dependency Rule ✅
Зависимости направлены строго внутрь:presentation
->infrastructure
->application
->domain
Все конкретные реализации (включая работу с токенами, шифрованием, хранилищем) располагаются в infrastructure и внедряются через зависимости.
Use-caseauthenticate
зависит только от интерфейсов и доменных сущностей.SPR/CCP/SoC/Low coupling/High cohesion ✅
Компоненты чётко разделены по зонам ответственности: генерация токенов, хранилище, транспорт, провайдер, авторизация. Каждая часть изолирована и легко заменяется.OCP ✅
Интерфейсы позволяют расширять модуль авторизации без изменения существующей логики. Например, можно внедрить новый транспорт или изменить реализациюITokenStorage
без правки use-case или зависимых компонентов.LSP ✅
Контракты соблюдены: все реализации интерфейсов соответствуют ожидаемому поведению.BcryptPasswordHasher
,JWTProvider
,RedisTokenStorage
,CookieTransport
,HeaderTransport
,JWTAuth
можно свободно подставлять без нарушения логики вызова.ISP/CRP ✅
Интерфейсы узкие, разделены по задачам:
ITokenProvider
— только генерация и расшифровка токенов,ITokenStorage
— только операции хранения/отзыва,IAuthTransport
— только работа с HTTP-транспортом.Use-case зависит только от нужных абстракций — нет перегрузки зависимостями.
DIP ✅
Use-case зависит только от абстракций, все реализации подключаются через слой presentation. Взаимодействие идёт через контракты (ITokenAuth
,ITokenStorage
и т.д.), а не через конкретные реализации.ADP ✅
На первый взгляд — всё корректно, циклов нет. Однако, есть взаимные пересечения с users (чуть позже мы это обсудим):auth
зависит отUser
(для работы с аутентифицированным пользователем)users
может импортировать@access_control
,TokenAuthDep
.
SDP/SAP ✅
Все зависимости направлены в сторону более устойчивых компонентов — от конкретных реализаций к абстрактным интерфейсам.Domain слой остаётся стабильным и абстрактным, инфраструктура — изменяемой и конкретной. Такой баланс отвечает требованиям надёжной, расширяемой архитектуры.
Vacancies
Этот модуль отвечает за основную бизнес-задачу приложения — сбор, хранение и поиск по вакансиям. Используя интеграции с внешними агрегаторами, система регулярно собирает информацию о вакансиях по заданным критериям.
Модуль разделяет ответственность за хранение данных между двумя типами баз данных:
PostgreSQL - реляционная база данных используется для хранения основной информации о вакансиях, а также исходных данных, полученных от внешних сервисов. Это обеспечивает надёжность, согласованность данных и возможность повторной обработки.
Elasticsearch служит хранилищем, предназначенным для эффективного поиска и агрегации. Благодаря ему мы можем быстро выполнять сложные поисковые запросы, фильтрацию и сортировку по вакансиям, а также визуализировать данные для аналитических задач (через Kibana).
Таким образом, модуль обеспечивает:
Периодический автоматический сбор данных с внешних источников (через интеграции и фоновые задачи Celery).
Хранение и нормализацию данных о вакансиях с сохранением исходного формата.
Гибкий и быстрый поиск по вакансиям с использованием Elasticsearch.
Возможность восстановления и повторной обработки данных, используя первоначальные структуры из реляционной БД.
Разбор процесса сбора вакансий
Рассмотрим, как устроен сценарий collect_vacancies
— основной use-case, отвечающий за сбор вакансий с внешних агрегаторов и сохранение их в систему.
async def collect_vacancies(
search_params: TSearchParams,
client: IVacancySourceClient,
uow: IVacancyUnitOfWork,
search_repo: IVacancySearchRepository
) -> dict[str, BulkResult]:
"""
Collect vacancies from the external API and save them to both the database and search storage.
:param search_params: Search parameters to pass to the external API.
:param client: External API client implementing IVacancySourceClient.
:param uow: Unit of Work to manage transactional operations with the database.
:param search_repo: Search engine repository (e.g. Elasticsearch) implementing IVacancySearchRepository.
:return: Dictionary containing bulk operation results for database and search storage.
"""
vacancies: list[Vacancy] = await client.get_vacancies(search_params)
db_result = await collect_vacancies_to_db(vacancies, uow)
search_db_result = await collect_vacancies_to_search(vacancies, search_repo)
statistics = {
"database": db_result,
"search_db": search_db_result
}
return statistics
async def collect_vacancies_to_db(
vacancies: list[Vacancy],
uow: IVacancyUnitOfWork
) -> BulkResult:
"""
Store vacancies in the relational database using the given Unit of Work.
:param vacancies: List of domain vacancy models to be saved.
:param uow: Unit of Work to manage the transactional context for database operations.
:return: Result of bulk insert/update operation.
"""
async with uow:
result = await uow.vacancies.bulk_add_or_update(vacancies)
await uow.commit()
return result
async def collect_vacancies_to_search(
vacancies: list[Vacancy],
search_repo: IVacancySearchRepository
) -> BulkResult:
"""
Store vacancies in the search database (e.g., Elasticsearch).
:param vacancies: List of domain vacancy models to be indexed.
:param search_repo: Repository for managing search engine operations.
:return: Result of bulk indexing operation.
"""
result = await search_repo.bulk_add(vacancies)
return result
Ниже представлена UML схема процесса сбора вакансий из агрегатора и взаимодействия компонентов:

В нём задействованы все архитектурные уровни.
Domain
Vacancy
– основная доменная сущность, представляющая вакансиюVacancySource
– value object, обозначающий источник вакансии (например, HeadHunter).MetroStation
– часть данных о вакансии, содержащая данные о метроAddress
– часть данных о вакансии, содержащая данные о адресеArea
– часть данных о вакансии, содержащая данные о зоне поискаEmployer
– часть данных о вакансии, содержащая данные о работодателеEmployment
– часть данных о вакансии, содержащая данные о типе трудоустройстваExperience
– часть данных о вакансии, содержащая данные о необходимом опыте соискателяSalary
– часть данных о вакансии, содержащая данные о предлагаемой заработной платеSchedule
– часть данных о вакансии, содержащая данные о графике работыType
– часть данных о вакансии, содержащая данные о типа вакансии (открытая/закрытая)ProfessionalRole
– часть данных о вакансии, содержащая данные о должностиIVacancyRepository
– интерфейс взаимодействия с хранилищем вакансийIVacancyUnitOfWork
– интерфейс UoW для вакансийIVacancySearchRepository
– интерфейс взаимодействия с поисковым хранилищем вакансий
Application
collect_vacancies
– use-case для сбора вакансий с агрегаторов и сохранения их в системуcollect_vacancies_to_db
– use-case для сохранения вакансий в базу данныхcollect_vacancies_to_search
– use-case для сохранения вакансий в базу данных поиска (ES)BulkResult
– стандартный результат по bulk-операции (импортируется изcore.domain.entities
)TSearchParams
– обобщенный тип параметров поиска вакансий, зависящий от конкретного агрегатораIVacancySourceClient
– интерфейс взаимодействия с агрегаторами вакансийVacancyDomainToDTOMapper
– маппер данных для преобразования сущностей в DTO
Infrastructure
VacancyDB
– SQLAlchemy ORM-модель вакансииVacancyDomainToElasticMapper
– маппер для преобразования сущности Vacancy в формат, соответствующий индексу ElasticSearchPGVacancyRepository
– реализацияIVacancyRepository
(PostgreSQL)PGVacancyUnitOfWork
– реализацияIVacancyUnitOfWork
(PostgreSQL)ESVacancySearchRepository
– реализацияIVacancySearchRepository
(ElasticSearch)
Presentation
dependencies.py
— связывает интерфейсы с реализациями с помощью DI (FastAPI Depends)collect_vacancies_task
– периодическая фоновая задача, запускающая use-casecollect_vacancies
с заранее заданными параметрами поиска (например,HHVacancySearchParams
)
Ниже описан пошаговый процесс исполнения:
Настройка зависимостей
В файлеpresentation/dependencies.py
мы связываем интерфейсы с реализациями с помощью Depends, указываем параметры поиска и определяем адаптеры.Например:
IVacancyUnitOfWork
—PGVacancyUnitOfWork
IVacancySearchRepository
—ESVacancySearchRepository
Вызов use-case
Задачаcollect_vacancies_task
запускается периодически с помощью Celery. Все зависимости импортируются и подставляются на уровне presentation согласно контрактам, определенным в use-case’e.@shared_task def collect_vacancies_task() -> dict: """ Celery task to collect vacancies from HeadHunter. This background task performs the following: - Queries HeadHunter API with specific search parameters. - Saves data in both relational DB and ElasticSearch. - Returns a summary of processed results. :return: Dictionary containing the number of processed items for each storage layer. """ # Example search parameters for Python backend developer in Moscow python_backend_params = HHVacancySearchParams( page=0, per_page=1, text='Backend python developer', area=['1'], # Moscow, order_by='publication_time' ) result = async_to_sync(collect_vacancies)( python_backend_params, get_headhunter_adapter(), get_vacancy_uow(), get_vacancy_search_repo() ) return {key: value.model_dump(mode="json") for key, value in result.items()}
Бизнес-логика
Выполняем сбор вакансий из агрегатора и получаем их в формате
Vacancy
, нас не интересует как именно это происходит, мы знаем только о контракте. Мы передаём на вход нужный адаптер и соответствующие ему параметры, напримерHHVacancySearchParams
иHeadHunterAdapter
.Полученные сущности передаются в use-case
collect_vacancies_to_db
. На стороне этого use-case происходит преобразование вакансий с помощьюVacancyDomainToDTOMapper
, т.к. база данных работает с конкретной DTO-структурой.Далее тот же список сущностей
Vacancy
передаётся в use-casecollect_vacancies_to_search
. Здесь маппинг происходит с помощьюVacancyDomainToElasticMapper
— он преобразует универсальную доменную модель в структуру, совместимую с индексом ElasticSearch.Результатом сохранения данных для use-case’ов будут
BulkResult
Примечание: часть, посвященная сбору вакансий из агрегаторов, будет рассмотрена подробнее в следующей главе.
Результат
На выходе — словарь, содержащий статистику о количестве успешно сохранённых, обновлённых и пропущенных вакансий для каждой подсистемы хранения.
Ключевые принципы и замечания
Зависимости направлены строго внутрь
presentation
->infrastructure
->application
->domain
Поток управления отличается от направления зависимостей
Поток управления начинается с presentation слоя: здесь происходит сбор зависимостей и вызов use-case.
Далее, на уровне application, use-case работает через интерфейсы — вызывает
PGVacancyUnitOfWork
,ESVacancySearchRepository
,HeadHunterAdapter
и другие. Несмотря на то, что управление переходит в infrastructure, application от неё не зависит.Между вызовами компонентов infrastructure мы взаимодействуем с сущностями из domain (например,
Vacancy
,Area
,Employer
)И после мы, как всегда, возвращаем результат обратно на уровень presentation.
Use-case изолирован от реализаций
Он оперирует интерфейсами, сущностями, TypeVar. Все реализации передаются извне, и мы никогда от них не зависим. Это позволяет полностью изолировать бизнес-логику от технических деталей и соблюдать принцип инверсии зависимостей (DIP).Маппинг
Мапперы работают в пределах инфраструктурного слоя: они вызываются внутри use-case'ов, но скрыты за интерфейсами репозиториев. Таким образом, сами use-case'ы остаются независимыми от формата хранения или схемы индексирования, а всё преобразование (в DTO, формат Elasticsearch и др.) реализуется на уровне хранилищ. Это сохраняет application слой чистым и изолированным от технических деталей.Различия в структуре Vacancy, VacancyDB, HHVacancy и индекса в ElasticSearch Хочется разъяснить причину, по которой доменная сущность
Vacancy
отличается от модели в базеVacancyDB
, от схемы данных, которую мы получаем от агрегаторов (например,HHVacancy
), а также от индекса в ElasticSearch.Vacancy (Доменная сущность) — это основное представление вакансии в системе. Она отражает наше внутреннее понимание предметной области. Я постарался включить в неё максимум значимых полей, независимо от источника данных или способа хранения. Именно с
Vacancy
работают use-case’ы, поэтому её структура должна быть наиболее полной и удобной для бизнес-логики.
VacancyDB (Модель базы данных) на текущий момент не требуется в полном объёме, мне не хотелось тратить время на создание всех связей и выделять отдельные таблицы под адрес, работодателя и прочее. Поэтому я храню лишь основные поля, которые действительно могут потребоваться, а также исходную структуру, которую мы получили от агрегатора, чтобы при необходимости её всегда можно было восстановить и проанализировать.
HHVacancy (Схема от агрегаторов) от нас не зависит, мы описываем её структуру в соответствии с получаемыми данным, которые всегда преобразуются вVacancy
, чтобы стандартизировать внутреннее представление.
Индекс в ElasticSearch – это отдельный формат, определённый под нужды поиска и агрегации. Здесь структура подбирается с учётом производительности, семантики поиска и удобства отображения в Kibana.
Таким образом,Vacancy
становится центральной сущностью, от которой исходят остальные представления. Каждое из них подгоняется под свою задачу: хранение, схема агрегатора, полнотекстовый поиск. Это позволяет не зависеть от ограничений конкретного хранилища и поддерживать единое ядро бизнес-логики.
Не буду врать, на разработку текущий доменной модели я не тратил много времени, она почти копия схемы из HeadHunter, так как она показалось мне довольно подробной.
Сравнительный анализ
Dependency Rule ✅
Зависимости направлены строго внутрь:presentation
->infrastructure
->application
->domain
Все реализации хранятся в infrastructure и подставляются вручную на уровне задач (например, Celery). Use-case'ы опираются исключительно на интерфейсы и доменные сущности, что позволяет изолировать бизнес-логику от технических реализаций.
SPR/CCP/SoC/Low coupling/High cohesion ✅
Компоненты чётко разделены: бизнес логика, основное хранилище, поисковое хранилище, взаимодействие с внешними API.
Каждая часть отвечает за свою зону ответственности и хорошо изолирована.OCP ✅
Интерфейсы (IVacancyRepository
,IVacancySearchRepository
) позволяют добавлять новые хранилища или поисковые движки без изменения существующей логики.LSP ✅
Контракты соблюдены: все реализации интерфейсов соответствуют ожидаемому поведению.PGVacancyUnitOfWork
,ESVacancySearchRepository
можно свободно подставлять без нарушения логики вызова.ISP/CRP ✅
Интерфейсы узкие, сфокусированы на конкретных аспектах (поиск, источник, хранилище).
Use-case'ы не перегружены — зависят только от необходимых абстракций.DIP ✅
Use-casecollect_vacancies
опирается только на абстракции. Все конкретные реализации (например,HeadHunterAdapter
,PGVacancyUnitOfWork
,ESVacancySearchRepository
) внедряются извне, что упрощает тестирование и замену компонентов.ADP ✅
Модуль не создаёт циклов. Связи с другими модулями (например, integrations) построены через контракты и не нарушают иерархию.SDP/SAP ✅
Абстракции определены в domain слое. Новые источники вакансий или изменения хранилищ не затрагивают бизнес-логику — достаточно реализовать нужный интерфейс.
Integrations
Модуль integrations отвечает за реализацию интеграций с внешними сервисами, в первую очередь — с API агрегаторов вакансий. Его основная цель — изолировать логику взаимодействия с внешним миром.
Модуль вынесен отдельно от vacancies
, несмотря на тесную связь между ними, чтобы:
Разделить зоны ответственности
Упростить масштабирование и добавление новых интеграций в будущем
Здесь определены:
Реализации конкретных адаптеров взаимодействия с внешними API (например,
HeadHunterAdapter
)IAsyncHttpClient
и его реализацияAiohttpClient
для выполнения асинхронных HTTP запросовБазовый
APIClientService
иAuthMixin
, абстрагирующий общие аспекты HTTP-взаимодействия (авторизация, заголовки, формирование запросов и т.д.), чтобы упростить и унифицировать реализацию адаптеров
Разбор процесса получения вакансий от внешнего источника
Этот процесс затрагивает компоненты двух модулей: vacancies
и integrations
. Он построен на разделении абстракций и конкретных реализаций — интерфейсы и контракты описаны в vacancies
, а конкретные адаптеры взаимодействия с внешним API — на уровне инфраструктуры в integrations
.
Дублирую схему взаимодействия процесса сбора вакансий из агрегатора:

Этот модуль отличается от остальных, он не реализует все слои. На текущий момент он содержит только:
Infrastructure
Импортируемые из domain слоя vacancies:
Vacancy
— доменная сущность вакансийTSearchParams
,TVacancy
,TVacancyResponse
— обобщённые типы (TypeVar) для параметров поиска, структуры вакансий и ответов агрегаторовIVacancySourceClient
— интерфейс-контракт для клиента получения вакансий от внешнего источникаHTTP-инфраструктура и авторизация
IAsyncHttpClient
— интерфейс, описывающий стандартные операции взаимодействия по HTTP.AiohttpClient
— реализация клиента aiohttp для выполнения асинхронных запросов в соответствии с контрактомIAsyncHttpClient
AuthType
— value object, описывающий тип авторизацииAuthMixin
— Mixin для установки нужных заголовков авторизацииAPIClientService
— сервис, унифицирующий взаимодействие с внешними API, не зависящий от конкретных реализаций HTTP клиентаСхемы запросов и ответов HeadHunter:
Запросы:
HHAccessUserTokenParams
,HHRefreshTokenParams
,HHAccessApplicationTokenParams
,HHVacancySearchParams
Ответы:
HHVacancyResponse
,HHVacancy
,HHProfessionalRole
,HHType
,HHSnippet
,HHSchedule
,HHSalary
,HHExperience
,HHEmployment
,HHEmployer
,HHLogoUrl
,HHArea
,HHAddress
,HHMetroStation
,HHArgument
,HHClusterGroup
Адаптеры и преобразователи:
HeadHunterAdapter
— адаптер, реализующийIVacancySourceClient
, инкапсулирует бизнес-логику взаимодействия с APIHHVacancyToDomainMapper
— маппер вакансий HeadHunter в доменную модельVacancyOriginalMapper
— маппер, который использует оригинальные данные ответа (dict) и восстанавливает схему данных агрегатораVacancyExternalToDomainMapper
— маппер внешней схемы данных вакансий в доменную сущность
Presentation
dependencies.py
— связывает инфраструктурные реализации с интерфейсами, предоставляет их в другие модули через DI или просто импорт.
Ниже описан пошаговый процесс исполнения:
В модуле integrations
не описан отдельный use-case — его задача лишь предоставить адаптер для получения вакансий, который затем используется в модуле vacancies
. Ниже рассмотрим ту часть процесса, которая относится именно к integrations
(та часть, которую я не описал в use-case collect_vacancies
).
class HeadHunterAdapter(
APIClientService,
IVacancySourceClient[HHVacancySearchParams, HHVacancy, HHVacancyResponse]
):
"""
Adapter for interacting with the HeadHunter public API.
This adapter implements the `IVacancySourceClient` interface and provides
an abstraction over key endpoints such as:
- Access token retrieval
- Application information
- Vacancy search (single-page or full pagination)
Args:
client (IAsyncHttpClient): An async HTTP client (default: AiohttpClient).
source_url (str): Base API URL (default: "https://api.hh.ru").
auth_type (AuthType): Authorization type to use (default: Bearer).
token (str | None): Optional token to include in requests.
"""
def __init__(
self,
client: IAsyncHttpClient = AiohttpClient,
source_url: str = 'https://api.hh.ru',
auth_type: AuthType = AuthType.BEARER_TOKEN,
token: str | None = os.environ.get("HEADHUNTER_TOKEN")
):
super(HeadHunterAdapter, self).__init__(source_url, client=client, auth_type=auth_type, token=token)
async def get_access_token(self) -> dict:
"""
Retrieve an application-level access token from HeadHunter.
:return dict: Token information including `access_token`, `expires_in`, etc.
"""
headers = {"Content-Type": "application/x-www-form-urlencoded"}
request_data = HHAccessApplicationTokenParams(
client_id=os.environ.get("HEADHUNTER_CLIENT_ID"),
client_secret=os.environ.get("HEADHUNTER_CLIENT_SECRET"),
)
response = await self.request(
method="POST", endpoint="/token", headers=headers,
params=request_data.model_dump(mode="json")
)
return await response.json()
async def get_application_info(self) -> dict:
"""
Fetch information about the currently authorized HeadHunter application.
Requires an access token.
:return dict: Metadata about the registered application.
"""
headers = {"HH-User-Agent": f"JobScope/1.0 {settings.EMAIL_FROM}"}
response = await self.request(method='GET', endpoint='/me', headers=headers)
return await response.json()
async def get_vacancies(
self, search_params: HHVacancySearchParams
) -> list[Vacancy]:
"""
Perform a single-page vacancy search using the given query parameters.
:param search_params: Parameters to filter the search.
:return list[Vacancy]: A list of matching vacancies.
"""
vacancy_response = await self._get_vacancy_response(search_params)
return VacancyExternalToDomainMapper().map(vacancy_response.items)
async def get_all_vacancies(
self, search_params: HHVacancySearchParams
) -> list[Vacancy]:
"""
Retrieve all vacancies matching the provided search parameters.
This method handles pagination automatically and is useful when a full
data set is needed.
Note that the HeadHunter API may have internal
pagination limits (e.g., 2000 records), which can affect the result.
:param search_params: Query parameters for the search.
:return list[Vacancy]: A complete list of matching vacancies.
"""
# Get the total number of vacancies
search_params.page = 0
search_params.per_page = 1
vacancy_response = await self._get_vacancy_response(search_params)
# Calculate the number of pages
max_pages = math.ceil(vacancy_response.found / 100)
# Fetch all pages separately
vacancies: list[HHVacancy] = []
search_params.per_page = 100
for page_num in range(max_pages):
search_params.page = page_num
_vacancy_response = await self._get_vacancy_response(search_params)
if _vacancy_response.items:
vacancies.extend(_vacancy_response.items)
# Added a delay just in case, since the API rate limits are unknown and I want to avoid spamming.
await asyncio.sleep(1)
return VacancyExternalToDomainMapper().map(vacancies)
async def _get_vacancy_response(self, search_params: HHVacancySearchParams) -> HHVacancyResponse:
"""
Internal helper to retrieve the full vacancy response payload from the API.
This includes pagination metadata, cluster information, and the list of
vacancy items, all wrapped in a validated `HHVacancyResponse`.
:param search_params: Query parameters for the request.
:return HHVacancyResponse: Validated response with vacancy items and metadata.
"""
headers = {"HH-User-Agent": f"JobScope/1.0 {settings.EMAIL_FROM}"}
response = await self.request(
method='GET', endpoint='/vacancies', headers=headers,
params=search_params.model_dump(mode="json", exclude_none=True, exclude_unset=True)
)
result = await response.json()
return HHVacancyResponse.model_validate(result)
Настройка зависимостей
На уровнеpresentation/dependencies.py
мы конфигурируем зависимости:Выбираем адаптер для взаимодействия с конкретным сервисом — сейчас это
HeadHunterAdapter
, реализующий интерфейсIVacancySourceClient
.Указываем параметры поиска — например, через
HHVacancySearchParams
, где задаются ключевые слова, регион, опыт и другие критерии.При необходимости можно заменить реализацию клиента HTTP — по умолчанию используется
AiohttpClient
, реализующийIAsyncHttpClient
.
Так как в системе пока реализована только одна интеграция, дополнительной прослойки выбора между адаптерами нет — параметры и клиент задаются напрямую.
Вызов адаптера
Вызов адаптера будет происходить через use-case, в котором определен лишьIVacancySourceClient
, контракт которого реализует адаптер.С переданными параметрами (
HHVacancySearchParams
) будет отправлен запрос на соответствующее API.Ответ API преобразуется в соответствующую схему данных (
HHVacancyResponse
,HHVacancy
и т.д.) для этого адаптера, которую мы определили в системе.Затем данные преобразуются в доменную сущность
Vacancy
при помощи маппера (HHVacancyToDomainMapper
).
Примечание: это третий вариант использования DTO.Возвращаем результат как список доменных сущностей
list[Vacancy]
.
Ключевые принципы и замечания
Использование Generic и TypeVar:
Стоит обратить внимание на использование обобщённых типов (Generics, TypeVar).
Поскольку мы заранее не знаем, какие именно схемы данных будут у разных агрегаторов, необходимо абстрагироваться от конкретных реализаций:Параметры запроса (
TSearchParams
)Схема данных вакансии у агрегатора (
TVacancy
)Структура ответа на запрос от агрегатора (
TVacancyResponse
).
Это позволяет выстроить единый контракт взаимодействия (через
IVacancySourceClient
), независимо от того, с каким API мы работаем.Важно: мы не можем объявлять схемы
HHVacancy
илиHHVacancyResponse
в domain/application, так как они представляют внешнюю структуру и находятся вне зоны нашего контроля. Но у нас должен быть способ работать с ними, поэтому мы объявляем мапперы на уровне инфраструктуры и преобразуем такие схемы в наши доменные сущности.Веб — это деталь
Несмотря на то, чтоAPIClientService
практически не зависит от внешних библиотек, а также поддерживает работу с различными реализациями http клиентов, удовлетворяющих контрактуIAsyncHttpClient
, разместить его на уровне application мы не можем, также как мы не можем разместить сам интерфейсIAsyncHttpClient
на уровне application.Web — это деталь, а значит это часть инфраструктуры. Поэтому именно на этом уровне должны быть определены интерфейсы, сервисы и реализации, связанные с ним.
Schemas = DTO?
Хотелось бы отметить название пакета для схем данных под запросы и ответы внешних API. По своей сути они могут называться DTOs. Мы получаем эти данные извне и должны использовать где-то в системе.
Однако для себя я провёл семантическую границу, отличающую schemas от DTO.Для меня DTO — это не просто те данные, которые будут предназначены для транспортировки информации между слоями или компонентами системы. Мы контролируем их структуру, которая так или иначе опирается на нашу бизнес-логику.
Пример:VacancyCreateDTO
подстраивается под структуру данных в базе и его удобно использовать для создания новых записей. При этом он может не соответствовать напрямую ниVacancy
, ниHHVacancy
.Schemas же наоборот представляют собой структуру данных, которой мы должны соответствовать. То есть мы должны подстраиваться под эти данные. Скорее всего они будут определены внешней системой, а мы просто фиксируем её схему и используем в своих целях.
Пример:HHVacancySearchParams
,HHVacancy
— всё это внешние схемы данных, которые будут меняться независимо от нас.
В теории их можно было бы назвать External и Internal DTO.
Схемы, как и DTO, полезно помечать суффиксами или префиксами, чтобы однозначно определять их границы. Например, видя в коде
HHVacancy
мы сразу понимаем, что это внешняя схема данных, и она не должна использоваться в application/domain слое.Компоненты с суффиксом DTO, такие как
VacancyCreateDTO
, скорее всего могут быть использованы на любом уровне. Например, если нам потребуется реализовать api-endpoint для создания вакансий, то скорее всего именно этот DTO будет определять входные данные в presentation слое и соответствующем use-case'е в application слое.
Сравнительный анализ
Dependency Rule ✅
Зависимости направлены строго внутрь:presentation
->infrastructure
->application
->domain
Все реализации хранятся в infrastructure и подставляются напрямую через вызовы задач (например, Celery). Use-case'ы взаимодействуют только с интерфейсами и доменными сущностями.
SPR/CCP/SoC/Low coupling/High cohesion ✅
Компоненты чётко структурированы по зонам ответственности:Инфраструктура API-клиентов и адаптеров
Схемы данных внешних сервисов
Мапперы внешних схем в доменные сущности
Связи между компонентами минимальны, каждый отвечает за свою роль.
OCP ✅
Расширение функциональности не требует изменений в существующем коде — достаточно реализовать нужный интерфейс.Например, чтобы добавить поддержку нового агрегатора (например, SuperJobAdapter), достаточно реализовать
IVacancySourceClient
и, при необходимости, собственный маппер.LSP ✅
Контракты соблюдены: все реализации интерфейсов соответствуют ожидаемому поведению.HeadHunterAdapter
корректно реализуетIVacancySourceClient
AiohttpClient
—IAsyncHttpClient
Они могут быть подставлены в любую часть системы, которая ожидает соответствующий интерфейс.
ISP/CRP ✅
Интерфейсы узкие и структурированные, каждый описывает одну задачу: HTTP-запросы, адаптация данных, генерация авторизации и т.п.DIP ✅
Вся логика получения вакансий строится на абстракциях:IVacancySourceClient
,IAsyncHttpClient
и обобщенных типах. Инверсия работает корректно.ADP ✅
Модуль не создаёт циклов. Связи с другими модулями (например, vacancies) построены через контракты и не нарушают иерархию.SDP/SAP ✅
Абстракции стабильны. Изменения в инфраструктуре (например, смена HTTP-клиента или внешнего API) не затрагивают application/domain. Это позволяет развивать инфраструктуру без риска для бизнес-логики.
Взаимодействия между модулями
Определяя границы модулей, мы всегда стараемся опираться на архитектурные принципы: выделять зоны ответственностей компонентов, думать, как они будут взаимодействовать между собой, как одни влияют на другие и какие между ними связи, что будет происходить при расширении функционала или изменении бизнес-требований. Это помогает создавать устойчивую, гибкую архитектуру, в которой каждый компонент знает только то, что ему действительно нужно знать.

Users и Auth
На схеме видно, что модуль auth
тесно зависит от users
: каждый его слой — от domain до presentation — ссылается на интерфейсы или сущности пользователя. Это создаёт ощущение высокой связанности, и возникает логичный вопрос: не стоит ли объединить их в один модуль?
Нет. users
и auth
должны быть разделены. И вот почему:
Независимое развитие и масштабирование
users
может эволюционировать в сторону сложной модели пользователя — профили, роли, соцсвязи, предпочтения, настройки безопасности и прочее.auth
может менять саму механику авторизации: от JWT к OAuth2, OpenID Connect и т.д. Эти изменения не должны затрагивать бизнес-логику users.Эти два модуля будут изменяться с разной скоростью и по разным причинам, поэтому их следует разделить так, чтобы их можно было изменять независимо.
Переиспользуемость
users
может использоваться в системе, где авторизация делегирована внешнему провайдеру.auth
не является универсальным модулем и не задумывался как самостоятельный пакет. Он зависит от бизнес-модели пользователя, репозиториев, схем данных и не может быть переиспользован без явной адаптации. Это ещё одна причина, почему он должен быть отделён от users — его нельзя изолировать, но можно ограничить зону ответственности.
Тестируемость и изоляция
Логика авторизации не может быть полностью изолирована от пользователя, но она изолирована от его реализации. Например, use-caseauthenticate
зависит от интерфейсаIUserUnitOfWork
и сущностиUser
, а не от конкретной ORM-модели или схемы БД. Это позволяет тестировать логику аутентификации с mock/fake-объектами без подключения к базе данных или внешним сервисам.
А что с циклом на схеме?
На схеме видно, что и users
, и auth
presentation слои ссылаются друг на друга. Но на самом деле auth
и users
импортируют только из dependencies.py
и только в api.py
. То есть взаимосвязь между этими модулями в presentation слое ограничена только уровнем маршрутов и DI-функций.
Они не импортируют друг друга на уровне бизнес-логики или application/domain, а лишь используют зависимости. Несмотря на импорт функций, это не создаёт нарушений архитектурных принципов.
Vacancies и Integrations
Граница между vacancies
и integrations
проведена именно на уровне ответственности. Хоть сами модули тесно связаны между собой, предполагается, что добавление новых интеграций не должно влиять на модуль vacancies
, по крайней мере на уровне бизнес-логики.
integrations
описывает взаимодействие с внешними API (например, HeadHunter).vacancies
определяет бизнес-логику: что делать с собранными вакансиями.
Такое разделение полностью оправдано:
integrations
может включать другие сервисы: email/sms-рассылки, telegram-нотификации и т.д. То есть данный модуль не обязан реализовывать только интеграции, связанные с вакансиями, это могут быть любые внешние взаимодействия.vacancies
— основное место работы с вакансиями, и оно не должно знать, откуда именно пришли данные. То есть если нам потребуется в дальнейшем реализовать сбор и сохранение вакансий из файла, то use-case всё также будет в модулеvacancies
, чтобы всё оставалось централизовано.
Хотя архитектурно vacancies
и integrations
разделены, на уровне presentation между ними всё же возникает техническая зависимость: use-case'ы из vacancies
используют реализации адаптеров из integrations
. Это не нарушает Dependency Rule, так как зависимости идут только в точках сборки и не затрагивают бизнес-логику.
Архитектурные соглашения и компромиссы
Интерфейсы
В рамках проекта вы можете заметить, что я не выношу интерфейсы за пределы domain слоя (за исключением тех, что обязаны быть в infrastructure). С точки зрения строгого следования разграничению между «enterprise business rules» и «application business rules» — это может быть спорным решением.
Однако, моя логика заключается в том, что интерфейсы, размещённые в domain, никогда не нарушают Dependency Rule: для реализации логики они будут использованы в application, для конкретной реализации в infrastructure, для сборки и выполнения в presentation.
Если в будущем количество бизнес-правил вырастет, и структура станет сложнее, тогда возможен пересмотр: интерфейсы могут быть вынесены и/или разделены. Но на текущем этапе такое упрощение оправдано.
Entities, values objects, dtos
Также я не выделяю value objects в отдельный файл. Entities и dtos лежат в файле, а не выделены в отдельные директории. Это не архитектурный стандарт, а осознанное упрощение.
Текущие компоненты обладают высокой внутренней согласованностью, но по мере роста проекта и увеличения количества таких элементов, целесообразно будет выделить для них отдельные директории. По-хорошему делать это сразу, так как это улучшит навигацию и облегчит реорганизацию кода — особенно если возникнет необходимость перемещения компонентов между модулями.
Pydantic
Ещё одно замечание – в domain слое entities, value objects, dtos определяются через pydantic. Это прямая инфраструктурная зависимость и, конечно, это порицается чистой архитектурой. Но у меня есть аргумент в пользу такой реализации.
Мы всегда от чего-то зависим, например, от реализаций в самом языке программирования, таких как @dataclass
. В этом случае, мы просто принимаем тот факт, что эта зависимость допустима. Она настолько надёжна, что вряд ли сломает нам проект.
Я рассматриваю pydantic с той же логикой, полагая что это надёжная и стабильная библиотека, которая, несмотря на свою «инфраструктурную природу», не влияет негативно на архитектурную целостность, зато значительно ускоряет разработку: валидация, сериализация, строгая типизация — всё доступно «из коробки».
Utils vs Helpers
В проекте у мапперов существует вспомогательная логика, размещённая в файлах с названием helpers
, а не utils
. Я разделаю их семантически:
utils
— это обычно нейтральные, абстрактные утилиты, не привязанные к контексту. Они легко переиспользуемы и могут находиться в любом месте проекта.helpers
— это вспомогательные функции, жёстко привязанные к конкретному сценарию или компоненту. В моём случае — к конкретному мапперу.
Но я не вижу проблемы выбрать одно единственное название и всегда его использовать, чтобы не усложнять структуру.
Exceptions
Если вы заглянете в базовые ошибки приложения, то увидите следующий код:
class AppException(Exception):
"""
Base application exception.
This class is used as the foundation for all custom exceptions in the app.
It includes HTTP status code, a human-readable message, and optional context.
Attributes:
status_code (int): HTTP status code associated with the exception.
detail (str): A human-readable message describing the error.
extra (dict | None): Additional context or metadata to include in the response.
Note:
HTTP status codes are used intentionally even at the domain/application level
because they are widely familiar, easy to interpret, and cover most common cases.
If a mismatch between internal status and external requirements occurs,
it can easily be resolved with a mapper at the infrastructure or presentation level.
Defining a separate internal status system would introduce unnecessary complexity
for developers accustomed to web applications, so HTTP codes are a pragmatic choice.
"""
status_code = statuses.HTTP_500_INTERNAL_SERVER_ERROR
detail = "Server error"
extra: dict | None = None
def __init__(
self,
status_code: int | None = None,
detail: str | None = None,
**kwargs
) -> None:
self.status_code = self.status_code if not status_code else status_code
self.detail = self.detail if not detail else detail
self.extra = self.extra if not kwargs else kwargs
super().__init__(self.detail)
Возникает вполне резонный вопрос: «Почему в приложении, построенном по принципам чистой архитектуры, в исключениях на уровне domain или application используются HTTP-статусы?»
Могу сказать, что это просто удобно.
Если их убрать, то ничего не поменяется, это не сломает приложение или бизнес логику. Да, HTTP-коды — это часть инфраструктурной концепции. Однако в контексте веб-приложений они:
универсальны и легко интерпретируются
охватывают большинство распространённых сценариев
знакомы практически любому разработчику
Если вынести статус-коды наружу или заменить их на собственную систему кодов — это усложнит восприятие, увеличит порог вхождения и приведёт к необходимости в дополнительных преобразованиях на всех слоях.
Также в моём случае такая реализация позволяет интегрировать базовые ошибки с FastAPI-обработчиком исключений без дополнительной обработки на уровне presentation.
Инфраструктурные детали
Цель данной статьи привести и разобрать пример реализации чистой архитектуры, но на описанных модулях проект не заканчивается. В нём используются инфраструктурные детали, которые я хотел бы затронуть лишь вскользь. Упомянуть, что они используются, и их конфигурации могут помочь кому-то разобраться и начать с ними работу.
Примечание: не все конфигурации являются production-ready
PostgreSQL — это надёжная объектно-реляционная система управления базами данных (СУБД), используемая в качестве основного хранилища данных. Она обеспечивает высокую совместимость с SQL-стандартом, расширяемость и широкие возможности работы с транзакциями. В проекте используется для хранения нормализованных данных о вакансиях, пользователях и другой информации.
Alembic — инструмент управления миграциями в SQLAlchemy. Позволяет пошагово изменять структуру базы данных, отслеживать версии и поддерживать согласованность схемы. В проекте используется для автоматического применения и отката изменений в БД.
Nginx — это высокопроизводительный веб-сервер и обратный прокси. Он используется как входная точка в приложение: принимает HTTP-запросы, перенаправляет их на backend (FastAPI), отдает статику и медиа файлы и может выполнять балансировку нагрузки. Конфигурация различается для dev, test и prod-сред, что позволяет гибко адаптировать поведение сервера под нужды окружения.
Filebeat — агент из Elastic-стека, собирающий логи с файловой системы и пересылающий их дальше (в нашем случае — в Logstash). Используется для передачи логов приложения и инфраструктурных компонентов для централизованного анализа.
Logstash — инструмент для обработки и маршрутизации логов. В проекте он получает данные от Filebeat, применяет фильтры (например, парсинг JSON), валидирует и отправляет их в Elasticsearch для хранения и поиска.
Также настроена автоматическая отправка уведомления в телеграм об ошибках уровня ERROR (message + последние 4000 символов stack trace: весь текст ошибки не умещается на скриншоте).Уведомление об ошибке в Telegram Elasticsearch — это распределённая поисковая система, оптимизированная под полнотекстовый поиск, агрегации и анализ данных. В проекте она выполняет две ключевые функции:
Хранение, анализ и быстрый поиск по вакансиям
Индексация логов, поступающих от Logstash, для последующего анализа и визуализации
Kibana — инструмент визуализации данных из Elasticsearch. В проекте используется для отображения логов и аналитики.

Prometheus — система мониторинга и сбора метрик. Позволяет отслеживать состояние инфраструктуры и приложения в реальном времени. В проекте настроены экспортеры для FastAPI, PostgreSQL, Nginx и самого хоста, что позволяет следить за доступностью, временем ответа, нагрузкой и другими параметрами.
Grafana — инструмент визуализации для метрик, собираемых Prometheus. Используется для построения дашбордов, отображающих ключевые показатели (например, число запросов, скорость их обработки, ошибки БД и т.д.).




Celery — распределённый таск-менеджер. Позволяет запускать обработку в реальном времени, а также поддерживающая планирование задач. В проекте используется для сбора вакансий по расписанию с заданными интервалами, что позволяет не блокировать основной поток приложения.
Redis — высокопроизводительное хранилище данных в памяти. Поддерживает Append Only File режим для сохранения данных в файл, чтобы не потерять их при перезапуске. Используется в двух ролях:
Как брокер и backend сообщений для Celery
Как хранилище JWT-токенов
Flower — веб-интерфейс для мониторинга задач Celery. Он предоставляет удобный UI, где можно отслеживать статус задач, время выполнения, аргументы вызова и ошибки.
В проекте используется как инструмент контроля: особенно полезен в процессе отладки задач сбора данных и анализа производительности Celery.Пример дашборда Flower из официальной документации Docker обеспечивает контейнеризацию компонентов проекта, изолируя среду выполнения и упрощая деплой. Docker Compose позволяет описать многоконтейнерную среду, включая базу данных, веб-сервер, брокер сообщений и само приложение. Это даёт возможность разворачивать систему «в один клик».
Поддержка testing, development, production окружения – проект может запускаться с различными настройками сред (.env) и docker-compose.
Например, в тестовой среде мы сможем постоянно поднимать тестовую базу данных и проверять инфраструктурные реализации.
В dev-среде мы сможем вести основную разработку и выполнять unit и модульные тесты, не боясь нарушить консистентность данных.
Production — потенциальная конфигурация для деплоя в боевое окружение, с повышенными требованиями к безопасности и отказоустойчивости.
О развитии проекта
На текущий момент проект довольно скромный, его основной функционал можно сказать «написан на коленке» и реализован для демонстрации архитектурных подходов, а не для прямого практического применения. Например, чтобы воспользоваться им, нужно вручную менять параметры поиска в коде и уметь работать с Kibana для построения аналитики. Это делает систему скорее техническим прототипом, чем готовым продуктом.
Но в теории у него уже есть неплохой фундамент, на основе которого можно строить более серьёзную систему. В перспективе он может развиться в полноценный сервис с:
Регистрацией и авторизацией пользователей
Сохранением унифицированных пользовательских параметров поиска
Автоматическим сбором подходящих вакансий по расписанию
Уведомлениями (например, в Telegram или по email) о новых вакансиях, соответствующих параметрам поиска
Аналитикой
Отслеживанием статусов откликов (подано резюме, получен отказ/приглашение и т.д.)
На текущий момент это не приоритетная задача, но возможно со временем я буду его дорабатывать, чтобы можно было рассмотреть более сложные паттерны проектирования на основе этого проекта.
Заключение
Существует множество других вариаций построения модулей. Выбор остаётся за вами. Подумайте, как проект может развиваться, какие use-case’ы будут добавлены, с чем придётся работать в дальнейшем.
Архитектура — это не математика. Здесь нет строгих«правильно» и «неправильно» (в разумных пределах). Есть осознанный выбор. Программные архитектуры могут быть совершенно разными, но одинаково эффективными. Не бойтесь допустить «нарушение», если вы понимаете его природу, последствия и делаете это ради улучшения UX разработки.
Не переусложняйте. Не стоит разрабатывать архитектуры уровня предприятия, когда в действительности нужен маленький и удобный инструмент для настольного компьютера, — это верный рецепт провала.
В моменты, когда вы ещё не до конца понимаете предметную область — сосредоточьтесь на её исследовании и создании работающего прототипа. Нельзя создать универсальную инфраструктуру, не создав прежде работающую инфраструктуру.
При этом написание работоспособного кода, который не мешает будущему коду, — умение нетривиальное. На то, чтобы освоить его, уходят годы. Поэтому книги, статьи и другие учебные материалы – это хорошо, но без практики вы не сможете усвоить эту информацию.
Спасибо за внимание! Если проект/статья оказались полезными, буду рад звёздочке на репозитории.