Descriptors
A descriptor is a piece of typed data that describes a capability. A feature ships descriptors; a plugin that mixes in DescriptorHandler<D> claims the type at boot and emits the implementation.
The pattern is the framework's open-extension mechanism. New capability shapes are added by writing a new descriptor type + matching plugin — without changing the core.
The Contract
Descriptor is a marker interface. Each concrete descriptor type stands for one capability shape:
abstract class Descriptor {
const Descriptor();
}
// Built-in:
class EntityCrudDescriptor<T extends Object> extends Descriptor { ... }
class ErrorCodesDescriptor extends Descriptor { ... }A plugin claims a descriptor type by mixing in DescriptorHandler<D>:
mixin DescriptorHandler<D extends Descriptor> on Plugin {
Future<void> register(D descriptor, MountContext ctx) async {}
Future<void> mount(D descriptor, MountContext ctx) async {}
}The dispatcher calls tryHandle for you. Plugins normally override register and/or mount; one plugin claims one descriptor type. The framework iterates registered plugins in order and first match wins. If no plugin claims a descriptor, bootstrap fails with a clear error:
StateError: No DescriptorHandler claimed EntityCrudDescriptor<Order> in
feature "billing". Register a Plugin that mixes in
`DescriptorHandler<EntityCrudDescriptor<Order>>`, or remove the
descriptor from the feature.Two-Phase Dispatch
Descriptors are dispatched twice during the server lifetime:
In the REGISTER phase, MountContext.scope == null — the plugin only records the descriptor in its internal registry. In the MOUNT phase, a live RouterScope is passed in and the plugin emits routes through it.
REGISTER (bootstrap) — MountContext.phase == DispatchPhase.register and scope == null. The plugin records the descriptor in its internal registry. No routes mount yet.
MOUNT (runtime.start) — phase == DispatchPhase.mount with a live RouterScope. The plugin reads its registry and emits routes through the scope.
This split lets one plugin's REGISTER output feed another plugin's MOUNT: OpenApiPlugin reads the RouteListenerRegistry populated by every other plugin's mount.
Built-in Descriptors
EntityCrudDescriptor<T>
Declares an entity that should get a standard CRUD route table.
EntityCrudDescriptor<Product>(
name: 'Products',
basePath: '/products',
config: EntityCrudConfig<Product>(
schema: 'public',
table: 'products',
fromJson: Product.fromJson,
toJson: (p) => p.toJson(),
searchableFields: const ['name'],
capabilities: {
...kEntityCrudDefaults,
EntityCrudCapability.versioning,
},
),
)Claimed by EntityCrudPlugin. Emits GET /products, POST /products, PUT /products/:id, DELETE /products/:id, GET /products/:id, GET /products/count, GET /products/grouped, GET /products/_/schema, and saved-view routes by default. Capability flags add versioning, audit, and draft routes. See Entity CRUD.
RealtimeChannelDescriptor
Declares a realtime topic and the source that produces its events.
RealtimeChannelDescriptor(
topic: 'activities',
source: SupabaseChannelSource(schema: 'elog', table: 'activities'),
metadataExtractor: (record) => {
if (record['site_id'] case final siteId?) 'site_id': siteId,
},
)Claimed by RealtimePlugin, which mounts /live and /live/status, starts each ChannelSource, and filters SSE delivery by topic metadata. See Realtime.
ErrorCodesDescriptor
Declares a domain's error codes for the structured-error pipeline.
FeatureDescriptor(
name: 'billing',
descriptors: [
ErrorCodesDescriptor([
ErrorCode(
code: 'billing.invoice.locked',
httpStatus: 409,
message: 'Invoice is locked; cannot modify.',
),
ErrorCode(
code: 'billing.payment.declined',
httpStatus: 402,
message: 'Payment was declined.',
),
]),
],
);Claimed by ErrorCodesPlugin. The framework's GlobalMiddleware.errorHandler() resolves codes against this registry.
Writing a Custom Descriptor
Suppose you want to declare health probes as data. Write a descriptor plus a plugin that claims it and mounts routes from the declaration.
1. The Descriptor
class HealthProbeDescriptor extends Descriptor {
const HealthProbeDescriptor({
required this.name,
required this.path,
required this.check,
});
final String name;
final String path;
final Future<bool> Function() check;
}2. The Plugin
class HealthProbePlugin extends Plugin
with DescriptorHandler<HealthProbeDescriptor> {
final _probes = <HealthProbeDescriptor>[];
@override
String get name => 'ops.health';
@override
Future<void> register(HealthProbeDescriptor descriptor, MountContext ctx) async {
_probes.add(descriptor);
}
@override
Future<void> mount(HealthProbeDescriptor descriptor, MountContext ctx) async {
ctx.scope!.get(descriptor.path, (req) async {
final ok = await descriptor.check();
return jsonResponse(
statusCode: ok ? 200 : 503,
body: ok
? {'success': true, 'data': {'ok': true}}
: {
'success': false,
'error': {
'code': 'health.failed',
'message': '${descriptor.name} is unhealthy',
},
},
);
});
}
}3. Use It
FeatureDescriptor(
name: 'billing',
descriptors: [
HealthProbeDescriptor(
name: 'database',
path: '/health/db',
check: checkDatabase,
),
],
);
await VyuhServer.bootstrap(
plugins: [
HealthProbePlugin(),
// ...
],
features: [billingFeature],
);The same pattern powers EntityCrudPlugin, RealtimePlugin, ErrorCodesPlugin, and the OpenAPI route-discovery loop. Once written, the descriptor type is a contract — any feature can declare probes without touching the plugin.
Env configs are NOT descriptor-shaped.
EnvPlugintakes typed configs at construction (EnvPlugin(configs: [TypedEnvConfig.factory(...)])) so other plugins can readvyuh.env.get<T>()from their owninit()hooks. See the Env section in the API reference.
Generic Descriptor Types
Notice EntityCrudDescriptor<T> is generic. Each T produces a distinct runtime type — EntityCrudDescriptor<Product> and EntityCrudDescriptor<Order> are different to the type system.
That's intentional. A plugin can declare DescriptorHandler over the unbounded generic (tryHandle checks descriptor is EntityCrudDescriptor) or over a specific T. The framework's dispatch algorithm uses runtime-type matching, so the generic parameter is preserved through the dispatch.
Why Descriptors?
Three properties fall out of the design:
Features don't know plugins. A
FeatureDescriptorthat ships anEntityCrudDescriptor<Product>works with the stockEntityCrudPlugin, a caching variant, or a fully custom implementation. The feature stays the same.Capability inventories are inspectable. Every capability a feature ships is a value in
feature.descriptors— you can list them, log them, audit them, generate a documentation page from them.Fail fast at boot. Misspelt descriptor types, missing plugins, orphan capabilities — all surface as
StateErrorat bootstrap, never as silent runtime no-ops.
Where to Go Next
- Plugins & Features — the contribution model
- Entity CRUD Plugin — the canonical descriptor in action
- Writing a Plugin — full walkthrough