Skip to content

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 a platform_admin role
  • 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 + /docs explorer
  • Token-bucket rate limiting on /api
  • Graceful shutdown wired to SIGINT/SIGTERM

Project Structure

A few files carry purpose annotations:

FileRole
bin/server.dartmain() — calls VyuhServer.bootstrap + runtime.start
lib/catalog/product_config.dartEntityCrudConfig<Product> for the catalog descriptor
lib/webhooks/webhooks_module.dartRouteModule exposing /webhooks/*
lib/admin/admin_module.dartRouteModule exposing /admin/*
lib/auth/verify_token.dartJWKS-backed JWT verifier

bin/server.dart

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)

dart
// 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/batch

Feature: Subscriptions (Custom Workflow)

dart
// 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)

dart
// 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)

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

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

Output:

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_limit

Prod:

bash
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 && ./server

The same binary runs everywhere. Plugins swap by env. Features stay identical.

What This Demonstrates

PatternWhere
Singleton plugin (database, telemetry)PostgresDbPlugin, OtelTelemetryPlugin
Strategy-chain plugin (auth)JwtAuthPlugin + ApiKeyAuthPlugin
Descriptor-driven capabilityEntityCrudDescriptor<Product>
Custom plugin published as a packageRateLimiterPlugin
Per-tenant row scopingEntityCrudConfig.scope
Per-role route gatingrequireRoleMiddleware
Mixed auth (JWT users + API key webhooks)webhooksModule.protectedPaths
Env-driven plugin selectionif (env == 'prod') OtelTelemetryPlugin(...) else ...
Background pub/sub workersfeature.init listening on vyuh.db.listen(...)
Auto-generated /docsOpenApiPlugin
Graceful shutdownFramework 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