Перейти к содержанию

Зачем archtool?

Цикл архитектурного долга

Каждый бэкенд-проект начинается с добрых намерений. Интерфейсы, чистые слои, никаких циклических импортов. Потом приходят дедлайны, и «временные» костыли множатся.

Через полгода у тебя:

  • 200-строчная точка входа, которая вручную разводит 30 объектов в строго правильном порядке
  • sys.path.insert(0, "..") в начале каждого entrypoint-файла, потому что никто так и не починил импорты
  • «Сервис», который напрямую импортирует из репо другого модуля, потому что «так было быстрее»
  • Тесты, которые ломаются при добавлении новой зависимости, потому что забыли обновить дерево моков
  • Новый разработчик, который два дня разбирался как вообще стартует приложение

Это не проблема навыков. Это структурная проблема. Ручная разводка не масштабируется.


Почему другие DI-решения не решают её

Большинство Python DI-библиотек решают не ту проблему. Они заменяют ручную разводку на... ручную регистрацию:

# dependency-injector
class Container(containers.DeclarativeContainer):
    user_repo = providers.Singleton(UserRepo)
    user_service = providers.Factory(UserService, repo=user_repo)
    order_repo = providers.Singleton(OrderRepo)
    order_service = providers.Factory(OrderService, repo=order_repo, user_service=user_service)
    # ... ещё 50 провайдеров ...

Ты всё равно вручную прописываешь каждую связь. Ты всё равно забываешь обновить контейнер когда добавляешь зависимость. Контейнер превращается во вторую кодовую базу, которую надо синхронизировать с первой.

Они также ничего не делают с архитектурой: ничто не мешает сервису импортировать репо напрямую, или контроллеру обращаться к базе данных. Нарушения невидимы, пока не превратятся в баг.


Что делает archtool иначе

Конвенция заменяет регистрацию.

Если твой класс находится в app/users/repos.py и наследует ABCRepo — archtool его найдёт. Если UserService имеет аннотацию repo: UserRepoABC — archtool подключит её. Никакого контейнера. Никаких вызовов регистрации. Никакой проблемы синхронизации.

Архитектура соблюдается принудительно, а не по надежде.

Ты объявляешь слои. archtool проверяет при старте, что ни один класс не пересекает запрещённую границу. Нарушение — это исключение при загрузке, а не тонкий баг через три месяца.

Структура и есть документация.

Когда каждый проект использует одну и ту же структуру — interfaces.py, services.py, repos.py — новый разработчик сразу знает куда смотреть. Файл интерфейсов буквально является проектной документацией для этого ограниченного контекста.


SOLID, обеспеченный структурой

archtool не просто допускает SOLID — он делает нарушения сложнее, чем соблюдение.

Single Responsibility — каждый AppModule является одним ограниченным контекстом. Users, Orders, Payments разделены. Они не могут случайно просочиться друг в друга.

Open/Closed — добавление нового модуля не требует изменений существующего кода. Инжектор подхватывает его из modules_list. Существующие модули не тронуты.

Liskov Substitution — замени UserRepo на StubUserRepo (предзарегистрируй до inject()), и все потребители получат заглушку. Потребители никогда не знали, что разговаривали с конкретным классом.

Interface Segregation — твои интерфейсы живут в одном сфокусированном файле. Ничто не заставляет тебя запихивать несвязанные методы в один интерфейс. Маленькие сфокусированные ABC — это естественный паттерн.

Dependency Inversion — на этом построен весь фреймворк. Ничто не зависит от конкретных классов. Сервисы зависят от UserRepoABC. Контроллеры зависят от UserServiceABC. Конкреции — это деталь рантайма.


Проектирование от интерфейсов

Правильный способ проектировать систему — начинать с интерфейсов, а не с реализаций.

Твой interfaces.py — это проектный документ для данного ограниченного контекста:

# app/orders/interfaces.py
class OrderRepoABC(ABCRepo):
    @abstractmethod
    async def get(self, order_id: UUID) -> Order: ...

    @abstractmethod
    async def save(self, order: Order) -> None: ...


class OrderServiceABC(ABCService):
    @abstractmethod
    async def place(self, user_id: UUID, items: list[Item]) -> Order: ...

    @abstractmethod
    async def cancel(self, order_id: UUID) -> None: ...

Читая этот файл, ты точно знаешь что делает ограниченный контекст заказов. Никаких деталей реализации. Никакого шума фреймворка. Только контракт.

Когда ты пишешь здесь docstring'и, ты документируешь поведение — не код. Здесь живут архитектурные решения. Файлы реализации — это просто исполнение этого контракта.


Любой проект может это использовать

Не нужно начинать с нуля. Archtool можно вводить в существующий проект модуль за модулем:

  1. Выбери один ограниченный контекст
  2. Вынеси его интерфейсы в interfaces.py, унаследовав от маркеров archtool
  3. Перемести конкретные классы в services.py / repos.py
  4. Добавь AppModule в инжектор

Остальная кодовая база не меняется. Мигрируешь в своём темпе.


Кастомные слои для кастомных архитектур

archtool поставляется с четырьмя встроенными слоями (Infrastructure, Domain, Application, Presentation) по Clean Architecture. Но если твоя архитектура другая — гексагональная, луковая, полностью своя — ты определяешь свои слои:

class IntegrationsLayer(Layer):
    depends_on = InfrastructureLayer
    class Components:
        clients = ComponentPattern("clients", superclass=ABCClient)
        adapters = ComponentPattern("adapters", superclass=ABCAdapter)

archtool будет сканировать clients.py на подклассы ABCClient и adapters.py на подклассы ABCAdapter, и разводить их автоматически. Фреймворк адаптируется к твоей архитектуре, а не наоборот.