Skip to content

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:

Vyuh Server isometric layered architecture: five stacked slabs from Application at top, through parallel Plugins and Features contributors, the five shared contribution slots, the runtime singletons addressable on vyuh.*, down to the Relic HTTP transport at the bottom

Reading the stack top-down:

LayerWhat it isExamples
Your Applicationmain.dart — the only code you write outside features/pluginsVyuhServer.bootstrap(...) + runtime.start(...)
ContributorsTwo parallel identities, both contribute through the same five slotsDbPlugin / AuthPlugin / TelemetryPlugin / ... ‖ catalogFeature / billingFeature / ...
Contribution SlotsThe shared vocabulary every contributor speaksroutes, middlewares, context, di, descriptors (+ extend escape)
RuntimeResolved singletons addressable from any handlervyuh.db, vyuh.storage, vyuh.auth, vyuh.telemetry, vyuh.query, vyuh.policy, vyuh.di, vyuh.env, vyuh.errors
HTTP TransportWhere requests actually landRelic — 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 EntityCrudPlugin and OpenApiPlugin also 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 typeSingleton role
DbPluginOne per server (the DbAdapter)
TelemetryPluginOne per server (the TelemetrySink)
QueryPluginOne per server (the QueryCompiler)
EnvPluginOne per server (the EnvRegistry)
AuthPlugin.fallbackAt most one fallback actor across all auth plugins
AuthPlugin.strategiesConcatenated across all auth plugins
PolicyPlugin.policiesMerged 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:

  1. Find the first plugin that mixes in DescriptorHandler<D> where D matches the descriptor's runtime type.
  2. Call plugin.tryHandle(descriptor, ctx) with phase: REGISTER.
  3. The plugin updates its internal registry (for example, EntityCrudPlugin records the entity config in EntityCrudRegistry).

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:

  1. Descriptor MOUNT outputs (plugins).
  2. Each plugin's declarative routes: list.
  3. Each plugin's extend(scope) callback.
  4. Each feature's declarative routes: list.
  5. Each feature's extend?.call(scope) callback.

Middleware then layers on top, innermost first:

  1. Per-request context providers (just outside the handler).
  2. Auth middleware on every protected path.
  3. All MiddlewareSpecs from plugins + features, sorted by order descending — larger order ends up outermost.
  4. Old-style ContextPropertyInjectors (for dart_server_core compat).
  5. User-supplied global middleware: parameter, reversed so the last in the list is outermost.
  6. The built-in clientContextMiddleware (mounted last — outermost). It populates req.clientContext with 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:

dart
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