Skip to content

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:

dart
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>:

dart
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.

dart
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.

dart
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.

dart
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

dart
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

dart
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

dart
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. EnvPlugin takes typed configs at construction (EnvPlugin(configs: [TypedEnvConfig.factory(...)])) so other plugins can read vyuh.env.get<T>() from their own init() 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:

  1. Features don't know plugins. A FeatureDescriptor that ships an EntityCrudDescriptor<Product> works with the stock EntityCrudPlugin, a caching variant, or a fully custom implementation. The feature stays the same.

  2. 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.

  3. Fail fast at boot. Misspelt descriptor types, missing plugins, orphan capabilities — all surface as StateError at bootstrap, never as silent runtime no-ops.

Where to Go Next