Skip to content

Plugins & Features

A VyuhServer is the composition of two kinds of contributors. They look identical from the outside — same five contribution slots, same lifecycle — but they own different responsibilities.

PluginFeature
One singleton role (storage, telemetry, auth chain entry)One domain area (catalog, billing, reports)
Publishes a typed verb on vyuh.XMounts user-facing routes
Initializes before featuresInitializes after every plugin
Often shipped as a separate packageLives in the application repo

That's it. The contribution shape is the same.

The Five Contribution Slots

Every plugin and every feature exposes these slots. Each is independently optional — empty by default.

routes: List<RouteSpec>

Declarative method + path + handler tuples. The runtime mounts each one, wrapping the handler with any route-local middleware in onion order.

dart
FeatureDescriptor(
  name: 'health',
  routes: [
    RouteSpec(
      method: 'GET',
      path: '/health',
      handler: (req) async => Response.ok(body: Body.fromString('ok')),
    ),
    RouteSpec(
      method: 'POST',
      path: '/internal/flush',
      handler: _flushHandler,
      middlewares: [_requireInternalToken()],
    ),
  ],
);

middlewares: List<MiddlewareSpec>

Path-scoped middleware with an explicit order. Larger order = outermost in the onion. Plugins and features merge into one stack sorted by order.

dart
FeatureDescriptor(
  name: 'rate_limit',
  middlewares: [
    MiddlewareSpec(
      path: '/api',
      middleware: rateLimitMiddleware(maxPerMinute: 1000),
      order: 50,
    ),
  ],
);

context: List<ContextProvider>

Per-request typed value providers. Each provider runs once per request, derives a typed value from the request, and registers it on req.di. Handlers resolve via req.di.get<T>().

dart
FeatureDescriptor(
  name: 'tenancy',
  context: [
    ContextProvider.of<TenantId>((req) {
      final id = req.headers.value('x-tenant-id');
      if (id == null) throw MissingTenantException();
      return TenantId(id);
    }),
  ],
);

Then in any handler:

dart
Future<Response> listOrders(Request req) async {
  final tenant = req.di.get<TenantId>();
  // ...
}

di: List<DiRegistration>

Server-wide DI bindings applied to vyuh.di during bootstrap. Use this slot for the long tail — domain singletons, test fakes, per-deployment configs. Don't double-register cross-cutting infrastructure already exposed on vyuh.X.

dart
FeatureDescriptor(
  name: 'reports',
  di: [
    DiRegistration.value<ReportRenderer>(PdfRenderer()),
    DiRegistration.factory<S3Client>((_) => S3Client.fromEnv()),
  ],
);

descriptors: List<Descriptor>

The open-extension mechanism. Features describe capabilities as data; plugins claim the type. See the Descriptors page for the full story.

dart
FeatureDescriptor(
  name: 'catalog',
  descriptors: [
    EntityCrudDescriptor<Product>(
      basePath: '/products',
      config: EntityCrudConfig<Product>(...),
    ),
  ],
);

extend: RouterShaper?

The imperative escape hatch — called during serve() with a live RouterScope AFTER every declarative slot has mounted. Use only when a contribution truly can't fit any declarative shape: computed paths, conditional registration, ad-hoc calls into scope.protect(...).

dart
FeatureDescriptor(
  name: 'legacy',
  extend: (scope) {
    for (final path in legacyRedirects) {
      scope.get(path, _redirectHandler);
    }
  },
);

The plugin equivalent is Plugin.extend(scope).

Plugin Subtypes

Plugin is an abstract base. The framework defines six subtypes for the singleton/chain roles:

Every subtype inherits the same lifecycle (name, dependencies, init, dispose) and the same five contribution slots (routes, middlewares, context, di, extend). Each subtype adds exactly one role-specific contract:

SubtypeAdds
DbPluginDbAdapter adapter
AuthPluginList<AuthStrategy> strategies + optional Actor? fallback
TelemetryPluginTelemetrySink sink
QueryPluginQueryCompiler compiler
PolicyPluginMap<String, PolicyEvaluator> policies
EnvPluginEnvRegistry registry

A plugin that doesn't fit any subtype extends Plugin directly. It still contributes through the same five slots; it just has no typed verb on vyuh.X.

For example, OpenApiPlugin extends Plugin directly. Its contribution is routes (/openapi.json, /docs) — there's no singleton role to claim.

Plugin Lifecycle Symmetry

Every plugin and every feature has the same lifecycle hooks:

HookWhenUse for
init(runtime)After contributions resolved, before serveOpen connections, start workers
dispose(runtime)On graceful shutdown, reverse orderClose connections, stop workers

Plugins init before features. Within the plugin or feature list, init runs in topo order (declared dependencies). Dispose runs in reverse.

Features additionally provide:

HookWhen
init: FutureOr<void> Function(Object runtime)?Same as Plugin.init, but after descriptor REGISTER
dispose: FutureOr<void> Function(Object runtime)?Same as Plugin.dispose

The Banner Hook

Plugins can self-advertise human-facing URLs by overriding bannerLines:

dart
class AdminPanelPlugin extends Plugin {
  @override
  final String name = 'admin.panel';

  @override
  List<RouteSpec> get routes => [
    RouteSpec(method: 'GET', path: '/admin', handler: _adminHandler),
  ];

  @override
  List<String> bannerLines(String baseUrl) => [
    '  Admin: $baseUrl/admin',
  ];
}

When the server starts:

saas-api listening on http://localhost:8080
  Docs: http://localhost:8080/docs
  Spec: http://localhost:8080/openapi.json
  Admin: http://localhost:8080/admin

No central registry. Each plugin owns its announcements; the framework collects them and prints.

When to Write a Plugin vs a Feature

Pick the identity that matches the contribution.

Write a plugin when:

  • The contribution is infrastructure-level: storage, observability, auth, error formatting.
  • The capability is a singleton or a chain entry.
  • The contribution makes sense to a server that has zero domain features.
  • You're publishing it as a separate package.

Write a feature when:

  • The contribution is a domain area: catalog, billing, reports.
  • The contribution is user-facing.
  • You'll have many of them per server.
  • It lives in the application repo.

The line gets fuzzy for capability plugins like EntityCrudPlugin — those are plugins because they own a registry (a singleton role: "emit CRUD route tables for every registered descriptor"), but the routes they emit are domain routes that feel feature-shaped. The rule of thumb still holds: the plugin owns the singleton mechanism, the feature declares the data.

Where to Go Next