ADR-044: Public Router and Composition API¶
Status¶
Accepted Date: 2026-05-06 | Amended Date: 2026-08-07
Context¶
cosalette is a decorator-first MQTT framework. All operation registrations currently attach directly to an App instance (@app.telemetry, @app.command, @app.device, @app.stream, @app.periodic, @app.react). This model works well for single-file or small applications but has no composition primitive for multi-module projects. There is no way to define a group of related operations in a separate module and attach them to an App with a shared topic prefix, tags, or adapter declarations without importing the App instance directly, which creates circular import risk and makes operation grouping an informal convention.
The upcoming breaking release (cos-s2q epic) targets an idiomatic FastAPI-for-MQTT experience. FastAPI's APIRouter / app.include_router pattern is the dominant ergonomic model for composing route groups in Python web frameworks and is well understood by the target audience. Adopting it for cosalette closes the ergonomic gap without requiring users to learn a new mental model.
The internal TopicRouter class (cosalette._mqtt._router.TopicRouter) already exists as a subscription-dispatch primitive but is not suitable for exposure as the public composition API: it carries MQTT subscription semantics and internal wiring that would become public contract obligations if exported.
Key constraints captured in decision bead cos-s2q.1:
- App-level decorators must remain first-class and must not be deprecated in this release.
- A Router prefix must be a single MQTT topic segment (no / character, no wildcards).
- Tags are additive across three layers: Router constructor, include_router call, and operation decorator.
- The dependencies= parameter is reserved for the dependency-injection epic (cos-ebc) and must raise NotImplementedError if passed before that epic ships.
- Stream adapter validation moves from decoration time to application startup (implemented in cos-s2q.4).
- Nested router composition (Router.include_router) is deferred out of scope.
- The public validation entry-point name is deferred to cos-s2q.4.
Decision¶
Introduce cosalette.Router as the public composition primitive and App.include_router(router, *, prefix=None, tags=None, dependencies=None, adapters=None) as the inclusion method on App. All MQTT-native operation decorators present on App (telemetry, command, device, stream, periodic, react) are replicated on Router with identical signatures and semantics. App-level decorators remain first-class and are not deprecated. The Router prefix is restricted to a single MQTT topic segment, validated by the existing validate_mqtt_name rules. Tags accumulate additively from Router constructor through include_router call to operation decorator, deduplicated while preserving insertion order. The dependencies= parameter is reserved and raises NotImplementedError until cos-ebc ships. Adapter declarations in adapters= are merged into the app's registry at include time with conflict detection. include_router uses snapshot semantics: registrations are captured at call time. Stream adapter validation is deferred to application startup (cos-s2q.4). Nested routers are deferred.
# sensors.py — define operations in a separate module
router = cosalette.Router(prefix="sensors", tags=["environment"])
@router.telemetry("temperature", interval=30)
async def read_temperature() -> dict:
return {"celsius": await sensor.read()}
@router.command("calibrate")
async def calibrate(ctx: cosalette.CommandContext) -> None:
await sensor.calibrate()
# main.py — compose modules into the app
app = cosalette.App("bridge")
app.include_router(router)
# → publishes to: bridge/sensors/temperature/state
# → subscribes to: bridge/sensors/calibrate/cmd
# Small-app pattern: still idiomatic, unchanged
@app.telemetry("heartbeat", interval=60)
async def heartbeat() -> dict:
return {"up": True}
Decision Drivers¶
- Multi-module cosalette applications have no composition primitive; each module must import the
Appinstance directly, creating circular-import risk and tight coupling - FastAPI's
APIRouter/app.include_routerpattern is the recognised ergonomic model for Python framework composition and matches the target audience's mental model - MQTT-native operation archetypes (
telemetry,command,device,stream,periodic,react) must remain first-class rather than being replaced by generic HTTP-style route objects - App-level decorators service the single-file small-app pattern and must not be removed or deprecated in a breaking release that is otherwise additive
- Topic composition must be deterministic and MQTT-safe: prefixes are validated segments, tags accumulate without surprising overrides, and adapter conflicts surface at include time rather than at startup
- The
dependencies=boundary with thecos-ebcepic must be explicit in the public API so that implementers and downstream AI tooling have a clear, authoritative reference
Considered Options¶
Option 1: Public Router with MQTT-native decorators (chosen)¶
Introduce cosalette.Router as an explicit public class and App.include_router(...) as the composition method. Router exposes the full MQTT-native decorator surface (telemetry, command, device, stream, periodic, react) identical to App. Prefix is a single topic segment. Tags are additive. App-level decorators remain unchanged and first-class.
- Advantages: Matches the FastAPI
APIRoutermental model exactly — low learning curve for the target audience; MQTT-native decorator names are preserved on Router, keeping the framework vocabulary consistent; Additive change: single-file apps using only App decorators require no changes; Prefix-as-topic-segment constraint is MQTT-safe and easy to document with a concrete composition example; Snapshot semantics at include time make behaviour deterministic and consistent with FastAPI; Clear boundary withcos-ebc(dependencies=reserved) prevents premature API surface expansion - Disadvantages: Two registration surfaces (
AppandRouter) share decorator logic; refactor to a shared mixin is required incos-s2q.3to avoid divergence; Multi-level topic namespacing requires multiple routers rather than a single multi-segment prefix string; Nested router composition is a user expectation from FastAPI that is explicitly deferred, which may frustrate early adopters
Option 2: Generic topic-route operations¶
Replace all MQTT-native decorators on both App and Router with a single generic @app.route(topic_pattern, ...) or @app.topic(...) decorator. Operation type (telemetry, command, etc.) is inferred from the handler signature or specified via a mode= parameter.
- Advantages: Single registration surface reduces API surface area; Familiar to developers coming from generic HTTP frameworks
- Disadvantages: Destroys the MQTT-native vocabulary that is a core cosalette differentiator (
telemetry,command,devicemap directly to MQTT archetypes in ADR-010); Loses static type information carried by archetype-specific decorator signatures; Breaking change removes the existing App decorator API, violating the compatibility stance; Degrades docs/AI teachability: generic topic routes do not communicate intent or archetype to human readers or AI tooling
Option 3: App-only with improved docs and contracts¶
Keep the existing app-level decorator API unchanged. Improve documentation, add explicit module-organisation guides, and publish a pattern library showing how to structure multi-module apps using App injection via dependency injection or module-level accessors.
- Advantages: No new API surface — zero migration cost; Eliminates the implementation risk of the Router and include_router changes
- Disadvantages: Does not solve the circular-import problem for multi-module apps; Lacks a testable composition boundary: each module still requires an App reference at import time; Misses the FastAPI-for-MQTT ergonomic target that is the stated goal of the
cos-s2qepic; Downstream frameworks and AI tooling (cos-zo3) cannot generate router-composition scaffolding without a canonical API
Option 4: Expose internal TopicRouter as public primitive¶
Promote the existing internal cosalette._mqtt._router.TopicRouter to a public cosalette.TopicRouter and document it as the composition entry point.
- Advantages: Reuses existing code with minimal new implementation; No parallel decorator surface to maintain
- Disadvantages:
TopicRoutercarries MQTT subscription-dispatch internals that are not appropriate public API surface; exposing it creates a leaky abstraction; The nameTopicRouteris already in use internally and would become a public contract obligation, blocking future refactors; Does not replicate the MQTT-native decorator surface, so callers must use lower-level registration primitives; Diverges from the FastAPI naming convention, reducing the ergonomic benefit of the change
Decision Matrix¶
| Criterion | Public Router with MQTT-native decorators | Generic topic-route operations | App-only with improved docs and contracts | Expose internal TopicRouter as public primitive |
|---|---|---|---|---|
| FastAPI-like ergonomics | 5 | 4 | 1 | 2 |
| MQTT domain fit | 5 | 2 | 5 | 3 |
| Migration cost | 5 | 1 | 5 | 3 |
| Implementation risk | 3 | 2 | 5 | 4 |
| Docs and AI teachability | 5 | 2 | 3 | 2 |
| Future typed-contract compatibility | 4 | 3 | 2 | 2 |
Scale: 1 (poor) to 5 (excellent)
Consequences¶
Positive¶
- Multi-module cosalette apps gain a clean, testable composition boundary:
Routerinstances can be defined and unit-tested in isolation without importing or mocking anApp - The FastAPI-for-MQTT ergonomic goal of the
cos-s2qepic is achieved;cosalette.RouterandApp.include_routerare immediately recognisable to FastAPI users - Single-file small-app patterns using app-level decorators are unaffected; no migration is required for existing codebases
- MQTT topic composition is deterministic: prefix validation, tag accumulation, and snapshot semantics are fully specified in this ADR and in the design notes of
cos-s2q.1 - Adapter conflict detection surfaces at
include_router()call time rather than at startup, providing early feedback without requiring a full app bootstrap - The
cos-zo3AI guidance system (cosalette ai) and Zensical documentation can generate router-composition scaffolding with a canonical, documented API surface (tracked incos-zo3.3andcos-zo3.6) - The reserved
dependencies=boundary withcos-ebcis explicit in the public API and in this ADR, preventing accidental coupling to the dependency-injection epic during Router implementation
Negative¶
- Two registration surfaces (
AppandRouter) must be kept in sync; a shared internal registration mixin is required incos-s2q.3to prevent decorator signature divergence - Stream adapter validation moves from decoration time to application startup (
cos-s2q.4), which is a breaking change for tests that relied on@app.streamraisingTypeErrorat import time - Nested router composition (
Router.include_router) is explicitly deferred; users expecting full FastAPIAPIRouterfeature parity may file follow-on issues - The public validation entry-point name is deferred to
cos-s2q.4: tests and templates that need pre-flight validation must useapp.run()until that task ships - Documentation must be updated in multiple places: public API reference, migration guide, getting-started guides,
cosalette aihelp topic (cos-zo3.3),ai primewhat's-new entry (cos-zo3.6), and Zensical site (cos-bnq) - AsyncAPI schema generation (
cos-bnq) depends on the tag vocabulary established here; tag naming conventions (lowercase, hyphen-separated) must be enforced now to avoid a later rename
Amendment (2026-08-07) — Corrective¶
Rationale: The reserved dependencies= parameter is retracted and removed from the public API. ADR-044 reserved dependencies= on Router.__init__, App.include_router, and every Router operation decorator, requiring it to raise NotImplementedError until the dependency-injection epic (cos-ebc) shipped. cos-ebc closed on 2026-05-09 with all five children complete, delivering dependency injection through parameter-level markers — Depends(), Payload(), Topic(), Message() — as recorded in ADR-046. The Router implementation landed on 2026-05-10, one day after the epic it deferred to had already completed, so the reservation has never had a live target. In its shipped form the parameter is a defect on three counts: it advertises a capability the framework does not deliver and points users at a closed epic; the value it accepts is stored in a self._dependencies field that is never read anywhere in the codebase; and it is asymmetric with App, whose operation decorators never accepted the keyword at all, so @router.command(dependencies=[...]) raised NotImplementedError while @app.command(dependencies=[...]) raised TypeError. Router-level dependency declaration is not being replaced by a new mechanism: DI is expressed per handler parameter via Depends(), which is the pattern ADR-046 already establishes and which needs no router-level collection point.
Justification for amendment (not supersession): Supersession is not warranted because the decision recorded in ADR-044 is otherwise unchanged and remains fully implemented:
cosalette.Routeras the public composition primitive,App.include_routeras the inclusion method, MQTT-native operation decorators replicated fromApp, single-segment prefix validation, additive tag accumulation across the three layers, snapshot inclusion semantics, and adapter merging with conflict detection all stand exactly as decided. Marking the whole ADR superseded would misrepresent the architecture by implying the composition API itself had been replaced, when a single reserved-parameter clause is being retracted. The impact analysis supports a corrective amendment: the parameter never had an implementation, so the only reachable behaviour for a non-empty argument was an immediateNotImplementedErrorat decoration or construction time. No working downstream code can depend on it — any caller that passed a real value already crashed at import. Removal changes the failure mode fromNotImplementedErrortoTypeError: unexpected keyword argument, which is a signature-level breaking change shipped under a MINOR version bump, and the migration path (Depends()) already exists and is documented. The change touches one module tree (cosalette._router) plus theinclude_routersignature onApp.
Revised Decision¶
Remove the reserved dependencies= parameter from the entire public composition surface. Router.__init__ and App.include_router become (*, prefix=None, tags=None, adapters=None); the Router operation decorators (telemetry, command, device, stream, periodic) drop the keyword entirely, matching react, which never accepted it. The dead self._dependencies field is deleted rather than read. Passing dependencies= now raises TypeError: unexpected keyword argument from Python's own argument binding, identically on Router and App, which removes the asymmetry ADR-044 left behind. Dependency injection is declared per handler parameter with Depends() (ADR-046) and needs no router-level or include-level collection point; the rest of the ADR-044 decision is unaffected. If a future need for router-scoped dependency declaration is established, it will be introduced as an additive change with a working implementation behind it and recorded in its own ADR — the framework does not carry reserved keywords for unbuilt features.
# Before — reserved keyword that could only ever raise
router = cosalette.Router(prefix="sensors", dependencies=None)
@router.command("calibrate", dependencies=[]) # NotImplementedError if non-empty
async def calibrate() -> None: ...
app.include_router(router, dependencies=None)
# After — the keyword is gone; DI is per parameter
router = cosalette.Router(prefix="sensors", tags=["environment"])
@router.command("calibrate")
async def calibrate(
sensor: Annotated[Sensor, cosalette.Depends(get_sensor)],
) -> None:
await sensor.calibrate()
app.include_router(router, tags=["production"])
Editorial note (2026-08-07)
The constraint in the Context section of this ADR — "The dependencies= parameter is reserved for the dependency-injection epic (cos-ebc) and must raise NotImplementedError if passed before that epic ships" — is retracted by this amendment, along with the corresponding clauses in the original Decision, Decision Drivers, Option 1 advantages, and Positive Consequences. Those passages are preserved above as the historical record of the 2026-05-06 decision and must be read together with this amendment. Implemented in cos-v1dj.3.
Additional Positive Consequences¶
- The public composition surface no longer advertises a capability the framework does not deliver, and no public parameter references a closed epic (
cos-ebc) RouterandAppnow behave identically for the removed keyword — both raiseTypeError: unexpected keyword argument— closing the asymmetry that ADR-044 introduced by puttingdependencies=onRouterbut not onApp's operation decorators- The dead
Router._dependenciesfield is gone; there is no stored state that is written and never read - Dependency injection has exactly one documented mechanism (
Depends()per handler parameter, ADR-046), so AI tooling, generated scaffolding, and documentation no longer present two competing answers to the same question - Removing the guard clauses deletes five near-identical validation blocks from the Router decorator modules and shrinks each decorator signature and docstring
Additional Negative Consequences¶
- Breaking change: any code passing
dependencies=toRouter(...),App.include_router(...), or a Router operation decorator now raisesTypeErrorat call time instead ofNotImplementedError. In practice onlydependencies=Noneanddependencies=[]were reachable without an exception, so affected call sites simply delete the argument - Router-scoped dependency declaration is no longer signposted anywhere in the API, so FastAPI users who expect
APIRouter(dependencies=[...])get no in-signature hint thatDepends()is the cosalette answer; the router documentation andcosalette ai helptopics must carry that pointer instead - If router-scoped dependency declaration is later wanted, it must be reintroduced as a new parameter rather than filling in a placeholder that callers were already passing