Design Decisions¶
Non-obvious architectural decisions made during the design and review process. Formal ADRs are in Technical → Architecture → ADRs. This page captures implementation-level decisions that do not warrant a full ADR.
Exception Naming Prefix¶
Chosen: All exception subclasses carry the Adapter prefix: AdapterConnectionError, AdapterTimeoutError.
Rejected: ConnectionError, TimeoutError (mirrors stdlib names).
Why: ConnectionError and TimeoutError are Python built-ins since Python 3.3. Defining identically-named classes in a package and importing them with from openframe.core.exceptions import * would shadow the built-ins in any file that does so, producing confusing tracebacks where except ConnectionError catches the wrong class.
Consequences: Slightly more verbose class names. No risk of stdlib shadowing in any consuming code.
super().init(message) Only¶
Chosen: AdapterError.__init__ calls super().__init__(message) with only the message string. Other fields (adapter, operation, cause) are stored as named instance attributes.
Rejected: super().__init__(message, adapter, operation, cause) passing all four args to Exception.__init__.
Why: Exception.__str__ returns str(self.args[0]) when len(self.args) == 1, but returns str(self.args) (a tuple representation) when len(self.args) > 1. Passing all four args produces str(exc) == "('entity not found', 'postgres', 'get', None)" — a raw tuple, not a readable error message.
Consequences: AdapterError requires an explicit __str__ override (implemented). self.args contains only (message,).
TracingProxy _cache as Closure Identity Cache¶
Chosen: _cache stores the _traced closure by method name to avoid repeated closure allocation. The closure itself resolves the method fresh on every invocation via getattr(wrapped, name).
Rejected (v1 original): object.__setattr__(self, name, _traced) — permanently shadowed __dict__ entries, breaking reconnecting adapters.
Rejected (v2 attempt): _cache storing the resolved method object — still a stale snapshot when the wrapped object replaces its method after reconnect.
Why: Reconnecting drivers (asyncpg pools, Redis clients) may replace their own internal method objects after a reconnect. A proxy that holds a reference to the original method object calls dead code. Resolving via getattr on every call ensures the proxy always delegates to whatever the wrapped object's current method is.
Consequences: _cache entries are closures (cheap allocations) rather than method references. The slight overhead of one getattr per async call is the correct trade-off for reconnect safety.
Pure ASGI Middleware¶
Chosen: TelemetryMiddleware uses async def __call__(self, scope, receive, send) — raw ASGI.
Rejected: Starlette's BaseHTTPMiddleware.
Why: BaseHTTPMiddleware has documented limitations around streaming responses and request body access. It buffers responses, which prevents streaming use cases. Pure ASGI middleware receives the raw send callable and can intercept it with a thin wrapper (send_with_telemetry) that captures the status code without buffering.
Consequences: Route template extraction is less convenient — Starlette sets scope["route"] to a Route object only after routing completes; the raw path is used as a fallback. This increases metric cardinality for routes with path parameters but is the correct trade-off for framework independence.
Three Application Wiring Options (unresolved, watch)¶
Three wiring patterns now coexist in openframe-core:
deps.py+lru_cache— module-level cached factory functions. Simple, familiar, no framework. Most existing templates use this.PluginRegistrydirect — explicit registry construction andinitialize_all()call in alifespanhandler. Full control, no abstraction layer.ApplicationBootstrap— subclass-based composition root. Manages thePluginRegistryinternally and addsconfigure()→start()→stop()lifecycle.
The ambiguity is better than before — ApplicationBootstrap has a name,
a docstring, and the correct stop() behaviour (port shutdown +
shutdown_telemetry()). But it is still three options without a clear
hierarchy.
The risk: if the docs don't establish ApplicationBootstrap as the
recommended path — with the other two documented as deliberate
alternatives and the conditions under which you'd choose each — the
ambiguity just gets more documented rather than actually resolved. A new
adapter author reads the code and sees three equivalent-looking patterns
with no signal about which to start from.
The resolution path: update the developer guide and the runtime module
doc to name ApplicationBootstrap as the default starting point, and
explain the two alternatives as escape hatches:
- Use
PluginRegistrydirectly when you need fine-grained control over initialization order or want to avoid the subclass pattern. - Use
deps.py+lru_cachewhen you're wiring a single port without a full startup/shutdown lifecycle (e.g. a simple stateless function).
This is tracked in the roadmap as a documentation task.
ports/ Nine-Module Dependency Chain, Two-Tier (structural watch)¶
openframe.core.ports (previously split across contracts/ and
ports/ — merged in v3.1.0, see ADR-006)
is nine files across two tiers: six primitive modules directly inside
ports/, plus three capability-specific outbound protocol modules in
the ports/outbound/ sub-package, mirroring openframe/core/inbound/
on the driving side of the hexagon:
ports/
capability → context → health → identity → lifecycle → port
outbound/
repository → port
producer → port
consumer → port
Each primitive module imports only from the one below it; each outbound
protocol module imports only from ports/port. The DAG is correct today.
The public import surface is unaffected by the two-tier layout —
from openframe.core.ports import BaseRepository works identically
whether BaseRepository lives directly in ports/ or in
ports/outbound/, because ports/__init__.py re-exports everything.
The concern: nine files for what is one concept — a registrable,
lifecycle-managed, identity-tagged port, plus its outbound domain-method
specialisations. The split is principled (each module has one thing, and
outbound/ groups the capability-specific protocols under a name that
states their role), but it creates a surface that is easy to expand
carelessly. A new module that is not as cleanly separated (for example,
one that imports from two non-adjacent layers, or one that blurs the
concept boundary) would make ports/ harder to reason about than the
current split, not easier.
The intake rule for outbound/: any new outbound port protocol that
targets a specific capability (e.g. BaseSecretsProvider,
BaseObjectStore, BaseFeatureFlagProvider from openframe-infra)
belongs in ports/outbound/, re-exported from the top-level
ports/__init__.py. It does not get its own top-level ports/ module,
and it does not live in the consuming package — port contracts belong
to openframe-core.
The boundary condition: the current module structure is fine as long as each module stays focused on a single, independently nameable concept. The signal to watch for is a new module that:
- Imports from more than one existing
ports/module at the same level (rather than strictly from the layer below it), or - Represents a concept that could have been a dataclass or a small addition to an existing module rather than its own file.
If either of those happens, further consolidation or re-splitting should be considered.
Chosen: OTLP authentication is handled via OTEL_EXPORTER_OTLP_HEADERS env var only. No vendor-specific header construction in openframe-core.
Rejected: _grafana_headers() function from the production template that constructs a Basic auth header from Grafana-specific credentials.
Why: The OTel SDK natively reads OTEL_EXPORTER_OTLP_HEADERS and applies it to every export request. Implementing Grafana-specific header construction in openframe-core couples the package to one specific backend, contradicting the platform-agnostic design. Any OTLP-compatible backend (Grafana Cloud, Honeycomb, Datadog, Jaeger, self-hosted Tempo) works by setting the standard env var.
Consequences: Grafana Cloud users set OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic $(echo -n '<user>:<token>' | base64)". This is documented in the telemetry module docstring.