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 /notificationsto enqueue a notification - Exposes
GET /notifications/feedfor the authenticated user's feed - Wires a per-request
NotificationsContextprovider - Registers a DI binding for a
NotificationSenderinterface - Listens to a Postgres
notification_eventschannel 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:
// 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:
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.actorto get the actor resolved byGlobalMiddleware.auth()vyuh.db.execute(...)for Postgresvyuh.telemetry.counter(...)for metricsjsonResponse(...)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
// 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
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
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:
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:
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
- Plugins & Features — the contribution model
- Entity CRUD Plugin — turn entities into routes
- Routes —
RouteSpec,RouteModule,RouterScope