Lesson 9 — choose a scope¶
What you will build¶
Three services that differ only in how long they live, and four lines of output that prove the difference. Scope is the answer to one question: how many instances should exist?
What you need¶
The setup from lesson 1. This lesson uses nothing beyond the core package.
The three answers¶
| Scope | Instances | Use it for |
|---|---|---|
Scope.APP (the default) |
one per application | connection pools, clients, configuration-backed services |
Scope.REQUEST |
one per request | per-request state, a unit of work |
Scope.TRANSIENT |
a new one every time | stateless helpers |
The code¶
"""Lesson 9 — how long a service lives.
Run it with:
python -m learn_fundamentals.fundamentals09_scopes
"""
from __future__ import annotations
import asyncio
from typing import Annotated
from xyberos import App, Inject, Scope
class Cache:
"""App-scoped: one instance for the whole application."""
class RequestState:
"""Request-scoped: one instance per request, discarded with it."""
def __init__(self, cache: Annotated[Cache, Inject()]) -> None:
self.cache = cache
class Formatter:
"""Transient: a new instance every time it is resolved."""
async def main() -> None:
app = App()
app.provide(Cache)
app.provide(RequestState, scope=Scope.REQUEST)
app.provide(Formatter, scope=Scope.TRANSIENT)
async with app.lifespan():
print(
"app-scoped gives the same instance:",
await app.resolve(Cache) is await app.resolve(Cache),
)
print(
"transient gives a new instance:",
await app.resolve(Formatter) is await app.resolve(Formatter),
)
async with app.request_context() as request:
first = await request.resolve(RequestState)
second = await request.resolve(RequestState)
print("request-scoped is stable inside one request:", first is second)
async with app.request_context() as request:
third = await request.resolve(RequestState)
print("request-scoped is new in the next request:", third is not first)
if __name__ == "__main__":
asyncio.run(main())
How to run it¶
What it prints¶
app-scoped gives the same instance: True
transient gives a new instance: False
request-scoped is stable inside one request: True
request-scoped is new in the next request: True
Read the second line carefully, because it reads backwards: the check is
(await app.resolve(Formatter)) is (await app.resolve(Formatter)), and the answer is False — a
transient provider builds a new instance every time, so the two are not the same object.
The rule that catches people out¶
A longer-lived service may not depend on a shorter-lived one. An app-scoped service cannot hold a request-scoped instance, because that instance would outlive the request it belongs to. The graph is refused when it is validated — before anything is built:
"""Lesson 9 — the rule the runtime enforces about lifetimes.
An app-scoped service cannot depend on a request-scoped one: the request-scoped instance would
outlive the request it belongs to. The graph is refused when it is validated, before anything is
built. Kept as a file so the lesson can quote the message and the suite can prove it.
"""
from __future__ import annotations
from typing import Annotated
from xyberos import App, Inject, Scope, ScopeViolationError
class RequestThing:
"""Request-scoped: one instance per request, discarded with it."""
class Holder:
"""App-scoped by default, and longer-lived than what it asks for."""
def __init__(self, thing: Annotated[RequestThing, Inject()]) -> None:
self.thing = thing
def main() -> None:
"""Compose a graph that cannot be valid, and show what the runtime says."""
app = App()
app.provide(RequestThing, scope=Scope.REQUEST)
app.provide(Holder)
try:
app.validate()
except ScopeViolationError as error:
print(f"{type(error).__name__}: {error}")
else:
raise AssertionError("the graph was accepted, so this lesson no longer tells the truth")
if __name__ == "__main__":
main()
Common mistakes¶
- Making everything app-scoped. It works until two requests share mutable state, which is a bug that only appears under concurrency.
- A transient that owns a resource. Something with a
closeoraclosemethod cannot be transient, because nothing would ever close it. If it needs cleanup, it wants a real scope. - Using a request-scoped service outside a request. Resolve it inside
async with app.request_context():; the context is what defines the request's lifetime.
Exercise¶
Predict the output before you run it, then check.
Show the answer
The line to watch is the second one: `transient gives a new instance: False`. Two resolutions of a transient provider produce two different objects, so the identity check is `False` — and that is what "transient" means. Request scope is the one worth predicting carefully: within one `request_context`, the same instance comes back twice; in the next `request_context`, it does not.Next¶
Lesson 10 reads the registry the way a debugger does: app.diagnostics() and xyberos check. Until it
is published, the roadmap shows where each lesson stands.