Skip to content

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

examples/learn_fundamentals/fundamentals09_scopes.py
"""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

$env:PYTHONPATH = "src;examples"
python -m learn_fundamentals.fundamentals09_scopes
PYTHONPATH=src:examples python -m learn_fundamentals.fundamentals09_scopes

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:

examples/learn_fundamentals/fundamentals09_scope_violation.py
"""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()
ScopeViolationError: app-scoped Holder cannot depend on shorter-lived request-scoped RequestThing

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 close or aclose method 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.

$env:PYTHONPATH = "src;examples"
python -m learn_fundamentals.fundamentals09_scopes
PYTHONPATH=src:examples python -m learn_fundamentals.fundamentals09_scopes
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.