Concepts
Two ideas make archtool immediately obvious: how it discovers and wires classes, and why layered architecture matters.
How archtool discovers and wires dependencies
The contract: interfaces.py
Every domain module has an interfaces.py with abstract classes that inherit from archtool's layer markers — ABCRepo, ABCService, ABCController. These abstract classes define the contract (what methods exist), not the implementation.
# app/users/interfaces.py
from abc import abstractmethod
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: ...
ABCRepo and ABCService are layer markers — empty base classes that tell archtool "scan this file and treat these as repo interfaces" vs "service interfaces".
The implementations: repos.py / services.py
Concrete classes live in separate files and inherit from the abstract interfaces. They have no __init__ parameters — archtool instantiates them as ConcreteClass() with no arguments.
# app/users/repos.py
from .interfaces import UserRepoABC
class UserRepo(UserRepoABC):
def find_all(self) -> list[str]:
return ["alice", "bob"]
# app/users/services.py
from .interfaces import UserServiceABC, UserRepoABC
class UserService(UserServiceABC):
repo: UserRepoABC # dependency declared here, on the concrete class
def get_name(self) -> str:
return self.repo.find_all()[0]
repo: UserRepoABC is the entire dependency declaration. archtool reads this annotation from UserService (not from UserServiceABC) and knows: "this service needs a registered UserRepoABC instance, set it as .repo".
The two-pass injection
When you call injector.inject():
Pass 1 — discover and register:
archtool walks every layer's ComponentPattern. For example, InfrastructureLayer has ComponentPattern("repos", superclass=ABCRepo). For each AppModule:
- Scan
app.users.interfaces→ find abstract subclasses ofABCRepo→UserRepoABC - Scan
app.users.repos→ find non-abstract subclasses ofUserRepoABC→UserRepo - Instantiate:
instance = UserRepo() - Register with key = full dotted path to the interface class:
"myproject.app.users.interfaces.UserRepoABC" → UserRepo()
Then DomainLayer repeats the same for ABCService / services.py.
Between passes — topological sort:
Before any setattr is called, archtool sorts registered components so that each dependency is always wired before the component that uses it. This is a DFS-based topological sort over the dependency graph.
If the graph contains a cycle (ServiceA depends on ServiceB, ServiceB depends on ServiceA), archtool does not fail — in the two-pass scheme all objects are already instantiated, so circular setattr calls are perfectly valid. Instead, a WARNING is logged once per inject() call:
[archtool] WARNING Circular dependency detected: ServiceA → ServiceB → ServiceA.
Wiring will succeed because all objects are already instantiated, but mutual
method recursion may cause infinite loops at runtime.
This is a design signal, not an error. Circular wiring often indicates that two components share too many responsibilities and could be split.
Pass 2 — inject:
Components are processed in topological order (deepest dependencies first). For each instance archtool reads its class-level annotations. For UserService:
archtool looks up "myproject.app.users.interfaces.UserRepoABC" in the registry, finds the UserRepo instance, and calls:
The result: user_service.repo is the fully wired UserRepo, with no boilerplate in your code.
Wiring the whole thing
from pathlib import Path
from archtool.dependency_injector import DependencyInjector
from archtool.global_types import AppModule
injector = DependencyInjector(
modules_list=[AppModule("app.users")],
project_root=Path(__file__).parent.parent,
)
injector.inject()
service = injector.get_dependency(UserServiceABC)
print(service.get_name()) # "alice" — repo was injected automatically
Layered architecture
The default layers in archtool map directly to Clean Architecture:
| Layer | File scanned | Marker superclass |
|---|---|---|
InfrastructureLayer |
repos.py |
ABCRepo |
DomainLayer |
services.py |
ABCService |
ApplicationLayer |
controllers.py |
ABCController |
PresentationLayer |
views.py |
ABCView |
Layers are assembled in dependency order: repos first, then services (which depend on repos), then controllers (which depend on services). The second injection pass wires them together.
The rule: inner layers must not import from outer layers. Domain (services) should not import infrastructure (repos) directly — they communicate only through the declared interface. archtool enforces this at startup if you pass layers=[...] to the injector.
Bounded contexts
Each AppModule is one bounded context — a vertical slice of the domain with its own interfaces, services, and repos:
app/
├── users/ ← AppModule("app.users")
├── orders/ ← AppModule("app.orders")
└── payments/ ← AppModule("app.payments")
A service in orders can declare a dependency on an interface from users — archtool resolves it across module boundaries by key.
The assembly test pattern
def test_di_assembles():
injector = DependencyInjector(modules_list=APPS, project_root=ROOT)
injector.inject() # raises on any wiring error
One fast test that catches: missing concrete class, wrong inheritance, from __future__ import annotations breaking type resolution, broken imports — before they reach production.