Skip to content

Writing a module

Xyberos has no module type, and that is the design rather than a gap. What it has is an application object whose registrations are explicit, so a module is a package that exposes one function taking the application. Nothing registers itself, nothing is discovered, and composition is a decision a reader can point at.

Two properties make a module composable, and both are about what it does not say:

  • It declares nothing about the application it lands in — no name, no settings file, no assumption about what else is registered.
  • Its route handlers obey the three parameter rules the HTTP adapter enforces: a path segment, the Request, or Annotated[T, Inject()]. A query string is read from the request, never invented.

The shape

examples/learn_extend/module_rates.py
"""A composable module: it registers capabilities and routes, and composes with anything.

This is the whole convention. Xyberos has no module type — what it has is an application object
whose registrations are explicit, so a module is a package that exposes one function taking the
application. Two properties make it composable:

* it declares nothing about the application it lands in — no name, no settings file, no assumption
  about what else is registered;
* its route handlers obey the three parameter rules the HTTP adapter enforces: a path segment, the
  `Request`, or `Annotated[T, Inject()]`. A query string is read from the request, never invented.
"""

from __future__ import annotations

from typing import Annotated

from xyberos import App, Inject


class Rates:
    """The capability this module contributes, and the contract other modules depend on."""

    def __init__(self, table: dict[str, float]) -> None:
        self._table = table

    def discount(self, code: str) -> float:
        """Return the discount for a code, or 0.0 for a code nobody has heard of."""
        return self._table.get(code.upper(), 0.0)


def register(app: App) -> None:
    """Attach this module's provider and route to an application, and return nothing."""
    app.provide(Rates, instance=Rates({"SAVE10": 0.10, "PARTNER": 0.25}))
    app.get("/rates/{code}")(read_rate)


async def read_rate(code: str, rates: Annotated[Rates, Inject()]) -> dict[str, object]:
    """Answer one rate: the path segment, and the capability resolved from the registry."""
    return {"code": code.upper(), "discount": rates.discount(code)}

register is the whole convention: it takes the application, attaches what this module contributes, and returns nothing. It is deliberately not a class with a bootstrap method and not a decorator — because a plain function can be read at a glance, called in a test, and called twice against two different applications.

Depending on a contract, not on a registration

examples/learn_extend/module_pricing.py
"""A second composable module: it depends on the first one's contract, not on its registration.

Importing the *type* is what depending on a contract means here, and it is the only thing this
module takes from its neighbour: it never calls `module_rates.register`, so which modules are
composed, and in which order, is the application's decision rather than this module's.
"""

from __future__ import annotations

from typing import Annotated

from starlette.requests import Request

from learn_extend.module_rates import Rates
from xyberos import App, Inject


class Pricing:
    """Quotes a price, using whatever rates the application happens to have registered."""

    def __init__(self, rates: Annotated[Rates, Inject()]) -> None:
        self._rates = rates

    def quote(self, code: str, price: float) -> float:
        """Return a price after the discount for ``code``."""
        return round(price * (1 - self._rates.discount(code)), 2)


def register(app: App) -> None:
    """Attach this module's provider and route to an application."""
    app.provide(Pricing)
    app.get("/quote")(read_quote)


async def read_quote(request: Request, pricing: Annotated[Pricing, Inject()]) -> dict[str, object]:
    """Answer one quote, reading the query string the way every handler does."""
    code = request.query_params.get("code", "")
    price = float(request.query_params.get("price", "0"))
    return {"code": code.upper(), "price": price, "quote": pricing.quote(code, price)}

The pricing module imports Rates — the type — and never calls module_rates.register. That is what depending on a contract means here: this module states which capability it needs, and the application decides who provides it. A module that wired its own neighbours would take the composition decision away from the application, and a test that wants to replace one of them would have nowhere to stand.

Composing

examples/learn_extend/modules01_composition.py
"""Walkthrough — composing modules into one application, and replacing one of them in a test.

Run it with:

    python -m learn_extend.modules01_composition
"""

from __future__ import annotations

from learn_extend import module_pricing, module_rates
from learn_extend.module_rates import Rates
from xyberos import App, Settings
from xyberos.http import HttpAdapter
from xyberos.http.testing import create_test_client


def create_app() -> App:
    """The application's own module: it decides which pieces exist, and in which order.

    This function is also the target a deployment points at —
    ``xyberos check --app pricing.app:create_app --app-dir src`` — which is why it takes no
    required arguments. A factory with required arguments is a factory the CLI and an ASGI server
    cannot call.
    """
    app = App(settings=Settings.load(environ={}, overrides={"app": {"name": "pricing"}}))
    module_rates.register(app)
    module_pricing.register(app)
    return app


def main() -> None:
    """Compose two modules, serve them, and then swap one module's answer without editing either."""
    app = create_app()
    print("routes:", [f"{route.method} {route.path}" for route in app.routes])
    print(app.diagnostics().render().strip())

    with create_test_client(HttpAdapter(app)) as client:
        print("GET /rates/SAVE10 ->", client.get("/rates/SAVE10").json())
        print("GET /quote ->", client.get("/quote?code=SAVE10&price=100").json())

        under_test = app.with_overrides({Rates: Rates({"SAVE10": 0.5})})
        with create_test_client(HttpAdapter(under_test)) as tested:
            print(
                "the same routes, a test's rates ->",
                tested.get("/quote?code=SAVE10&price=100").json(),
            )
            print("the clone is a different application:", under_test is not app)


if __name__ == "__main__":
    main()

create_app is the application's own module, and it is the only place that knows which pieces exist. It takes no required arguments, which is not a style preference: xyberos check --app pricing.app:create_app --app-dir src calls it, and so does an ASGI server through an import string. A factory with required arguments is a factory neither of those can call.

routes: ['GET /rates/{code}', 'GET /quote']
providers:
  Rates [app] from application
  Pricing [app] from application
plugins:
  (none)
GET /rates/SAVE10 -> {'code': 'SAVE10', 'discount': 0.1}
GET /quote -> {'code': 'SAVE10', 'price': 100.0, 'quote': 90.0}
the same routes, a test's rates -> {'code': 'SAVE10', 'price': 100.0, 'quote': 50.0}
the clone is a different application: True

The last two lines are the payoff. with_overrides({Rates: ...}) changes what the pricing module sees without editing either module, because the pricing module never asked where Rates came from — only that it exists. A second application built from the same modules and a different Rates is a different application, not a mocked one.

What belongs in a module

Belongs Why
app.provide(...) for the contracts it owns the module that constructs a capability owns its lifetime
Routes that read those capabilities the handler signature is the dependency declaration
Constants and types other modules may import a contract is what makes composition possible
Nothing about the application it is registered with the application owns composition
Does not belong Why
Calling another module's register that is the application's decision, not the module's
Reading the environment or a settings file configuration reaches a module as a constructor argument
Reaching into an internal xyberos module only the public surface is a stable contract
Registering after startup routes and providers are frozen once the application is ready

When a module should become a plugin. The moment somebody else has to install it. A module is code inside your application; a plugin is a package with a name, an api_version, and typed configuration, which the runtime can validate, order, and attribute. The mechanics are the same app.provide and app.get calls — which is why the next page is about the wrapper and not about a second API.