Architecture
A Vyuh Server is the composition of a VyuhServer runtime, a set of plugins, a set of features, and a Relic HTTP transport. Understanding the bootstrap pipeline — what runs in what order — is the key to writing plugins or debugging boot-time issues.
Component Layers
A Vyuh Server composes from the top down — your application code at the top, the HTTP transport at the bottom, and three layers between them that translate one into the other:
Reading the stack top-down:
| Layer | What it is | Examples |
|---|---|---|
| Your Application | main.dart — the only code you write outside features/plugins | VyuhServer.bootstrap(...) + runtime.start(...) |
| Contributors | Two parallel identities, both contribute through the same five slots | DbPlugin / AuthPlugin / TelemetryPlugin / ... ‖ catalogFeature / billingFeature / ... |
| Contribution Slots | The shared vocabulary every contributor speaks | routes, middlewares, context, di, descriptors (+ extend escape) |
| Runtime | Resolved singletons addressable from any handler | vyuh.db, vyuh.storage, vyuh.auth, vyuh.telemetry, vyuh.query, vyuh.policy, vyuh.di, vyuh.env, vyuh.errors |
| HTTP Transport | Where requests actually land | Relic — onion middleware, per-request DI, graceful shutdown |
A few subtleties the diagram is designed to highlight:
- Plugins and features are siblings, not parent and child. They sit at the same vertical level because they speak the same language — the five contribution slots. The only difference is identity: a plugin is a singleton role; a feature is a composable domain area.
- Capability plugins like
EntityCrudPluginandOpenApiPluginalso live in the Plugins layer, but they don't claim a singleton runtime verb. Instead they claim a descriptor type and emit routes from registered data. - The contribution-slots layer is the contract, not an implementation. It's the shape both plugins and features must produce.
The Bootstrap Pipeline
Calling VyuhServer.bootstrap(...) runs a deterministic sequence of phases. Read it once and you know exactly when your plugin's code runs and what state it can rely on.
Phase 1 — Topological Sort
Plugins and features both declare a dependencies: <List<String>> list of names. The framework topo-sorts each list separately:
- A feature can depend on a plugin (by name).
- A plugin can depend on another plugin.
- Cycles fail fast with a clear "dependency cycle" error.
The result is the order in which init / register / mount phases iterate.
Phase 2 — Singleton Resolution
The framework walks the sorted plugin list and pattern-matches each plugin's type:
| Plugin type | Singleton role |
|---|---|
DbPlugin | One per server (the DbAdapter) |
TelemetryPlugin | One per server (the TelemetrySink) |
QueryPlugin | One per server (the QueryCompiler) |
EnvPlugin | One per server (the EnvRegistry) |
AuthPlugin.fallback | At most one fallback actor across all auth plugins |
AuthPlugin.strategies | Concatenated across all auth plugins |
PolicyPlugin.policies | Merged across all policy plugins (collision on the same ref = boot error) |
Two DbPlugins = SingletonCollisionError with both plugin names. The framework refuses to guess.
Phase 3 — Plugin Init
Each plugin's init(runtime) runs in topo order. The runtime is already fully constructed — vyuh.db, vyuh.auth, vyuh.telemetry all work — so init can resolve other plugins.
Use init for resource acquisition: open the Postgres pool, start the OTel SDK, spin up a background worker. Don't mount routes here; mount comes later when the transport starts.
Phase 4 — DI Bindings
Plugins and features each have a di: slot with List<DiRegistration>. After init, the framework applies plugin DI entries first (infrastructure), then feature DI entries (app-level). The result lives on vyuh.di and is parented into every request's req.di.
Phase 5 — Descriptor REGISTER
Each feature's descriptors: list is walked. For each Descriptor:
- Find the first plugin that mixes in
DescriptorHandler<D>whereDmatches the descriptor's runtime type. - Call
plugin.tryHandle(descriptor, ctx)withphase: REGISTER. - The plugin updates its internal registry (for example,
EntityCrudPluginrecords the entity config inEntityCrudRegistry).
If no plugin claims the descriptor, bootstrap fails. The error message names the descriptor type, the feature, and what to register.
Phase 6 — Feature Init
After every plugin has registered every descriptor, each feature's init(runtime) runs in topo order. Features see the final registry state — a feature can read another plugin's registry, register listeners, kick off warm-up tasks.
Phase 7 — Descriptor MOUNT
Triggered when runtime.start() (from the serve.dart extension) fires. The framework walks the descriptor list again, this time with a live RouterScope, and the claiming plugin mounts the routes implied by the descriptor.
This two-phase design lets one plugin's descriptor depend on another plugin's REGISTER phase — for example, OpenApiPlugin reads the RouteListenerRegistry populated by every other plugin's mount.
Phase 8 — Routes and Middleware
Routes mount in this order:
- Descriptor MOUNT outputs (plugins).
- Each plugin's declarative
routes:list. - Each plugin's
extend(scope)callback. - Each feature's declarative
routes:list. - Each feature's
extend?.call(scope)callback.
Middleware then layers on top, innermost first:
- Per-request context providers (just outside the handler).
- Auth middleware on every protected path.
- All
MiddlewareSpecs from plugins + features, sorted byorderdescending — larger order ends up outermost. - Old-style
ContextPropertyInjectors (fordart_server_corecompat). - User-supplied global
middleware:parameter, reversed so the last in the list is outermost. - The built-in
clientContextMiddleware(mounted last — outermost). It populatesreq.clientContextwith request id, IP, user-agent, device hints, and product code.
Phase 9 — Listen
app.serve(port) starts accepting connections. The framework prints the startup banner: server name + listen URL, plus each plugin's bannerLines(baseUrl) contribution (Swagger UI URL, /metrics, admin panels, …).
SIGINT / SIGTERM watchers are wired (unless gracefulSignals: const [] was passed). The first received signal drains the shutdown pipeline.
Graceful Shutdown
The reverse-topological ordering mirrors init — late-initialized dependencies tear down before the things they depend on. This is the same discipline as Dart's Zone setup/teardown or Spring's PreDestroy.
The vyuh Global Accessor
After bootstrap returns, the global vyuh is bound to your runtime. Route handlers, middleware, and feature init callbacks reach the resolved infrastructure through it:
import 'package:vyuh_server/vyuh_server.dart';
Future<Response> listProducts(Request req) async {
final span = vyuh.telemetry.startSpan('catalog.list');
try {
final actor = req.actor;
final rows = await vyuh.db.execute(
'SELECT * FROM products WHERE tenant_id = @tenant',
parameters: {'tenant': actor.tenantId},
);
return Response.ok(body: Body.fromString(jsonEncode(rows.toList())));
} finally {
span.end();
}
}Per isolate. The global is bound to the most-recently-started VyuhServer. Multiple servers in one isolate are not supported in v0.1. For tests that boot and shut down servers in sequence, runtime.shutdown() unbinds at the end so a fresh bootstrap can take over.
Where to Go Next
- Plugins & Features — the contribution model in detail
- Descriptors — the open-extension mechanism
- Routes —
RouteSpec,RouteModule,RouterScope - Middleware — onion composition, ordering, per-request context