Example: Multi-Tenant SaaS API
A complete SaaS backend in ~200 lines of main.dart plus three feature files. By the end of this example, you have:
- A multi-tenant catalog (
/products) - A subscriptions API (
/subscriptions) with custom approval flows - A webhooks endpoint (
/webhooks/stripe) authenticated by API key - An admin panel (
/admin) gated by aplatform_adminrole - JWT + API key auth chains coexisting on the same server
- Postgres storage with per-tenant row scoping
- OpenTelemetry tracing in prod, console JSON in dev
- An auto-generated
/openapi.json+/docsexplorer - Token-bucket rate limiting on
/api - Graceful shutdown wired to
SIGINT/SIGTERM
Project Structure
A few files carry purpose annotations:
| File | Role |
|---|---|
bin/server.dart | main() — calls VyuhServer.bootstrap + runtime.start |
lib/catalog/product_config.dart | EntityCrudConfig<Product> for the catalog descriptor |
lib/webhooks/webhooks_module.dart | RouteModule exposing /webhooks/* |
lib/admin/admin_module.dart | RouteModule exposing /admin/* |
lib/auth/verify_token.dart | JWKS-backed JWT verifier |
bin/server.dart
import 'dart:io';
import 'package:cdx_query/cdx_query.dart';
import 'package:vyuh_server/vyuh_server.dart';
import 'package:vyuh_server_plugin_postgres/vyuh_server_plugin_postgres.dart';
import 'package:vyuh_server_plugin_auth_jwt/vyuh_server_plugin_auth_jwt.dart';
import 'package:vyuh_server_plugin_auth_apikey/vyuh_server_plugin_auth_apikey.dart';
import 'package:vyuh_server_plugin_telemetry_console/vyuh_server_plugin_telemetry_console.dart';
import 'package:vyuh_server_plugin_telemetry_otel/vyuh_server_plugin_telemetry_otel.dart';
import 'package:vyuh_server_plugin_entity_crud/vyuh_server_plugin_entity_crud.dart';
import 'package:cdx_query_server_postgres/cdx_query_server_postgres.dart';
import 'package:vyuh_server_plugin_openapi/vyuh_server_plugin_openapi.dart';
import 'package:saas/auth/verify_token.dart';
import 'package:saas/auth/lookup_api_key.dart';
import 'package:saas/catalog/catalog_feature.dart';
import 'package:saas/subscriptions/subscriptions_feature.dart';
import 'package:saas/webhooks/webhooks_feature.dart';
import 'package:saas/admin/admin_feature.dart';
import 'package:saas/tenancy/tenancy_feature.dart';
import 'package:saas/errors/saas_error_codes.dart';
import 'package:saas/rate_limiter_plugin.dart';
Future<void> main() async {
loadEnvFile(path: '.env'); // optional dotenv
final env = Platform.environment['APP_ENV'] ?? 'dev';
final runtime = await VyuhServer.bootstrap(
name: 'saas-api',
plugins: [
// Env + Postgres
EnvPlugin(
configs: [TypedEnvConfig.factory(PostgresConnectionConfig.new)],
),
PostgresDbPlugin(),
// Auth chain — JWT for end users, API key for partners
JwtAuthPlugin(
verifier: verifyTokenWithJwks,
tenantClaim: 'org_id',
),
ApiKeyAuthPlugin(lookup: lookupApiKey),
// Telemetry — pick by env
if (env == 'prod')
OtelTelemetryPlugin(defaultServiceName: 'saas-api')
else
ConsoleTelemetryPlugin(),
// Filters & CRUD
PostgresQueryPlugin(),
EntityCrudPlugin(),
// OpenAPI explorer
OpenApiPlugin(
title: 'SaaS API',
version: '2.4.0',
description: 'Multi-tenant SaaS backend',
),
// Custom plugin
RateLimiterPlugin(maxPerMinute: 1000, path: '/api'),
// Error codes
ErrorCodesPlugin(),
],
features: [
saasErrorCodes,
tenancyFeature,
catalogFeature,
subscriptionsFeature,
webhooksFeature,
adminFeature,
],
);
await runtime.start(
port: int.parse(Platform.environment['PORT'] ?? '8080'),
middleware: [
GlobalMiddleware.cors(),
GlobalMiddleware.errorHandler(),
GlobalMiddleware.requestLogger(),
if (env == 'prod') tracingMiddleware(),
],
);
}Feature: Catalog (Multi-Tenant CRUD)
// lib/catalog/catalog_feature.dart
final catalogFeature = FeatureDescriptor(
name: 'app.catalog',
description: 'Product catalog',
descriptors: [
EntityCrudDescriptor<Product>(
name: 'Products',
basePath: '/api/products',
config: EntityCrudConfig<Product>(
schema: 'public',
table: 'products',
fromJson: Product.fromJson,
toJson: (p) => p.toJson(),
searchableFields: const ['name', 'description'],
sortableFields: const ['name', 'price_cents', 'created_at'],
defaultSortField: 'name',
capabilities: {
...kEntityCrudDefaults,
EntityCrudCapability.audit,
},
scope: (req, _) {
final actor = req.actor;
final tenantId = actor.tenantId;
if (tenantId == null) {
throw EntityValidationException(
field: 'tenant_id',
reason: 'Authenticated actor has no tenant id',
);
}
return FilterCondition(
field: 'tenant_id',
operator: FilterOps.equals,
value: FilterValue.single(tenantId),
);
},
validateCreate: (payload) {
final price = payload['price_cents'] as int?;
if (price == null || price < 0) {
throw EntityValidationException(
field: 'price_cents',
reason: 'Price must be a non-negative integer',
);
}
return payload;
},
afterCreate: (persisted, req) async {
vyuh.telemetry.counter('catalog.products.created', delta: 1);
await vyuh.db.execute(
"SELECT pg_notify('catalog_events', @payload)",
parameters: {
'payload': jsonEncode({'op': 'create', 'row': persisted}),
},
);
},
),
),
],
);Auto-emitted routes (all /api-prefixed, JWT-authenticated, rate- limited, tenant-scoped):
GET /api/products
POST /api/products/count
GET /api/products/:id
POST /api/products
PATCH /api/products/:id
DELETE /api/products/:id
POST /api/products/batchFeature: Subscriptions (Custom Workflow)
// lib/subscriptions/subscriptions_feature.dart
final subscriptionsFeature = FeatureDescriptor(
name: 'app.subscriptions',
dependencies: ['kernel.postgres'],
descriptors: [
EntityCrudDescriptor<Subscription>(
name: 'Subscriptions',
basePath: '/api/subscriptions',
config: subscriptionsConfig,
// Custom routes alongside the standard CRUD table
customRoutes: (scope) {
scope.apiPost('/:id/upgrade', _upgradeHandler, doc: Operation(
summary: 'Upgrade a subscription tier',
tags: ['subscriptions'],
));
scope.apiPost('/:id/cancel', _cancelHandler, doc: Operation(
summary: 'Cancel a subscription',
tags: ['subscriptions'],
));
},
),
],
);Feature: Webhooks (API Key Authenticated)
// lib/webhooks/webhooks_module.dart
final webhooksModule = RouteModule(
name: 'app.webhooks',
basePath: '/webhooks',
protectedPaths: ['/stripe'], // API key on /stripe only
setup: (scope) {
scope.post('/stripe', _stripeWebhookHandler);
scope.get('/health', _webhookHealthHandler); // public
},
);A webhook delivery presents X-API-Key: <stripe-side-secret>; the ApiKeyAuthPlugin lookup verifies, and the handler runs.
Feature: Admin (Role-Gated, Cross-Tenant)
// lib/admin/admin_feature.dart
final adminFeature = FeatureDescriptor(
name: 'app.admin',
middlewares: [
MiddlewareSpec(
path: '/admin',
middleware: requireRoleMiddleware('platform_admin'),
order: 700,
),
],
extend: (scope) {
scope.protect(['/admin']);
scope.get('/admin/tenants', _listTenantsHandler);
scope.post('/admin/migrate/products', _migrateProductsHandler);
},
);The admin routes opt out of tenant scoping (the user has platform_admin); the framework's auth still applies, and a custom requireRoleMiddleware rejects non-admins.
Running
Dev:
APP_ENV=dev \
DB_HOST=localhost DB_NAME=saas DB_USER=app DB_PASSWORD=secret \
JWKS_URI=http://localhost:4000/.well-known/jwks.json \
JWT_ISSUER=http://localhost:4000/ \
JWT_AUDIENCE=https://api.saas.local \
dart run bin/server.dartOutput:
saas-api listening on http://localhost:8080
Docs: http://localhost:8080/docs
Spec: http://localhost:8080/openapi.json
Rate-limit metrics: http://localhost:8080/_metrics/rate_limitProd:
APP_ENV=prod \
DB_HOST=db.internal DB_NAME=saas_prod DB_USER=app DB_PASSWORD=$SECRET \
OTEL_SERVICE_NAME=saas-api OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.example.com:4317 \
OTEL_TRACES_SAMPLER=parentbased_always_on \
JWKS_URI=https://auth.example.com/.well-known/jwks.json \
JWT_ISSUER=https://auth.example.com/ \
JWT_AUDIENCE=https://api.example.com \
dart compile exe bin/server.dart -o server && ./serverThe same binary runs everywhere. Plugins swap by env. Features stay identical.
What This Demonstrates
| Pattern | Where |
|---|---|
| Singleton plugin (database, telemetry) | PostgresDbPlugin, OtelTelemetryPlugin |
| Strategy-chain plugin (auth) | JwtAuthPlugin + ApiKeyAuthPlugin |
| Descriptor-driven capability | EntityCrudDescriptor<Product> |
| Custom plugin published as a package | RateLimiterPlugin |
| Per-tenant row scoping | EntityCrudConfig.scope |
| Per-role route gating | requireRoleMiddleware |
| Mixed auth (JWT users + API key webhooks) | webhooksModule.protectedPaths |
| Env-driven plugin selection | if (env == 'prod') OtelTelemetryPlugin(...) else ... |
| Background pub/sub workers | feature.init listening on vyuh.db.listen(...) |
Auto-generated /docs | OpenApiPlugin |
| Graceful shutdown | Framework default — SIGINT + SIGTERM |
Every concern lives in one place. The handler never threads the runtime, never reaches for a service locator, never branches on env.
Where to Go Next
- Writing a Plugin — build the
RateLimiterPluginfrom scratch - Multi-Tenancy Guide —
EntityCrudConfig.scopein depth - Architecture — the boot sequence that wires all of this