Comparison with other frameworks
Quick overview
| archtool | dependency-injector | injector | Manual DI | |
|---|---|---|---|---|
| Registration style | Convention (auto-scan) | Explicit providers | Decorator + module | Explicit |
| Boilerplate | Minimal | Medium–High | Medium | High |
| Layer enforcement | ✅ Built-in | ❌ | ❌ | ❌ |
| CLI scaffolding | ✅ | ❌ | ❌ | ❌ |
from __future__ import annotations |
✅ | ✅ | Partial | — |
| Async-specific support | ❌ | ✅ | ❌ | Custom |
| Scopes / lifetimes | ❌ | ✅ | ✅ | Custom |
| Factories / providers | ❌ | ✅ | ✅ | Custom |
| Community / maturity | Small / new | Large / mature | Medium / stable | — |
archtool
archtool uses class-level annotations on concrete classes to declare dependencies. No registration code, no decorators — if a class is in the right file and inherits the right base, it's picked up automatically.
# interfaces.py
from archtool.layers.default_layer_interfaces import ABCService, ABCRepo
class UserRepoABC(ABCRepo):
@abstractmethod
def find_all(self) -> list[str]: ...
class UserServiceABC(ABCService):
@abstractmethod
def get_name(self) -> str: ...
# services.py — annotation on the concrete class, no __init__ needed
class UserService(UserServiceABC):
repo: UserRepoABC # archtool reads this, finds UserRepo, calls setattr
def get_name(self) -> str:
return self.repo.find_all()[0]
injector = DependencyInjector(
modules_list=[AppModule("app.users")],
project_root=ROOT,
)
injector.inject()
dependency-injector
dependency-injector by ets-labs — the most popular Python DI library. Uses explicit providers declared in a container class.
from dependency_injector import containers, providers
class Container(containers.DeclarativeContainer):
user_repo = providers.Singleton(UserRepo)
user_service = providers.Factory(UserService, repo=user_repo)
container = Container()
service = container.user_service()
Strengths:
- Mature, battle-tested, large community
- Rich provider types: Singleton, Factory, ThreadLocalSingleton, Resource, Coroutine, …
- First-class async support (async providers, async init_resources)
- Scopes and lifetimes for every object
- Framework integrations out of the box (FastAPI, Flask, Django)
Weaknesses: - Every dependency must be registered manually — dozens of lines for a large project - No layer boundary enforcement - No CLI tooling
When to choose over archtool: you need scopes, async resource management, or framework integrations.
injector
injector by Alec Thomas — inspired by Google Guice. Uses modules and constructor injection via type annotations.
from injector import Injector, Module, provider, singleton
class AppModule(Module):
@singleton
@provider
def provide_repo(self) -> UserRepoABC:
return UserRepo()
i = Injector([AppModule()])
service = i.get(UserService) # constructor: def __init__(self, repo: UserRepoABC)
Note: injector uses constructor injection — the dependency is passed as a constructor parameter, not set as a class attribute. This is fundamentally different from archtool's annotation-on-concrete-class style.
Strengths: - Clean Guice-style API - Scopes: singleton, thread-local, custom - Auto-binding — can often skip the module declaration entirely for simple cases
Weaknesses: - Requires constructor parameters for every dependency - Still needs explicit module wiring for non-trivial cases - No architecture enforcement - Limited async support
Manual DI (pure Python)
For small projects, manual wiring is often the right answer:
repo = UserRepo()
service = UserService()
service.repo = repo # same style as archtool, just written by hand
Strengths: - Zero dependencies - No magic — every connection is explicit and debuggable - Full control over object lifetimes
Weaknesses: - Wiring code grows linearly with the codebase - Must be maintained by hand as the graph deepens - No architectural constraint enforcement
Strengths and weaknesses of archtool
Strengths
- Zero boilerplate — add a file in the right place, inherit the right base class, and archtool finds it. No registration calls.
- Architecture enforcement — layer boundary violations are caught at startup, not silently at runtime.
- Works with
from __future__ import annotations— usestyping.get_type_hints()internally to resolve stringified annotations. - CLI scaffolding —
archtool initproduces a full layered-architecture project skeleton in seconds. - Lightweight runtime — only
clickandrichas runtime dependencies. - Assembly test — one test exercises the entire wiring before anything reaches production.
Things to know (not deal-breakers)
-
Sensible defaults, fully extensible — the built-in layers (
repos.py,services.py, etc.) follow Clean Architecture conventions. But layers are just classes: you can define your ownLayerwith anyComponentPattern, pointing at any filename and any superclass. The defaults are a starting point, not a cage. -
No required constructor parameters — concrete classes are instantiated as
Class(). This is intentional: dependencies flow through DI annotations, not constructors. If an object needs configuration at startup, the cleanest options are: - Read it from env vars / config in the
__init__body (no args needed) - Pre-register it via
injector.register(key=ConfigABC, value=my_config)before callinginject()— archtool will inject it like any other dependency -
After
inject(), set it directly viainjector.dependencies["...key..."] = value -
One implementation per interface per module — intentional. Conditional wiring (swap a repo in tests) is handled by pre-registering the test implementation before
inject()runs. archtool respects manually pre-registered dependencies and skips auto-discovery for them. -
Async resource initialisation — archtool's job is structural wiring (what connects to what), not lifecycle management. Async resources (connection pools, clients) are initialised outside archtool, then handed in via
injector.register():
When to use archtool
Good fit:
- Greenfield project following clean/layered architecture
- Team wants zero DI boilerplate: add a module, it's discovered automatically
- Architectural constraints enforced at startup matter
- Need to pre-register async resources or custom objects alongside auto-wired components
Less suited if:
- Your domain layout fundamentally doesn't map to the service/repo/controller split and you don't want to define custom layers
- SQLAlchemy
Sessionper request — archtool does not manage session lifecycles. The idiomatic pattern is to injectasync_sessionmakerinto repos as a regular DI dependency, then open a session per method via aUnitOfWorkcontext manager:
class UserRepo(UserRepoABC):
session_maker: async_sessionmaker # archtool injects this
async def get_user(self, user_id: str) -> UserDM:
async with UnitOfWork(self.session_maker) as uow:
session = uow.get_session()
return await session.get(UserORM, user_id)
The session lives exactly as long as the method needs it. If a service method must span several repo calls inside one transaction, pass the UnitOfWork (or the session itself) as a method argument — explicit and straightforward, unlike React props-drilling.