Hierarchical Contexts
In real applications, some dependencies only exist after a runtime event — a request arrives, a user authenticates, a transaction begins. Imagine a UserRepository that scopes database access to a specific user:
class UserRepository:
def __init__(self, db: BaseDatabase, user_id: str) -> None:
self.db = db
self.user_id = user_id
BaseDatabase is fine to wire up at startup, but user_id is per-request — there's no sensible value to register globally. We need a way to express "UserRepository is only constructible after a user authenticates."
Inlay models this with a transition: a method on the parent context that returns a child context with extra fields in scope. Let's build it up.
Declare the authorized scope
First, declare what becomes reachable once a user is authenticated:
class AuthorizedContext(Protocol):
@property
def repo(self) -> UserRepository: ...
Nothing here mentions user_id — that's an implementation detail of UserRepository. The child context only declares its user-facing surface.
Add the transition to the parent
Next, advertise the entry point on the root context:
class AppContext(Protocol):
def authorize(self, token: str) -> AuthorizedContext: ...
This is still just a Protocol. We haven't told Inlay how authorize works — only that AppContext is anything with a method matching this signature.
Implement the transition
The implementation is just a function that returns the new fields the child scope gains over the parent. A TypedDict is a clean way to express those fields:
from typing import TypedDict
class AuthorizedFields(TypedDict):
user_id: str
def authorize(token: str) -> AuthorizedFields:
# validate token, look up user, etc.
return {'user_id': 'u-123'}
When this function runs, its return value contributes a user_id: str into the child context's resolution scope. The child inherits everything from the parent (so Database is still available) and adds these new fields on top.
Wire it up
register_method binds the function to the protocol method:
registry = (
Registry()
.register(BaseDatabase)(PostgresDatabase)
.register_method(AppContext, AppContext.authorize)(authorize)
)
Inlay can now resolve AuthorizedContext.repo: inside the authorized scope it has PostgresDatabase (inherited from the parent) and user_id (introduced by the transition's return type), which is everything UserRepository.__init__ requires.
Compile and call it
The final step is the same as before — compile the root and walk through the transition:
@compiled(registry)
def make_app(url: str) -> AppContext: ...
app = make_app(url='postgres://localhost/app')
authorized = app.authorize(token='...')
assert authorized.repo.user_id == 'u-123'
UserRepository is constructed only once authorize(...) is called — up to that point no user_id exists, and Inlay never attempts to build it. The same pattern composes recursively: child contexts can declare their own transitions.