Getting Started with Decorators¶
The examples below build a single web application step by step, introducing one concept at a time. All code is runnable with the standard library only.
Step 1 — Define services¶
Decorate a class with @scope to make it a DI container. Annotate each value with
@resource and expose it with @public. Resources declare their dependencies as
ordinary function parameters; the framework injects them by name.
Use @extern to declare a dependency that must come from outside the scope — the
equivalent of a pytest fixture parameter. Pass multiple scopes to evaluate() to
compose them; dependencies are resolved by name across scope boundaries. Config
values are passed as kwargs when calling the evaluated scope.
@scope
class SQLiteDatabase:
@extern
def databasePath() -> str: ... # caller must provide this
@public
@resource
def connection(databasePath: str) -> sqlite3.Connection:
return sqlite3.connect(databasePath)
@scope
class UserRepository:
@public
@resource
def userCount(connection: sqlite3.Connection) -> int:
connection.execute(
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)"
)
(count,) = connection.execute("SELECT COUNT(*) FROM users").fetchone()
return count
SQLiteDatabase owns databasePath; UserRepository has no knowledge of the
database layer — it only declares connection: sqlite3.Connection as a parameter
and receives it automatically from the composed scope.
Tip
Naming convention
Throughout this tutorial we use UpperCamelCase for scopes and lowerCamelCase for resources. The idea is that a scope is conceptually a class — an instantiable container — while a resource is a lazily-evaluated value inside it.
In the example above, SQLiteDatabase and UserRepository are scopes
(UpperCamelCase), while databasePath, connection, and userCount are
resources (lowerCamelCase).
This convention extends to Python modules used as scopes: a module file representing
a scope is named in UpperCamelCase (e.g., SqliteDatabase.py), and a subpackage
representing a nested scope likewise (e.g., UserRepository/Request/). This
deviates from PEP 8 — the MIXINv2 decorators form a DSL, and the casing signals
that the code is not plain Python data model.
Step 2 — Layer cross-cutting concerns with @patch and @merge¶
@patch wraps an existing resource value with a transformation. This lets an
add-on scope modify a value without touching the scope that defined it — the same
idea as pytest’s monkeypatch, but composable.
@scope
class Base:
@public
@resource
def maxConnections() -> int:
return 10
@scope
class HighLoad:
"""Patch for high-load environments: double the connection limit."""
@patch
def maxConnections() -> Callable[[int], int]:
return lambda previous: previous * 2
When several independent scopes each contribute a piece to the same resource, use
@merge to define how the contributions are aggregated:
@scope
class PragmaBase:
@public
@merge
def startupPragmas() -> Callable[[Iterator[str]], frozenset[str]]:
return frozenset # aggregation strategy: collect into frozenset
@scope
class WalMode:
@patch
def startupPragmas() -> str:
return "PRAGMA journal_mode=WAL"
@scope
class ForeignKeys:
@patch
def startupPragmas() -> str:
return "PRAGMA foreign_keys=ON"
A @patch can itself declare @extern dependencies, which are injected like any
other resource:
@scope
class PragmaBase:
@public
@merge
def startupPragmas() -> Callable[[Iterator[str]], frozenset[str]]:
return frozenset
@scope
class UserVersionPragma:
@extern
def schemaVersion() -> int: ... # provided as a kwarg at call time
@patch
def startupPragmas(schemaVersion: int) -> str:
return f"PRAGMA user_version={schemaVersion}"
Step 3 — Force evaluation at startup with @eager¶
All resources are lazy by default: computed on first access, then cached for the
lifetime of the scope. Mark a resource @eager to evaluate it immediately when
evaluate() returns — useful for schema migrations or connection pre-warming that
must complete before the application starts serving requests:
@scope
class SQLiteDatabase:
@public
@eager
@resource
def connection() -> sqlite3.Connection:
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
db.commit()
return db
Without @eager, the CREATE TABLE would not run until root.connection is first
accessed.
Step 4 — App scope vs request scope¶
So far all resources have had application lifetime: created once at startup and reused for every request. Real applications also need per-request resources — values that must be created fresh for each incoming request and discarded when it completes.
A nested @scope named Request serves as a per-request factory. The
framework injects it by name as a Callable; calling
Request(request=handler) returns a fresh instance.
The application below has four scopes, each owning only its own concern:
SQLiteDatabase — owns
databasePath, providesconnectionUserRepository — business logic; owns
userCountand per-requestcurrentUserHttpHandlers — HTTP layer; owns per-request
userId,responseBody,responseSentNetworkServer — network layer; owns
host/port, creates theHTTPServer
UserRepository.Request and HttpHandlers.Request are composed into a
single Request by the union mount. userId (extracted from the HTTP path
by HttpHandlers.Request) flows automatically into currentUser (looked up
in the DB by UserRepository.Request) without any glue code.
responseSent is an IO resource: it sends the HTTP response as a side effect and
returns None. The handler body is a single attribute access — all logic lives in
the DI graph. In an async framework (e.g. FastAPI), return an asyncio.Task[None]
instead of a coroutine, which cannot be safely awaited in multiple dependents.
@scope
class SQLiteDatabase:
@extern
def databasePath() -> str: ... # database owns its own config
# App-scoped: one connection for the entire process lifetime.
# check_same_thread=False: created in main thread, used in handler threads.
@public
@resource
def connection(databasePath: str) -> sqlite3.Connection:
db = sqlite3.connect(databasePath, check_same_thread=False)
db.execute(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"
)
db.execute("INSERT INTO users VALUES (1, 'alice')")
db.execute("INSERT INTO users VALUES (2, 'bob')")
db.commit()
return db
@scope
class UserRepository:
@extern
def connection() -> sqlite3.Connection: ...
# @scope as a composable dataclass — fields are @extern, constructed via DI.
@public
@scope
class User:
@public
@extern
def userId() -> int: ...
@public
@extern
def name() -> str: ...
# App-scoped: total count across all requests.
@public
@resource
def userCount(connection: sqlite3.Connection) -> int:
(count,) = connection.execute(
"SELECT COUNT(*) FROM users"
).fetchone()
return count
# Request-scoped: per-request DB resources, wired by overlay union-mount.
@public
@scope
class Request:
@extern
def userId() -> int: ... # provided by HttpHandlers.Request
@public
@resource
def currentUser(
connection: sqlite3.Connection, userId: int, User: Callable
) -> object:
row = connection.execute(
"SELECT id, name FROM users WHERE id = ?", (userId,)
).fetchone()
assert row is not None, f"no user with id={userId}"
identifier, name = row
return User(userId=identifier, name=name)
@scope
class HttpHandlers:
@extern
def userCount() -> int: ...
# Request is nested because its lifetime is per-request,
# not per-application.
@public
@scope
class Request:
class _RequestWithPath(Protocol):
path: str
@extern
def request() -> BaseHTTPRequestHandler: ...
@extern
def currentUser() -> object: ...
# userId is extracted from the request and injected into
# UserRepository.Request.currentUser automatically.
@public
@resource
def userId(request: _RequestWithPath) -> int:
return int(request.path.split("/")[-1])
@public
@resource
def responseBody(userCount: int, currentUser: object) -> bytes:
return (
f"total={userCount} current={currentUser.name}"
).encode()
# IO resource: sends the HTTP response as a side effect.
@public
@resource
def responseSent(
request: BaseHTTPRequestHandler,
responseBody: bytes,
) -> None:
request.send_response(200)
request.end_headers()
request.wfile.write(responseBody)
@scope
class NetworkServer:
@extern
def host() -> str: ... # network layer owns its own config
@extern
def port() -> int: ...
@scope
class Request:
pass
# Request is injected by name as a Callable (StaticScope).
# Calling Request(request=handler) returns a fresh InstanceScope.
@public
@resource
def server(host: str, port: int, Request: Callable) -> HTTPServer:
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
Request(request=self).responseSent
def log_message(self, format: str, *arguments: object) -> None:
pass
return HTTPServer((host, port), Handler)
@extend(
LexicalReference(path=("SQLiteDatabase",)),
LexicalReference(path=("UserRepository",)),
LexicalReference(path=("HttpHandlers",)),
LexicalReference(path=("NetworkServer",)),
)
@public
@scope
class App:
pass
Assemble into a module and evaluate — pass the module directly to evaluate():
import mixinv2_examples.app_decorator.step4_http_server as step4_http_server
root = evaluate(step4_http_server, modules_public=True).App(
databasePath="/var/lib/myapp/prod.db",
host="127.0.0.1",
port=8080,
)
server = root.server
Swapping to a test configuration is just different kwargs; no scope or composition changes:
test_root = evaluate(step4_http_server, modules_public=True).App(
databasePath=":memory:", # fresh, isolated database for each test
host="127.0.0.1",
port=0, # OS assigns a free port
)
# test_root.connection → sqlite3.Connection to :memory:
# test_root.server → HTTPServer on OS-assigned port
Decorator reference¶
Decorator |
Purpose |
|---|---|
|
Define a DI container (class) or sub-namespace |
|
Declare a lazily-computed value; parameters are injected by name |
|
Expose a |
|
Declare a required dependency that must come from the composed scope |
|
Provide a transformation that wraps an existing resource |
|
Like |
|
Define how patches are aggregated (e.g. |
|
Force evaluation at scope creation rather than on first access |
|
Inherit from other scopes explicitly (for package-level union mounts) |
|
Resolve and union-mount one or more scopes into a single dependency graph |
Python modules as scopes¶
The @scope classes above are a teaching convenience — the real-world style is
plain Python modules, just like pytest fixtures don’t require a class. Every
@scope class maps directly to a module file; pass it to evaluate() the same
way:
import SqliteDatabase # SqliteDatabase.py with @extern / @resource / @public
import UserRepository # UserRepository/ package
The same decorators work on module-level functions exactly as on class methods. A
subpackage becomes a nested scope — UserRepository/Request/ is the
module equivalent of a nested @scope class Request.
Use @extend in a package’s __init__.py to declare the composition, then
evaluate() receives the single package:
@extend(
LexicalReference(path=("SqliteDatabase",)),
LexicalReference(path=("UserRepository",)),
)
@public
@scope
class Step1App:
pass
import myapp
root = evaluate(myapp, modules_public=True).App(databasePath=":memory:")
Runnable module-based equivalents of all tutorial examples are in packages/mixinv2-examples/tests/test_readme_package_examples.py, using the fixture package at packages/mixinv2-examples/src/mixinv2_examples/app_di/.