Skip to content

ADR-001: Framework Architecture Style

Status

Accepted Date: 2026-02-14

Context

The cosalette project needs to provide common infrastructure for 8+ IoT-to-MQTT bridge applications. Each project currently implements its own MQTT lifecycle, logging, error reporting, signal handling, and CLI — resulting in approximately 1,000+ lines of duplicated infrastructure code per project. A decision is needed on how to package and deliver this shared functionality: as a passive library, a plugin system, a configuration-driven engine, or an opinionated framework.

Analysis of the velux2mqtt reference implementation shows the infrastructure-to-domain ratio is heavily skewed: main.py alone is 238 lines of lifecycle wiring, the custom MqttClientAdapter is 356 lines, ErrorPublisher is 251 lines, and JsonFormatter is 105 lines — all of which are generic, non-domain code that repeats across projects.

Decision

Use Inversion of Control with decorator-based registration (FastAPI-inspired framework) because it eliminates the maximum amount of boilerplate while preserving domain purity through the hexagonal architecture boundary.

The central cosalette.App object owns the event loop, signal handlers, MQTT lifecycle, and logging. Project authors register their devices and adapters via decorators (@app.command, @app.device, @app.telemetry) and method calls (app.adapter()), and the framework calls their code — not the other way around.

import cosalette

app = cosalette.App(name="velux2mqtt", version="0.1.0")

@app.device("blind")
async def blind(ctx: cosalette.DeviceContext) -> None:
    ...

Decision Drivers

  • Eliminate ~1,000 lines of duplicated infrastructure per project
  • Consistent behaviour across all 8+ IoT projects (topic layout, logging, error reporting, shutdown flow)
  • FastAPI-like developer experience — decorators, context injection, opinionated defaults
  • Domain layer must remain pure (no framework imports)
  • Each project produces an independent, standalone console application
  • Each project deploys as its own process — no shared runtime or inter-process coupling

Considered Options

Option 1: Library approach

Projects import and call framework functions explicitly. The project owns main() and wires everything together.

  • Advantages: Maximum flexibility, no inversion of control to learn, familiar to all Python developers.
  • Disadvantages: Does not eliminate the composition root boilerplate (~238 lines in velux2mqtt's main.py). Each project must wire MQTT lifecycle, signal handling, and logging setup manually. Consistency depends on discipline, not enforcement.

Option 2: Plugin architecture

A generic host application discovers and loads project code via entry points or plugin protocols.

  • Advantages: Strong separation, standardised extension points.
  • Disadvantages: Over-engineered for a single-developer ecosystem of 8 projects. Plugin discovery adds complexity. Debugging through plugin loading layers is painful. Does not match the "standalone app" deployment requirement.

Option 3: Configuration-driven engine

Projects are defined entirely via YAML/TOML configuration. The engine reads config and executes pre-built device templates.

  • Advantages: Zero code for simple cases, very consistent.
  • Disadvantages: Cannot express arbitrary device logic (e.g., velux2mqtt's homing sequence, position estimation). Falls apart for complex bidirectional devices. Would require a DSL for non-trivial cases, which is worse than Python.

Option 4: Framework with Inversion of Control (chosen)

The framework owns the lifecycle. Projects register devices via decorators and implement domain logic in device functions that receive a DeviceContext.

  • Advantages: Maximum boilerplate elimination (main.py → 2 lines). Consistent behaviour enforced by the framework. FastAPI-like DX with decorators and context injection. Escape hatches via lifecycle hooks (now the lifespan context manager — see Addendum) and raw MQTT access.
  • Disadvantages: Framework lock-in — migrating away requires reimplementing infrastructure. Debugging through framework layers adds indirection. Maintenance burden of a real package with CI, versioning, and releases.

Decision Matrix

Criterion Library Plugin Config-Driven Framework (IoC)
Boilerplate elimination 2 3 5 5
Consistency enforcement 2 4 5 5
Flexibility for complex 5 3 1 4
Developer experience 3 2 3 5
Maintenance burden 5 2 3 3
Domain purity preservation 3 4 2 5

Scale: 1 (poor) to 5 (excellent)

Consequences

Positive

  • Each project's main.py reduces to ~2 lines (from myapp.app import app; app.run())
  • MQTT lifecycle, signal handling, logging, error publication, CLI, and health reporting are provided once and shared across all projects
  • New projects can be scaffolded rapidly with minimal infrastructure code
  • Decorator-based API provides clear, readable device declarations
  • The framework acts as the composition root, eliminating manual wiring

Negative

  • Projects are coupled to cosalette's conventions — migrating away means reimplementing all infrastructure
  • Stack traces cross cosalette internals, adding debugging indirection
  • cosalette is a real package with its own release cycle, CI, and maintenance burden
  • New contributors must learn cosalette's conventions before contributing to any project

2026-02-14

Addendum — Lifespan Pattern (2026-02-19)

The @app.on_startup and @app.on_shutdown decorator-based lifecycle hooks mentioned in the original decision have been replaced by a single lifespan context manager passed to the App constructor:

App(name="myapp", version="1.0.0", lifespan=my_lifespan)

This follows the same pattern established by Starlette/FastAPI's lifespan API. The context manager's startup code (before yield) runs before devices start; shutdown code (after yield) runs after devices stop. This change improves resource safety (paired init/cleanup in one function) and reduces API surface.

Additionally, signature-based handler injection was introduced: device handlers now declare only the parameters they need via type annotations. Zero-parameter handlers are valid. ctx: DeviceContext remains supported but is no longer required.

@app.command() was added as a third decorator for simple command handlers that don't need a long-running coroutine. topic and payload parameters are optional — handlers declare only the MQTT message values they need.

See the Lifespan guide, Lifecycle concept, and Command Device guide for details.

2026-02-20