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.
| Plugin | Feature |
|---|---|
| One singleton role (storage, telemetry, auth chain entry) | One domain area (catalog, billing, reports) |
Publishes a typed verb on vyuh.X | Mounts user-facing routes |
| Initializes before features | Initializes after every plugin |
| Often shipped as a separate package | Lives 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.
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.
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>().
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:
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.
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.
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(...).
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:
| Subtype | Adds |
|---|---|
DbPlugin | DbAdapter adapter |
AuthPlugin | List<AuthStrategy> strategies + optional Actor? fallback |
TelemetryPlugin | TelemetrySink sink |
QueryPlugin | QueryCompiler compiler |
PolicyPlugin | Map<String, PolicyEvaluator> policies |
EnvPlugin | EnvRegistry 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:
| Hook | When | Use for |
|---|---|---|
init(runtime) | After contributions resolved, before serve | Open connections, start workers |
dispose(runtime) | On graceful shutdown, reverse order | Close 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:
| Hook | When |
|---|---|
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:
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/adminNo 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
- Descriptors — the open extension mechanism
- Routes —
RouteSpec,RouteModule,RouterScope - Middleware — onion ordering and per-request context
- Writing a Plugin — a step-by-step guide