Skip to content

Writing a Feature

A feature is a domain area: catalog, billing, reports, content moderation, real-time chat. Build one as a FeatureDescriptor that contributes through the same five slots a plugin uses.

This guide builds a notificationsFeature that:

  • Exposes POST /notifications to enqueue a notification
  • Exposes GET /notifications/feed for the authenticated user's feed
  • Wires a per-request NotificationsContext provider
  • Registers a DI binding for a NotificationSender interface
  • Listens to a Postgres notification_events channel in a background worker

1. Sketch the API

POST /notifications          → enqueue
GET  /notifications/feed     → my feed (auth required)
GET  /notifications/:id      → one (auth required)

2. The Module

Group the routes under one RouteModule for a clean basePath + auth profile:

dart
// lib/notifications/notifications_module.dart
import 'package:vyuh_server/vyuh_server.dart';

final notificationsModule = RouteModule(
  name: 'app.notifications',
  basePath: '/notifications',
  protectedPaths: ['/'],
  setup: (scope) {
    scope.post('/',     _enqueueHandler);
    scope.get('/feed',  _feedHandler);
    scope.get('/:id',   _readHandler);
  },
);

3. The Handlers

Each handler is a plain Relic Handler:

dart
Future<Response> _enqueueHandler(Request req) async {
  final body = jsonDecode(await req.readAsString()) as Map<String, Object?>;
  final actor = req.actor;

  final id = await vyuh.db.execute(
    '''
    INSERT INTO notifications (id, tenant_id, recipient_id, payload, created_at)
    VALUES (gen_random_uuid(), @tenant, @recipient, @payload::jsonb, NOW())
    RETURNING id
    ''',
    parameters: {
      'tenant': actor.tenantId,
      'recipient': body['recipient'],
      'payload': jsonEncode(body['payload']),
    },
  );

  vyuh.telemetry.counter('notifications.enqueued', delta: 1);
  return jsonResponse(
    statusCode: 201,
    body: {'success': true, 'data': {'id': id.rowAsMap(0)['id']}},
  );
}

Future<Response> _feedHandler(Request req) async {
  final actor = req.actor;
  final rows = await vyuh.db.execute(
    '''
    SELECT * FROM notifications
    WHERE recipient_id = @user
    ORDER BY created_at DESC
    LIMIT 50
    ''',
    parameters: {'user': actor.id},
  );
  return jsonResponse(body: {'success': true, 'data': rows.asMaps});
}

Future<Response> _readHandler(Request req) async {
  final id = req.pathParameters['id']!;
  final actor = req.actor;
  final rows = await vyuh.db.execute(
    'SELECT * FROM notifications WHERE id = @id AND recipient_id = @user',
    parameters: {'id': id, 'user': actor.id},
  );
  if (rows.isEmpty) {
    return jsonResponse(
      statusCode: 404,
      body: {
        'success': false,
        'error': {'code': 'notification.not_found', 'message': 'Not found'},
      },
    );
  }
  return jsonResponse(body: {'success': true, 'data': rows.rowAsMap(0)});
}

Handlers use:

  • req.actor to get the actor resolved by GlobalMiddleware.auth()
  • vyuh.db.execute(...) for Postgres
  • vyuh.telemetry.counter(...) for metrics
  • jsonResponse(...) or the typed response helpers for JSON responses

No DI, no service locator, no "where does this come from?" — every verb is typed.

4. The Feature Descriptor

dart
// lib/notifications/notifications_feature.dart
final notificationsFeature = FeatureDescriptor(
  name: 'app.notifications',
  description: 'In-app notification feed',
  dependencies: ['kernel.postgres'], // ensures storage is up before init

  extend: (scope) => notificationsModule.applyTo(scope),

  di: [
    DiRegistration.value<NotificationSender>(EmailNotificationSender()),
  ],

  context: [
    ContextProvider.of<NotificationsContext>((req) {
      return NotificationsContext(
        actorId: req.actor.id,
        sentAt: DateTime.now().toUtc(),
      );
    }),
  ],

  init: (runtime) async {
    // Background worker — subscribe to NOTIFY events
    unawaited(_listenForEvents());
  },
);

5. Background Worker

dart
Future<void> _listenForEvents() async {
  await for (final payload in vyuh.db.listen('notification_events')) {
    try {
      final event = jsonDecode(payload) as Map<String, Object?>;
      final sender = vyuh.di.get<NotificationSender>();
      await sender.send(event);
      vyuh.telemetry.counter('notifications.sent', delta: 1);
    } catch (e, st) {
      vyuh.telemetry.event('notifications.send_failed', attrs: {
        'error': e.toString(),
      });
    }
  }
}

The worker starts in feature.init after every plugin is up. Postgres NOTIFY notification_events, '<json>' triggers a send.

6. Wire It In

dart
final runtime = await VyuhServer.bootstrap(
  name: 'saas-api',
  plugins: [
    PostgresDbPlugin(),
    JwtAuthPlugin(verifier: verifyToken),
    OtelTelemetryPlugin(defaultServiceName: 'saas-api'),
    EntityCrudPlugin(),
    OpenApiPlugin(title: 'SaaS API'),
  ],
  features: [
    catalogFeature,
    notificationsFeature,
  ],
);

Patterns Worth Adopting

One feature, many entities

Use a single FeatureDescriptor to declare multiple EntityCrudDescriptor<T> entries when they belong to the same domain:

dart
final blogFeature = FeatureDescriptor(
  name: 'app.blog',
  descriptors: [
    EntityCrudDescriptor<Post>(basePath: '/posts', config: postConfig),
    EntityCrudDescriptor<Category>(basePath: '/categories', config: categoryConfig),
    EntityCrudDescriptor<Comment>(basePath: '/comments', config: commentConfig),
  ],
);

Cross-feature isolation

Features should not import each other. If two features share types, move the types into a sibling pure-Dart package and depend on it from both. This is the same discipline the Vyuh Framework uses on the Flutter side.

Feature flags / conditional features

features: is just a list — compute it:

dart
features: [
  catalogFeature,
  if (env == 'prod') paymentsFeature,
  if (Platform.environment['EXPERIMENT_REALTIME'] == 'true')
    realtimeChatFeature,
],

No code lazy-loads or "registers" at runtime. The set of features is declared once at boot.

Where to Go Next