openframe-adapters 2.0.2__tar.gz → 2.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openframe-adapters
3
- Version: 2.0.2
3
+ Version: 2.0.3
4
4
  Summary: OpenFrame Microservice Suite - adapter meta-package. Install adapters by name or group.
5
5
  Author-email: Furious Meteors Engineering <engineering@furiousmeteors.dev>
6
6
  Maintainer-email: Furious Meteors Engineering <engineering@furiousmeteors.dev>
@@ -184,112 +184,134 @@ Every adapter wires into your service through a single file — `deps.py` or
184
184
  that knows which adapter is active. Routes and services never import adapters
185
185
  directly.
186
186
 
187
- The rule is simple:
187
+ Wiring goes through `openframe-core`'s `ApplicationBootstrap`
188
+ (requires `openframe-core>=3.3`) — one recommended class, at two levels of
189
+ ceremony:
188
190
 
189
- > **One adapter** — wire directly with `lru_cache`.
190
- > **Two or more adapters** — use `PluginRegistry`.
191
- > The trigger to upgrade is adding a second adapter.
191
+ > **One or a few adapters, no per-adapter config needed** — `ApplicationBootstrap.compose(*plugins)`. No subclass.
192
+ > **An adapter needs its own `config=`/`init_timeout=`, or registration order depends on a runtime condition** — subclass with `configure()`.
193
+ > Either way, `ApplicationBootstrap` manages startup ordering, health aggregation, and graceful shutdown (including telemetry flush) across all adapters — you never hand-roll that part.
192
194
 
193
- ### Stage 1 — One adapter
194
-
195
- Install one adapter and wire it directly. Four lines. No registry needed.
195
+ ### One adapter — `compose()`, no subclass
196
196
 
197
197
  ```python
198
198
  # bootstrap/dependencies.py
199
- from functools import lru_cache
200
- from openframe.adapters.db.postgres import PostgresRepository, PostgresSettings
199
+ from openframe.adapters.db.postgres import PostgresPlugin, PostgresSettings
200
+ from openframe.core.ports import Capability
201
+ from openframe.core.runtime import ApplicationBootstrap
201
202
  from openframe.core.tracing import TracingProxy
202
203
  from src.adapters.item_repository import ItemPostgresRepository
203
204
  from src.application.services.item_service import ItemService
204
205
 
205
- @lru_cache(maxsize=1)
206
- def _get_repository() -> ItemPostgresRepository:
207
- return ItemPostgresRepository(PostgresSettings())
206
+ app = ApplicationBootstrap.compose(
207
+ PostgresPlugin(PostgresSettings(), repository_class=ItemPostgresRepository)
208
+ )
208
209
 
209
210
  def get_item_service() -> ItemService:
210
- return ItemService(TracingProxy(_get_repository(), prefix="repository.item"))
211
+ repo = TracingProxy(app.get(Capability.PERSISTENCE).get_repository(), prefix="repository.item")
212
+ return ItemService(repo)
211
213
  ```
212
214
 
213
215
  `PostgresSettings()` reads `DATABASE_URL` from env at startup.
214
- `lru_cache(maxsize=1)` constructs the repository once per process.
215
- `TracingProxy` wraps it for automatic OTel spans on every call.
216
+ `TracingProxy` wraps the repository for automatic OTel spans on every call.
216
217
 
217
- ### Stage 2 — Two or more adapters
218
+ ### Two or more adapters — still `compose()`, or subclass if you need per-adapter config
218
219
 
219
- When a second adapter is needed, replace `lru_cache` with `PluginRegistry`.
220
- The registry handles startup ordering, health aggregation, and graceful
221
- shutdown across all adapters.
220
+ `compose()` accepts any number of ports — pass them all in one call:
222
221
 
223
222
  ```python
224
223
  # bootstrap/dependencies.py
225
224
  from openframe.adapters.db.postgres import PostgresPlugin, PostgresSettings
226
225
  from openframe.adapters.db.redis import RedisPlugin, RedisSettings
227
- from openframe.core.plugins import PluginRegistry
226
+ from openframe.core.ports import Capability
227
+ from openframe.core.runtime import ApplicationBootstrap
228
228
  from openframe.core.tracing import TracingProxy
229
229
  from src.application.services.item_service import ItemService
230
230
  from src.application.services.session_service import SessionService
231
231
 
232
- _registry: PluginRegistry | None = None
233
-
234
- async def initialise() -> None:
235
- global _registry
236
- _registry = PluginRegistry()
237
- _registry.register(PostgresPlugin(PostgresSettings())) # capability: Capability.PERSISTENCE
238
- _registry.register(RedisPlugin(RedisSettings())) # capability: Capability.CACHE
239
- await _registry.initialize_all() # fails fast if any backend unreachable
240
-
241
- async def shutdown() -> None:
242
- if _registry:
243
- await _registry.shutdown_all() # never raises
232
+ app = ApplicationBootstrap.compose(
233
+ PostgresPlugin(PostgresSettings()), # capability: Capability.PERSISTENCE
234
+ RedisPlugin(RedisSettings()), # capability: Capability.CACHE
235
+ )
244
236
 
245
237
  def get_item_service() -> ItemService:
246
- repo = TracingProxy(
247
- _registry.get("persistence").get_repository(),
248
- prefix="repository.item",
249
- )
238
+ repo = TracingProxy(app.get(Capability.PERSISTENCE).get_repository(), prefix="repository.item")
250
239
  return ItemService(repo)
251
240
 
252
241
  def get_session_service() -> SessionService:
253
- cache = TracingProxy(
254
- _registry.get("cache").get_repository(),
255
- prefix="cache.session",
256
- )
242
+ cache = TracingProxy(app.get(Capability.CACHE).get_repository(), prefix="cache.session")
257
243
  return SessionService(cache)
258
244
  ```
259
245
 
260
- Wire `initialise()` and `shutdown()` in your FastAPI lifespan:
246
+ Reach for a subclass instead once an adapter needs its own `config=` mapping,
247
+ a per-adapter `init_timeout=`, or registration order that depends on a
248
+ runtime condition:
249
+
250
+ ```python
251
+ class AppBootstrap(ApplicationBootstrap):
252
+ def configure(self) -> None:
253
+ self.register(PostgresPlugin(PostgresSettings()), config={"pool_hint": "primary"})
254
+ self.register(RedisPlugin(RedisSettings()), init_timeout=5.0)
255
+
256
+ app = AppBootstrap()
257
+ ```
258
+
259
+ For the deliberate multi-port-per-capability case (e.g. primary + replica
260
+ Postgres), use `app.get_all(Capability.PERSISTENCE)` or, for anything neither
261
+ covers, the underlying registry directly via `app.registry`.
262
+
263
+ Wire `start()` and `stop()` in your FastAPI lifespan:
261
264
 
262
265
  ```python
263
266
  from contextlib import asynccontextmanager
264
267
  from fastapi import FastAPI
265
268
  from openframe.core.middleware import TelemetryMiddleware
266
269
  from openframe.core.telemetry import setup_telemetry
267
- from src.bootstrap import dependencies
270
+ from src.bootstrap.dependencies import app as bootstrap
268
271
 
269
272
  @asynccontextmanager
270
273
  async def lifespan(app: FastAPI):
271
274
  setup_telemetry()
272
- await dependencies.initialise()
275
+ await bootstrap.start() # initializes every registered adapter — fails fast if any backend unreachable
273
276
  yield
274
- await dependencies.shutdown()
277
+ await bootstrap.stop() # shuts down every adapter, then flushes telemetry — never raises
275
278
 
276
279
  app = FastAPI(lifespan=lifespan)
277
280
  app.add_middleware(TelemetryMiddleware)
278
281
  ```
279
282
 
283
+ ### Domain subclass registration
284
+
285
+ Every `*Plugin` supports registering a domain-specific subclass instead of
286
+ the plain base adapter class — `repository_class` (Postgres, Mongo, Redis),
287
+ `producer_class` and `consumer_class` (Kafka):
288
+
289
+ ```python
290
+ app = ApplicationBootstrap.compose(
291
+ PostgresPlugin(PostgresSettings(), table="items", repository_class=ItemRepository),
292
+ RedisPlugin(RedisSettings(), repository_class=SessionRepository),
293
+ KafkaPlugin(KafkaSettings(), producer_class=ArtifactEventProducer, consumer_class=OrderEventConsumer),
294
+ )
295
+ ```
296
+
297
+ `get_repository()` / `get_producer()` / `make_consumer()` then return an
298
+ instance of the subclass you passed in, not the plain base class — so
299
+ overridden entity mapping (`_doc_to_entity`, `_entity_to_doc`, `_serialise`,
300
+ `_deserialise`, etc.) is preserved end-to-end through the registry.
301
+
280
302
  ### Plugin capabilities
281
303
 
282
304
  Every `*Plugin` class declares a `capability` attribute — a typed
283
305
  `openframe.core.ports.Capability` enum member (not a raw string as of
284
306
  `openframe-core` v3.0; the module was `openframe.core.contracts` before
285
307
  v3.1, which merged it into `openframe.core.ports`). This is the key used
286
- by `registry.get()`:
308
+ by `ApplicationBootstrap.get()` / `PluginRegistry.get()`:
287
309
 
288
310
  ```python
289
311
  from openframe.core.ports import Capability
290
312
 
291
- registry.get(Capability.PERSISTENCE) # → PostgresPlugin / MongoPlugin
292
- registry.get(Capability.CACHE) # → RedisPlugin
313
+ app.get(Capability.PERSISTENCE) # → PostgresPlugin / MongoPlugin
314
+ app.get(Capability.CACHE) # → RedisPlugin
293
315
  ```
294
316
 
295
317
  The capability taxonomy is a closed enum shared across the entire OpenFrame
@@ -314,29 +336,32 @@ keep working, but new code should key on the enum member directly.
314
336
  ### The env-var swap exception
315
337
 
316
338
  Switching between adapters via an environment variable (`PERSISTENCE_BACKEND=postgres`
317
- vs `PERSISTENCE_BACKEND=mongo`) is still Stage 1 — because only one adapter
318
- is active at any moment. Use `lru_cache` direct wiring with a conditional:
339
+ vs `PERSISTENCE_BACKEND=mongo`) still only ever has **one** adapter active at
340
+ a time — `compose()` still applies, just with the chosen plugin decided
341
+ before the call:
319
342
 
320
343
  ```python
321
- @lru_cache(maxsize=1)
322
- def _get_repository():
344
+ def _make_plugin():
323
345
  backend = os.environ.get("PERSISTENCE_BACKEND", "postgres")
324
346
  if backend == "postgres":
325
- return ItemPostgresRepository(PostgresSettings())
347
+ return PostgresPlugin(PostgresSettings(), repository_class=ItemPostgresRepository)
326
348
  elif backend == "mongo":
327
- return ItemMongoRepository(MongoSettings())
349
+ return MongoPlugin(MongoSettings(), repository_class=ItemMongoRepository)
328
350
  raise ValueError(f"Unknown PERSISTENCE_BACKEND: {backend!r}")
351
+
352
+ app = ApplicationBootstrap.compose(_make_plugin())
329
353
  ```
330
354
 
331
- `PluginRegistry` is for services that need multiple adapters simultaneously,
332
- not for services that swap between adapters via configuration.
355
+ Both backends still register under the same `Capability.PERSISTENCE` — the
356
+ rest of your service (`app.get(Capability.PERSISTENCE)`) never knows which
357
+ one is active.
333
358
 
334
359
  ### Full wiring reference
335
360
 
336
- For the complete guide — upgrade path from Stage 1 to Stage 2, lifespan
337
- wiring, and architecture diagrams — see the
338
- [Composition Root](https://furious-meteors.github.io/openframe-core/developer-guide/composition-root/)
339
- page in the `openframe-core` documentation.
361
+ For the complete guide — the three-tier model (`compose()` → `configure()`
362
+ subclass → `.registry` escape hatch), lifespan wiring, and architecture
363
+ diagrams — see [How It Works § Choosing a Wiring Pattern](https://furious-meteors.github.io/openframe-core/developer-guide/how-it-works/#choosing-a-wiring-pattern)
364
+ in the `openframe-core` documentation.
340
365
 
341
366
  ---
342
367
 
@@ -93,112 +93,134 @@ Every adapter wires into your service through a single file — `deps.py` or
93
93
  that knows which adapter is active. Routes and services never import adapters
94
94
  directly.
95
95
 
96
- The rule is simple:
96
+ Wiring goes through `openframe-core`'s `ApplicationBootstrap`
97
+ (requires `openframe-core>=3.3`) — one recommended class, at two levels of
98
+ ceremony:
97
99
 
98
- > **One adapter** — wire directly with `lru_cache`.
99
- > **Two or more adapters** — use `PluginRegistry`.
100
- > The trigger to upgrade is adding a second adapter.
100
+ > **One or a few adapters, no per-adapter config needed** — `ApplicationBootstrap.compose(*plugins)`. No subclass.
101
+ > **An adapter needs its own `config=`/`init_timeout=`, or registration order depends on a runtime condition** — subclass with `configure()`.
102
+ > Either way, `ApplicationBootstrap` manages startup ordering, health aggregation, and graceful shutdown (including telemetry flush) across all adapters — you never hand-roll that part.
101
103
 
102
- ### Stage 1 — One adapter
103
-
104
- Install one adapter and wire it directly. Four lines. No registry needed.
104
+ ### One adapter — `compose()`, no subclass
105
105
 
106
106
  ```python
107
107
  # bootstrap/dependencies.py
108
- from functools import lru_cache
109
- from openframe.adapters.db.postgres import PostgresRepository, PostgresSettings
108
+ from openframe.adapters.db.postgres import PostgresPlugin, PostgresSettings
109
+ from openframe.core.ports import Capability
110
+ from openframe.core.runtime import ApplicationBootstrap
110
111
  from openframe.core.tracing import TracingProxy
111
112
  from src.adapters.item_repository import ItemPostgresRepository
112
113
  from src.application.services.item_service import ItemService
113
114
 
114
- @lru_cache(maxsize=1)
115
- def _get_repository() -> ItemPostgresRepository:
116
- return ItemPostgresRepository(PostgresSettings())
115
+ app = ApplicationBootstrap.compose(
116
+ PostgresPlugin(PostgresSettings(), repository_class=ItemPostgresRepository)
117
+ )
117
118
 
118
119
  def get_item_service() -> ItemService:
119
- return ItemService(TracingProxy(_get_repository(), prefix="repository.item"))
120
+ repo = TracingProxy(app.get(Capability.PERSISTENCE).get_repository(), prefix="repository.item")
121
+ return ItemService(repo)
120
122
  ```
121
123
 
122
124
  `PostgresSettings()` reads `DATABASE_URL` from env at startup.
123
- `lru_cache(maxsize=1)` constructs the repository once per process.
124
- `TracingProxy` wraps it for automatic OTel spans on every call.
125
+ `TracingProxy` wraps the repository for automatic OTel spans on every call.
125
126
 
126
- ### Stage 2 — Two or more adapters
127
+ ### Two or more adapters — still `compose()`, or subclass if you need per-adapter config
127
128
 
128
- When a second adapter is needed, replace `lru_cache` with `PluginRegistry`.
129
- The registry handles startup ordering, health aggregation, and graceful
130
- shutdown across all adapters.
129
+ `compose()` accepts any number of ports — pass them all in one call:
131
130
 
132
131
  ```python
133
132
  # bootstrap/dependencies.py
134
133
  from openframe.adapters.db.postgres import PostgresPlugin, PostgresSettings
135
134
  from openframe.adapters.db.redis import RedisPlugin, RedisSettings
136
- from openframe.core.plugins import PluginRegistry
135
+ from openframe.core.ports import Capability
136
+ from openframe.core.runtime import ApplicationBootstrap
137
137
  from openframe.core.tracing import TracingProxy
138
138
  from src.application.services.item_service import ItemService
139
139
  from src.application.services.session_service import SessionService
140
140
 
141
- _registry: PluginRegistry | None = None
142
-
143
- async def initialise() -> None:
144
- global _registry
145
- _registry = PluginRegistry()
146
- _registry.register(PostgresPlugin(PostgresSettings())) # capability: Capability.PERSISTENCE
147
- _registry.register(RedisPlugin(RedisSettings())) # capability: Capability.CACHE
148
- await _registry.initialize_all() # fails fast if any backend unreachable
149
-
150
- async def shutdown() -> None:
151
- if _registry:
152
- await _registry.shutdown_all() # never raises
141
+ app = ApplicationBootstrap.compose(
142
+ PostgresPlugin(PostgresSettings()), # capability: Capability.PERSISTENCE
143
+ RedisPlugin(RedisSettings()), # capability: Capability.CACHE
144
+ )
153
145
 
154
146
  def get_item_service() -> ItemService:
155
- repo = TracingProxy(
156
- _registry.get("persistence").get_repository(),
157
- prefix="repository.item",
158
- )
147
+ repo = TracingProxy(app.get(Capability.PERSISTENCE).get_repository(), prefix="repository.item")
159
148
  return ItemService(repo)
160
149
 
161
150
  def get_session_service() -> SessionService:
162
- cache = TracingProxy(
163
- _registry.get("cache").get_repository(),
164
- prefix="cache.session",
165
- )
151
+ cache = TracingProxy(app.get(Capability.CACHE).get_repository(), prefix="cache.session")
166
152
  return SessionService(cache)
167
153
  ```
168
154
 
169
- Wire `initialise()` and `shutdown()` in your FastAPI lifespan:
155
+ Reach for a subclass instead once an adapter needs its own `config=` mapping,
156
+ a per-adapter `init_timeout=`, or registration order that depends on a
157
+ runtime condition:
158
+
159
+ ```python
160
+ class AppBootstrap(ApplicationBootstrap):
161
+ def configure(self) -> None:
162
+ self.register(PostgresPlugin(PostgresSettings()), config={"pool_hint": "primary"})
163
+ self.register(RedisPlugin(RedisSettings()), init_timeout=5.0)
164
+
165
+ app = AppBootstrap()
166
+ ```
167
+
168
+ For the deliberate multi-port-per-capability case (e.g. primary + replica
169
+ Postgres), use `app.get_all(Capability.PERSISTENCE)` or, for anything neither
170
+ covers, the underlying registry directly via `app.registry`.
171
+
172
+ Wire `start()` and `stop()` in your FastAPI lifespan:
170
173
 
171
174
  ```python
172
175
  from contextlib import asynccontextmanager
173
176
  from fastapi import FastAPI
174
177
  from openframe.core.middleware import TelemetryMiddleware
175
178
  from openframe.core.telemetry import setup_telemetry
176
- from src.bootstrap import dependencies
179
+ from src.bootstrap.dependencies import app as bootstrap
177
180
 
178
181
  @asynccontextmanager
179
182
  async def lifespan(app: FastAPI):
180
183
  setup_telemetry()
181
- await dependencies.initialise()
184
+ await bootstrap.start() # initializes every registered adapter — fails fast if any backend unreachable
182
185
  yield
183
- await dependencies.shutdown()
186
+ await bootstrap.stop() # shuts down every adapter, then flushes telemetry — never raises
184
187
 
185
188
  app = FastAPI(lifespan=lifespan)
186
189
  app.add_middleware(TelemetryMiddleware)
187
190
  ```
188
191
 
192
+ ### Domain subclass registration
193
+
194
+ Every `*Plugin` supports registering a domain-specific subclass instead of
195
+ the plain base adapter class — `repository_class` (Postgres, Mongo, Redis),
196
+ `producer_class` and `consumer_class` (Kafka):
197
+
198
+ ```python
199
+ app = ApplicationBootstrap.compose(
200
+ PostgresPlugin(PostgresSettings(), table="items", repository_class=ItemRepository),
201
+ RedisPlugin(RedisSettings(), repository_class=SessionRepository),
202
+ KafkaPlugin(KafkaSettings(), producer_class=ArtifactEventProducer, consumer_class=OrderEventConsumer),
203
+ )
204
+ ```
205
+
206
+ `get_repository()` / `get_producer()` / `make_consumer()` then return an
207
+ instance of the subclass you passed in, not the plain base class — so
208
+ overridden entity mapping (`_doc_to_entity`, `_entity_to_doc`, `_serialise`,
209
+ `_deserialise`, etc.) is preserved end-to-end through the registry.
210
+
189
211
  ### Plugin capabilities
190
212
 
191
213
  Every `*Plugin` class declares a `capability` attribute — a typed
192
214
  `openframe.core.ports.Capability` enum member (not a raw string as of
193
215
  `openframe-core` v3.0; the module was `openframe.core.contracts` before
194
216
  v3.1, which merged it into `openframe.core.ports`). This is the key used
195
- by `registry.get()`:
217
+ by `ApplicationBootstrap.get()` / `PluginRegistry.get()`:
196
218
 
197
219
  ```python
198
220
  from openframe.core.ports import Capability
199
221
 
200
- registry.get(Capability.PERSISTENCE) # → PostgresPlugin / MongoPlugin
201
- registry.get(Capability.CACHE) # → RedisPlugin
222
+ app.get(Capability.PERSISTENCE) # → PostgresPlugin / MongoPlugin
223
+ app.get(Capability.CACHE) # → RedisPlugin
202
224
  ```
203
225
 
204
226
  The capability taxonomy is a closed enum shared across the entire OpenFrame
@@ -223,29 +245,32 @@ keep working, but new code should key on the enum member directly.
223
245
  ### The env-var swap exception
224
246
 
225
247
  Switching between adapters via an environment variable (`PERSISTENCE_BACKEND=postgres`
226
- vs `PERSISTENCE_BACKEND=mongo`) is still Stage 1 — because only one adapter
227
- is active at any moment. Use `lru_cache` direct wiring with a conditional:
248
+ vs `PERSISTENCE_BACKEND=mongo`) still only ever has **one** adapter active at
249
+ a time — `compose()` still applies, just with the chosen plugin decided
250
+ before the call:
228
251
 
229
252
  ```python
230
- @lru_cache(maxsize=1)
231
- def _get_repository():
253
+ def _make_plugin():
232
254
  backend = os.environ.get("PERSISTENCE_BACKEND", "postgres")
233
255
  if backend == "postgres":
234
- return ItemPostgresRepository(PostgresSettings())
256
+ return PostgresPlugin(PostgresSettings(), repository_class=ItemPostgresRepository)
235
257
  elif backend == "mongo":
236
- return ItemMongoRepository(MongoSettings())
258
+ return MongoPlugin(MongoSettings(), repository_class=ItemMongoRepository)
237
259
  raise ValueError(f"Unknown PERSISTENCE_BACKEND: {backend!r}")
260
+
261
+ app = ApplicationBootstrap.compose(_make_plugin())
238
262
  ```
239
263
 
240
- `PluginRegistry` is for services that need multiple adapters simultaneously,
241
- not for services that swap between adapters via configuration.
264
+ Both backends still register under the same `Capability.PERSISTENCE` — the
265
+ rest of your service (`app.get(Capability.PERSISTENCE)`) never knows which
266
+ one is active.
242
267
 
243
268
  ### Full wiring reference
244
269
 
245
- For the complete guide — upgrade path from Stage 1 to Stage 2, lifespan
246
- wiring, and architecture diagrams — see the
247
- [Composition Root](https://furious-meteors.github.io/openframe-core/developer-guide/composition-root/)
248
- page in the `openframe-core` documentation.
270
+ For the complete guide — the three-tier model (`compose()` → `configure()`
271
+ subclass → `.registry` escape hatch), lifespan wiring, and architecture
272
+ diagrams — see [How It Works § Choosing a Wiring Pattern](https://furious-meteors.github.io/openframe-core/developer-guide/how-it-works/#choosing-a-wiring-pattern)
273
+ in the `openframe-core` documentation.
249
274
 
250
275
  ---
251
276
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openframe-adapters
3
- Version: 2.0.2
3
+ Version: 2.0.3
4
4
  Summary: OpenFrame Microservice Suite - adapter meta-package. Install adapters by name or group.
5
5
  Author-email: Furious Meteors Engineering <engineering@furiousmeteors.dev>
6
6
  Maintainer-email: Furious Meteors Engineering <engineering@furiousmeteors.dev>
@@ -184,112 +184,134 @@ Every adapter wires into your service through a single file — `deps.py` or
184
184
  that knows which adapter is active. Routes and services never import adapters
185
185
  directly.
186
186
 
187
- The rule is simple:
187
+ Wiring goes through `openframe-core`'s `ApplicationBootstrap`
188
+ (requires `openframe-core>=3.3`) — one recommended class, at two levels of
189
+ ceremony:
188
190
 
189
- > **One adapter** — wire directly with `lru_cache`.
190
- > **Two or more adapters** — use `PluginRegistry`.
191
- > The trigger to upgrade is adding a second adapter.
191
+ > **One or a few adapters, no per-adapter config needed** — `ApplicationBootstrap.compose(*plugins)`. No subclass.
192
+ > **An adapter needs its own `config=`/`init_timeout=`, or registration order depends on a runtime condition** — subclass with `configure()`.
193
+ > Either way, `ApplicationBootstrap` manages startup ordering, health aggregation, and graceful shutdown (including telemetry flush) across all adapters — you never hand-roll that part.
192
194
 
193
- ### Stage 1 — One adapter
194
-
195
- Install one adapter and wire it directly. Four lines. No registry needed.
195
+ ### One adapter — `compose()`, no subclass
196
196
 
197
197
  ```python
198
198
  # bootstrap/dependencies.py
199
- from functools import lru_cache
200
- from openframe.adapters.db.postgres import PostgresRepository, PostgresSettings
199
+ from openframe.adapters.db.postgres import PostgresPlugin, PostgresSettings
200
+ from openframe.core.ports import Capability
201
+ from openframe.core.runtime import ApplicationBootstrap
201
202
  from openframe.core.tracing import TracingProxy
202
203
  from src.adapters.item_repository import ItemPostgresRepository
203
204
  from src.application.services.item_service import ItemService
204
205
 
205
- @lru_cache(maxsize=1)
206
- def _get_repository() -> ItemPostgresRepository:
207
- return ItemPostgresRepository(PostgresSettings())
206
+ app = ApplicationBootstrap.compose(
207
+ PostgresPlugin(PostgresSettings(), repository_class=ItemPostgresRepository)
208
+ )
208
209
 
209
210
  def get_item_service() -> ItemService:
210
- return ItemService(TracingProxy(_get_repository(), prefix="repository.item"))
211
+ repo = TracingProxy(app.get(Capability.PERSISTENCE).get_repository(), prefix="repository.item")
212
+ return ItemService(repo)
211
213
  ```
212
214
 
213
215
  `PostgresSettings()` reads `DATABASE_URL` from env at startup.
214
- `lru_cache(maxsize=1)` constructs the repository once per process.
215
- `TracingProxy` wraps it for automatic OTel spans on every call.
216
+ `TracingProxy` wraps the repository for automatic OTel spans on every call.
216
217
 
217
- ### Stage 2 — Two or more adapters
218
+ ### Two or more adapters — still `compose()`, or subclass if you need per-adapter config
218
219
 
219
- When a second adapter is needed, replace `lru_cache` with `PluginRegistry`.
220
- The registry handles startup ordering, health aggregation, and graceful
221
- shutdown across all adapters.
220
+ `compose()` accepts any number of ports — pass them all in one call:
222
221
 
223
222
  ```python
224
223
  # bootstrap/dependencies.py
225
224
  from openframe.adapters.db.postgres import PostgresPlugin, PostgresSettings
226
225
  from openframe.adapters.db.redis import RedisPlugin, RedisSettings
227
- from openframe.core.plugins import PluginRegistry
226
+ from openframe.core.ports import Capability
227
+ from openframe.core.runtime import ApplicationBootstrap
228
228
  from openframe.core.tracing import TracingProxy
229
229
  from src.application.services.item_service import ItemService
230
230
  from src.application.services.session_service import SessionService
231
231
 
232
- _registry: PluginRegistry | None = None
233
-
234
- async def initialise() -> None:
235
- global _registry
236
- _registry = PluginRegistry()
237
- _registry.register(PostgresPlugin(PostgresSettings())) # capability: Capability.PERSISTENCE
238
- _registry.register(RedisPlugin(RedisSettings())) # capability: Capability.CACHE
239
- await _registry.initialize_all() # fails fast if any backend unreachable
240
-
241
- async def shutdown() -> None:
242
- if _registry:
243
- await _registry.shutdown_all() # never raises
232
+ app = ApplicationBootstrap.compose(
233
+ PostgresPlugin(PostgresSettings()), # capability: Capability.PERSISTENCE
234
+ RedisPlugin(RedisSettings()), # capability: Capability.CACHE
235
+ )
244
236
 
245
237
  def get_item_service() -> ItemService:
246
- repo = TracingProxy(
247
- _registry.get("persistence").get_repository(),
248
- prefix="repository.item",
249
- )
238
+ repo = TracingProxy(app.get(Capability.PERSISTENCE).get_repository(), prefix="repository.item")
250
239
  return ItemService(repo)
251
240
 
252
241
  def get_session_service() -> SessionService:
253
- cache = TracingProxy(
254
- _registry.get("cache").get_repository(),
255
- prefix="cache.session",
256
- )
242
+ cache = TracingProxy(app.get(Capability.CACHE).get_repository(), prefix="cache.session")
257
243
  return SessionService(cache)
258
244
  ```
259
245
 
260
- Wire `initialise()` and `shutdown()` in your FastAPI lifespan:
246
+ Reach for a subclass instead once an adapter needs its own `config=` mapping,
247
+ a per-adapter `init_timeout=`, or registration order that depends on a
248
+ runtime condition:
249
+
250
+ ```python
251
+ class AppBootstrap(ApplicationBootstrap):
252
+ def configure(self) -> None:
253
+ self.register(PostgresPlugin(PostgresSettings()), config={"pool_hint": "primary"})
254
+ self.register(RedisPlugin(RedisSettings()), init_timeout=5.0)
255
+
256
+ app = AppBootstrap()
257
+ ```
258
+
259
+ For the deliberate multi-port-per-capability case (e.g. primary + replica
260
+ Postgres), use `app.get_all(Capability.PERSISTENCE)` or, for anything neither
261
+ covers, the underlying registry directly via `app.registry`.
262
+
263
+ Wire `start()` and `stop()` in your FastAPI lifespan:
261
264
 
262
265
  ```python
263
266
  from contextlib import asynccontextmanager
264
267
  from fastapi import FastAPI
265
268
  from openframe.core.middleware import TelemetryMiddleware
266
269
  from openframe.core.telemetry import setup_telemetry
267
- from src.bootstrap import dependencies
270
+ from src.bootstrap.dependencies import app as bootstrap
268
271
 
269
272
  @asynccontextmanager
270
273
  async def lifespan(app: FastAPI):
271
274
  setup_telemetry()
272
- await dependencies.initialise()
275
+ await bootstrap.start() # initializes every registered adapter — fails fast if any backend unreachable
273
276
  yield
274
- await dependencies.shutdown()
277
+ await bootstrap.stop() # shuts down every adapter, then flushes telemetry — never raises
275
278
 
276
279
  app = FastAPI(lifespan=lifespan)
277
280
  app.add_middleware(TelemetryMiddleware)
278
281
  ```
279
282
 
283
+ ### Domain subclass registration
284
+
285
+ Every `*Plugin` supports registering a domain-specific subclass instead of
286
+ the plain base adapter class — `repository_class` (Postgres, Mongo, Redis),
287
+ `producer_class` and `consumer_class` (Kafka):
288
+
289
+ ```python
290
+ app = ApplicationBootstrap.compose(
291
+ PostgresPlugin(PostgresSettings(), table="items", repository_class=ItemRepository),
292
+ RedisPlugin(RedisSettings(), repository_class=SessionRepository),
293
+ KafkaPlugin(KafkaSettings(), producer_class=ArtifactEventProducer, consumer_class=OrderEventConsumer),
294
+ )
295
+ ```
296
+
297
+ `get_repository()` / `get_producer()` / `make_consumer()` then return an
298
+ instance of the subclass you passed in, not the plain base class — so
299
+ overridden entity mapping (`_doc_to_entity`, `_entity_to_doc`, `_serialise`,
300
+ `_deserialise`, etc.) is preserved end-to-end through the registry.
301
+
280
302
  ### Plugin capabilities
281
303
 
282
304
  Every `*Plugin` class declares a `capability` attribute — a typed
283
305
  `openframe.core.ports.Capability` enum member (not a raw string as of
284
306
  `openframe-core` v3.0; the module was `openframe.core.contracts` before
285
307
  v3.1, which merged it into `openframe.core.ports`). This is the key used
286
- by `registry.get()`:
308
+ by `ApplicationBootstrap.get()` / `PluginRegistry.get()`:
287
309
 
288
310
  ```python
289
311
  from openframe.core.ports import Capability
290
312
 
291
- registry.get(Capability.PERSISTENCE) # → PostgresPlugin / MongoPlugin
292
- registry.get(Capability.CACHE) # → RedisPlugin
313
+ app.get(Capability.PERSISTENCE) # → PostgresPlugin / MongoPlugin
314
+ app.get(Capability.CACHE) # → RedisPlugin
293
315
  ```
294
316
 
295
317
  The capability taxonomy is a closed enum shared across the entire OpenFrame
@@ -314,29 +336,32 @@ keep working, but new code should key on the enum member directly.
314
336
  ### The env-var swap exception
315
337
 
316
338
  Switching between adapters via an environment variable (`PERSISTENCE_BACKEND=postgres`
317
- vs `PERSISTENCE_BACKEND=mongo`) is still Stage 1 — because only one adapter
318
- is active at any moment. Use `lru_cache` direct wiring with a conditional:
339
+ vs `PERSISTENCE_BACKEND=mongo`) still only ever has **one** adapter active at
340
+ a time — `compose()` still applies, just with the chosen plugin decided
341
+ before the call:
319
342
 
320
343
  ```python
321
- @lru_cache(maxsize=1)
322
- def _get_repository():
344
+ def _make_plugin():
323
345
  backend = os.environ.get("PERSISTENCE_BACKEND", "postgres")
324
346
  if backend == "postgres":
325
- return ItemPostgresRepository(PostgresSettings())
347
+ return PostgresPlugin(PostgresSettings(), repository_class=ItemPostgresRepository)
326
348
  elif backend == "mongo":
327
- return ItemMongoRepository(MongoSettings())
349
+ return MongoPlugin(MongoSettings(), repository_class=ItemMongoRepository)
328
350
  raise ValueError(f"Unknown PERSISTENCE_BACKEND: {backend!r}")
351
+
352
+ app = ApplicationBootstrap.compose(_make_plugin())
329
353
  ```
330
354
 
331
- `PluginRegistry` is for services that need multiple adapters simultaneously,
332
- not for services that swap between adapters via configuration.
355
+ Both backends still register under the same `Capability.PERSISTENCE` — the
356
+ rest of your service (`app.get(Capability.PERSISTENCE)`) never knows which
357
+ one is active.
333
358
 
334
359
  ### Full wiring reference
335
360
 
336
- For the complete guide — upgrade path from Stage 1 to Stage 2, lifespan
337
- wiring, and architecture diagrams — see the
338
- [Composition Root](https://furious-meteors.github.io/openframe-core/developer-guide/composition-root/)
339
- page in the `openframe-core` documentation.
361
+ For the complete guide — the three-tier model (`compose()` → `configure()`
362
+ subclass → `.registry` escape hatch), lifespan wiring, and architecture
363
+ diagrams — see [How It Works § Choosing a Wiring Pattern](https://furious-meteors.github.io/openframe-core/developer-guide/how-it-works/#choosing-a-wiring-pattern)
364
+ in the `openframe-core` documentation.
340
365
 
341
366
  ---
342
367
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "openframe-adapters"
7
- version = "2.0.2"
7
+ version = "2.0.3"
8
8
  description = "OpenFrame Microservice Suite - adapter meta-package. Install adapters by name or group."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"