ObjectStackObjectStack

Configuration Resolution

Hierarchical config merging, precedence rules, environment overrides, and tenant isolation

Configuration Resolution

Protocol spec, partial implementation. This page describes ObjectStack's target configuration model: hierarchical sources, tenant isolation, secret stores. The merge / precedence logic is implemented; the richer nested shapes shown in code samples below (e.g. database, http, secrets.provider) are illustrative of intent β€” today they live as env-vars, service-level options, and the datasources / plugins keys on defineStack. Treat the snippets as design intent, not paste-ready code.

ObjectStack uses a hierarchical configuration system that merges settings from multiple sources with clear precedence rules. This enables environment-specific overrides, tenant isolation, and user preferencesβ€”all from a single unified API.

The Configuration Problem

Traditional applications struggle with configuration management:

// Where does apiKey come from? 🀷
const apiKey = 
  process.env.API_KEY ||              // Environment variable?
  config.stripe.apiKey ||             // Config file?
  tenantSettings.apiKey ||            // Database?
  userPrefs.apiKey ||                 // User override?
  'fallback-key';                     // Hardcoded default?

// Which value wins if multiple sources define it?
// How do you handle tenant-specific overrides?
// How do you validate that the value is correct?

Result: Configuration chaos. Developers spend hours debugging "works on my machine" issues caused by conflicting config sources.

Configuration Sources

ObjectStack defines six configuration sources with strict precedence:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 1. RUNTIME                                                  β”‚
β”‚    Programmatic overrides (context.config.set())            β”‚
β”‚    Highest priority, temporary                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          ↓ overrides
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 2. ENVIRONMENT                                              β”‚
β”‚    Environment variables (OS_*, NODE_ENV, etc.)             β”‚
β”‚    Set by deployment platform (Kubernetes, Docker)          β”‚
β”‚    Pins the value (locked) when present                     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          ↓ overrides
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 3. TENANT                                                   β”‚
β”‚    Multi-tenant overrides (per-customer config)             β”‚
β”‚    Stored in database, tenant-scoped                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          ↓ overrides
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 4. USER PREFERENCES                                         β”‚
β”‚    Per-user settings (language, theme, etc.)                β”‚
β”‚    Stored in database, user-specific                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          ↓ overrides
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 5. FILE                                                     β”‚
β”‚    Configuration files (objectstack.config.yml)             β”‚
β”‚    Checked into Git, environment-specific                   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          ↓ overrides
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 6. PLUGIN DEFAULTS                                          β”‚
β”‚    Default values from plugin manifests                     β”‚
β”‚    Lowest priority, fallback values                         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Precedence Rule: Higher source always wins when same key is defined.

Configuration API

Reading Configuration

// Access configuration via context
const config = context.config;

// Get single value
const apiKey = config.get('stripe.apiKey');
// Returns: Value from highest-priority source that defines 'stripe.apiKey'

// Get with default
const timeout = config.get('http.timeout', 30_000);
// Returns: 30000 if 'http.timeout' not defined in any source

// Get typed value (with Zod schema)
const stripeConfig = config.get('stripe', stripeConfigSchema);
// Returns: Validated and typed value
// Throws: ZodError if value doesn't match schema

// Get required value (throw if missing)
const requiredKey = config.require('stripe.apiKey');
// Throws: ConfigError if 'stripe.apiKey' not defined in any source

// Get all config for namespace
const allStripeConfig = config.getNamespace('stripe');
// Returns: { apiKey: '...', webhookSecret: '...', ... }

// Check if key exists
if (config.has('stripe.apiKey')) {
  // Key is defined in at least one source
}

Writing Configuration

// Set runtime value (highest priority, temporary)
config.set('feature.newUI', true);

// Set user preference (persisted to database)
await config.setUserPreference('theme', 'dark');

// Set tenant config (multi-tenant SaaS)
await config.setTenant('stripe.apiKey', 'sk_test_tenant123');

// Batch set
config.merge({
  'stripe.apiKey': 'sk_test_...',
  'stripe.webhookSecret': 'whsec_...',
});

Source Details

1. Runtime (Programmatic)

Use Case: Temporary overrides for testing, feature flags toggled at runtime.

// Example: Enable feature for A/B test
context.config.set('feature.newCheckout', true);

// Config is ephemeral (not persisted)
// Lost on server restart

Storage: In-memory Map (per-request context)

2. Environment Variables

Use Case: Deployment-specific config (API keys, database URLs, feature flags).

When an OS_* env var is present, it pins (locks) the value β€” no database scope (tenant or user) can override it.

# .env.production
OS_STRIPE_APIKEY=sk_live_...
OS_DATABASE_URL=postgresql://prod-db:5432/objectstack
OS_FEATURE_NEWUI=true
NODE_ENV=production

Naming Convention (see envKeyOf in service-settings):

  • Prefix with OS_
  • Form the key from {namespace}_{key}, then uppercase it
  • Dots and hyphens become underscores: stripe.apiKey β†’ OS_STRIPE_APIKEY
// Environment variables are auto-loaded at boot
const apiKey = config.get('stripe.apiKey');
// Reads from: process.env.OS_STRIPE_APIKEY

const dbUrl = config.get('database.url');
// Reads from: process.env.OS_DATABASE_URL

3. Tenant (Multi-Tenant)

Use Case: Customer-specific configuration in multi-tenant SaaS.

// Tenant "acme-corp" has custom Stripe key
await context.config.setTenant('stripe.apiKey', 'sk_live_acme...', {
  tenantId: 'acme-corp',
});

// Request from Acme Corp user
const apiKey = context.config.get('stripe.apiKey');
// Returns: 'sk_live_acme...' (tenant-specific)

// Request from different tenant
// Returns: Default Stripe key (from lower-priority source)

Storage: Database table

CREATE TABLE objectstack_tenant_config (
  tenant_id UUID NOT NULL,
  key TEXT NOT NULL,
  value JSONB NOT NULL,
  updated_at TIMESTAMP NOT NULL,
  PRIMARY KEY (tenant_id, key)
);

Isolation: Tenant config is automatically scoped to current tenant in request context.

4. User Preferences

Use Case: Per-user settings (language, timezone, UI theme).

// User changes language preference
await context.config.setUserPreference('locale', 'de');

// Next request from this user
const locale = context.config.get('locale');
// Returns: 'de' (from user preferences)

Storage: Database table

CREATE TABLE objectstack_user_preferences (
  user_id UUID NOT NULL,
  key TEXT NOT NULL,
  value JSONB NOT NULL,
  updated_at TIMESTAMP NOT NULL,
  PRIMARY KEY (user_id, key)
);

Type Coercion is driven by the type of the setting's default value (coerceEnvValue in service-settings):

OS_HTTP_TIMEOUT=30000              # default is number β†’ 30000
OS_FEATURE_NEWUI=true              # default is boolean β†’ true ('1'/'yes' also truthy)
OS_ALLOWED_ORIGINS=["a","b","c"]   # default is array/object β†’ JSON.parse β†’ ['a','b','c']

If the raw value can't be parsed for the expected type, it falls back to the raw string.

5. Configuration Files

Use Case: Environment-specific defaults checked into Git.

File Locations

ObjectStack loads config files in this order:

1. objectstack.config.ts          (TypeScript, recommended)
2. objectstack.config.js           (JavaScript)
3. objectstack.config.yml          (YAML)
4. objectstack.config.json         (JSON)

5. objectstack.config.{env}.ts     (Environment-specific)
   - objectstack.config.production.ts
   - objectstack.config.staging.ts
   - objectstack.config.development.ts
// objectstack.config.ts
import { defineStack } from '@objectstack/spec';

export default defineStack({
  // Database
  database: {
    url: 'postgresql://localhost:5432/objectstack',
    pool: {
      min: 2,
      max: 10,
    },
  },
  
  // Plugins
  plugins: {
    enabled: [
      '@objectstack/core',
      '@mycompany/crm',
    ],
  },
  
  // HTTP server
  http: {
    port: 3000,
    cors: {
      origins: ['http://localhost:3000'],
    },
  },
  
  // Feature flags
  features: {
    newUI: false,
    aiAssistant: true,
  },
  
  // Plugin-specific config
  stripe: {
    apiKey: process.env.STRIPE_API_KEY,
    webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
  },
});

Environment-Specific Config

// objectstack.config.production.ts
import { defineStack } from '@objectstack/spec';

export default defineStack({
  database: {
    url: process.env.DATABASE_URL,
    pool: {
      min: 10,
      max: 50,
    },
  },
  
  http: {
    port: process.env.PORT || 8080,
    cors: {
      origins: ['https://app.mycompany.com'],
    },
  },
  
  features: {
    newUI: true,  // Enabled in production
  },
});

File Selection: Based on NODE_ENV:

NODE_ENV=production  β†’ loads objectstack.config.production.ts
NODE_ENV=staging     β†’ loads objectstack.config.staging.ts
NODE_ENV=development β†’ loads objectstack.config.development.ts

YAML Config (Alternative)

# objectstack.config.yml
database:
  url: postgresql://localhost:5432/objectstack
  pool:
    min: 2
    max: 10

plugins:
  enabled:
    - '@objectstack/core'
    - '@mycompany/crm'

http:
  port: 3000
  cors:
    origins:
      - http://localhost:3000

features:
  newUI: false
  aiAssistant: true

6. Plugin Defaults

Use Case: Default values defined by plugin authors.

// @mycompany/crm plugin manifest
export default definePlugin({
  name: '@mycompany/crm',
  
  config: {
    defaults: {
      maxAccountsPerUser: 1000,
      enableScoring: true,
      syncInterval: 'daily',
    },
  },
});

// In application code (no config set)
const maxAccounts = config.get('crm.maxAccountsPerUser');
// Returns: 1000 (from plugin defaults)

Storage: In-memory (loaded from plugin manifest at boot)

Merge Strategies

When multiple sources define the same key, ObjectStack uses deep merge for objects and replace for primitives.

Replace (Primitives)

// Plugin defaults
{ stripe: { apiKey: 'sk_test_default' } }

// File config
{ stripe: { apiKey: 'sk_test_file' } }

// Environment variable
OS_STRIPE_APIKEY=sk_test_env

// Result
config.get('stripe.apiKey')
// Returns: 'sk_test_env' (highest priority source)

Deep Merge (Objects)

// Plugin defaults
{
  http: {
    port: 3000,
    timeout: 30000,
    cors: {
      origins: ['*'],
      credentials: false,
    },
  },
}

// File config
{
  http: {
    port: 8080,
    cors: {
      origins: ['https://app.example.com'],
    },
  },
}

// Result (deep merge)
config.get('http')
// Returns:
{
  port: 8080,              // From file (overrides default)
  timeout: 30000,          // From defaults (not overridden)
  cors: {
    origins: ['https://app.example.com'],  // From file
    credentials: false,    // From defaults (not overridden)
  },
}

Array Merge (Replace, Not Concat)

Arrays are replaced, not concatenated:

// Defaults
{ plugins: { enabled: ['@objectstack/core', '@mycompany/base'] } }

// File config
{ plugins: { enabled: ['@objectstack/core', '@mycompany/crm'] } }

// Result
config.get('plugins.enabled')
// Returns: ['@objectstack/core', '@mycompany/crm']
// (File config replaces defaults, does NOT concat)

Tenant Isolation

In multi-tenant SaaS, each tenant can have isolated configuration.

Automatic Scoping

// Request from Tenant A
context.tenantId = 'tenant-a';
const apiKey = context.config.get('stripe.apiKey');
// Checks: tenant-a config β†’ env β†’ file β†’ defaults

// Request from Tenant B
context.tenantId = 'tenant-b';
const apiKey = context.config.get('stripe.apiKey');
// Checks: tenant-b config β†’ env β†’ file β†’ defaults

Setting Tenant Config

// Admin API: Set tenant-specific config
await admin.setTenantConfig('tenant-a', {
  'stripe.apiKey': 'sk_live_tenantA...',
  'features.newUI': true,
});

await admin.setTenantConfig('tenant-b', {
  'stripe.apiKey': 'sk_live_tenantB...',
  'features.newUI': false,
});

Tenant Config UI

// ObjectUI admin panel for tenant config
export default defineView({
  name: 'tenant_config',
  type: 'form',
  
  fields: [
    {
      name: 'stripe.apiKey',
      label: 'Stripe API Key',
      type: 'text',
      secret: true,
    },
    {
      name: 'features.newUI',
      label: 'Enable New UI',
      type: 'boolean',
    },
  ],
  
  onSave: async ({ values, context }) => {
    await context.config.setTenant(values, {
      tenantId: context.tenantId,
    });
  },
});

Secrets Management

Sensitive configuration (API keys, passwords) requires special handling.

Marking Secrets

// Plugin config schema
export const configSchema = z.object({
  apiKey: z.string()
    .describe('Stripe API Key')
    .meta({ secret: true }),  // ← Mark as secret
  
  webhookSecret: z.string()
    .describe('Webhook Secret')
    .meta({ secret: true }),
});

Secret Storage

Secrets are encrypted at rest using AES-256-GCM:

CREATE TABLE objectstack_secrets (
  key TEXT PRIMARY KEY,
  encrypted_value BYTEA NOT NULL,
  encryption_key_id UUID NOT NULL,
  created_at TIMESTAMP NOT NULL
);

Secret Access

// Secrets are automatically decrypted when accessed
const apiKey = config.get('stripe.apiKey');
// ObjectStack decrypts value transparently

// Secrets are redacted in logs
logger.info('Config loaded', { config: config.getAll() });
// Output: { stripe: { apiKey: '[REDACTED]', ... } }

External Secret Stores

ObjectStack integrates with external secret managers:

// objectstack.config.ts
export default defineStack({
  secrets: {
    provider: 'aws-secrets-manager',
    
    // Map config keys to secret ARNs
    mappings: {
      'stripe.apiKey': 'arn:aws:secretsmanager:us-east-1:123:secret:stripe-key',
      'database.password': 'arn:aws:secretsmanager:us-east-1:123:secret:db-pass',
    },
  },
});

// Access works the same
const apiKey = config.get('stripe.apiKey');
// ObjectStack fetches from AWS Secrets Manager transparently

Supported Providers:

  • AWS Secrets Manager
  • Google Cloud Secret Manager
  • Azure Key Vault
  • HashiCorp Vault
  • Environment variables (fallback)

Configuration Validation

ObjectStack validates configuration against Zod schemas at boot.

Schema Definition

// Plugin defines config schema
export const configSchema = z.object({
  maxAccountsPerUser: z.number()
    .min(1)
    .max(10000)
    .default(1000),
  
  enableScoring: z.boolean()
    .default(true),
  
  syncInterval: z.enum(['hourly', 'daily', 'weekly'])
    .default('daily'),
  
  apiKey: z.string()
    .min(1)
    .describe('API Key is required')
    .meta({ secret: true }),
});

Boot-Time Validation

// ObjectStack validates config during boot
export async function onBoot({ context }) {
  // Get and validate config
  const config = context.config.get('crm', configSchema);
  // If config is invalid, ObjectStack throws ZodError with details
}

Validation Error Example

ConfigValidationError: Invalid configuration for plugin @mycompany/crm

Errors:
  - crm.apiKey: Required
  - crm.maxAccountsPerUser: Expected number, received string
  - crm.syncInterval: Invalid enum value. Expected 'hourly' | 'daily' | 'weekly', received 'monthly'

Fix these errors in:
  1. Environment variable: OS_CRM_APIKEY
  2. Config file: objectstack.config.ts
  3. Plugin defaults: @mycompany/crm/plugin.manifest.ts

Configuration Inspection

CLI Commands

A dedicated config topic on the os CLI is not yet implemented β€” the commands below are illustrative of the intended surface. Today, configuration is validated as part of os validate and inspected via os info / os doctor.

# (planned) Show all configuration (merged result)
os config show

# (planned) Show config for specific namespace
os config show stripe

# (planned) Show which source provides each value
os config sources

# (planned) Validate configuration
os config validate

# (planned) Export configuration (for backup)
os config export > backup.json

# (planned) Import configuration
os config import backup.json

Programmatic Inspection

// Get config with source attribution
const configWithSources = config.inspect('stripe.apiKey');
// Returns:
{
  value: 'sk_live_...',
  source: 'environment',  // Which source provided the value
  sources: {
    runtime: undefined,
    user: undefined,
    tenant: undefined,
    environment: 'sk_live_...',
    file: 'sk_test_...',
    defaults: 'sk_test_default',
  },
}

// List all config keys
const allKeys = config.keys();
// Returns: ['stripe.apiKey', 'stripe.webhookSecret', ...]

// Get config schema
const schema = config.getSchema('stripe');
// Returns: Zod schema for 'stripe' namespace

Real-World Examples

Example 1: Feature Flags

// Plugin defaults (features disabled by default)
{
  features: {
    newUI: false,
    aiAssistant: false,
  },
}

// File config (enable in staging)
// objectstack.config.staging.ts
{
  features: {
    newUI: true,  // Test in staging
  },
}

// Tenant override (enable for specific customer)
await admin.setTenantConfig('beta-customer', {
  'features.aiAssistant': true,
});

// User preference (user opts into beta)
await context.config.setUserPreference('features.newUI', true);

// In application code
if (config.get('features.newUI')) {
  return <NewUIComponent />;
}

Example 2: Multi-Region Deployment

// Base config (shared)
// objectstack.config.ts
{
  database: {
    pool: { min: 2, max: 10 },
  },
}

// US region
// objectstack.config.us.ts
{
  database: {
    url: process.env.DATABASE_URL_US,
  },
  stripe: {
    apiKey: process.env.STRIPE_KEY_US,
  },
}

// EU region
// objectstack.config.eu.ts
{
  database: {
    url: process.env.DATABASE_URL_EU,
  },
  stripe: {
    apiKey: process.env.STRIPE_KEY_EU,
  },
}

// Deploy with region-specific config
NODE_ENV=us node server.js   # Loads objectstack.config.us.ts
NODE_ENV=eu node server.js   # Loads objectstack.config.eu.ts

Example 3: Development Overrides

// Production config
{
  stripe: {
    apiKey: process.env.STRIPE_API_KEY,  // Live key
  },
  email: {
    provider: 'sendgrid',
    fromAddress: 'noreply@company.com',
  },
}

// Development override
// objectstack.config.development.ts
{
  stripe: {
    apiKey: 'sk_test_...',  // Test key
  },
  email: {
    provider: 'console',  // Log emails instead of sending
  },
}

// Developer can further override with .env.local
OS_EMAIL_PROVIDER=mailhog  # Use local Mailhog for testing

Best Practices

1. Never Hardcode Secrets

// βœ— BAD: Hardcoded API key
const apiKey = 'sk_live_abc123';

// βœ“ GOOD: Load from config
const apiKey = config.require('stripe.apiKey');

2. Use Environment-Specific Files

// βœ“ GOOD: Separate configs for each environment
objectstack.config.production.ts   # Production settings
objectstack.config.staging.ts      # Staging settings
objectstack.config.development.ts  # Development settings

3. Validate Early

// βœ“ GOOD: Validate during boot, not at runtime
export async function onBoot({ context }) {
  const config = context.config.get('myPlugin', myConfigSchema);
  // Throws clear error if invalid
}

// βœ— BAD: Validate on first use (fails in production)
export async function someHandler({ context }) {
  const config = context.config.get('myPlugin', myConfigSchema);
  // Error only occurs when handler is called!
}

4. Provide Sensible Defaults

// βœ“ GOOD: Plugin works out-of-box with defaults
export default definePlugin({
  config: {
    defaults: {
      maxRetries: 3,
      timeout: 30000,
    },
  },
});

5. Document Configuration

// βœ“ GOOD: Use Zod descriptions
export const configSchema = z.object({
  apiKey: z.string()
    .describe('API key from Stripe Dashboard > Developers > API Keys'),
  
  webhookSecret: z.string()
    .describe('Webhook signing secret for verifying webhook payloads'),
});

Summary

ObjectStack configuration resolution:

  • Six sources with clear precedence (runtime > environment > tenant > user > file > defaults)
  • Deep merge for objects, replace for primitives
  • Tenant isolation for multi-tenant SaaS
  • Secret encryption with external secret store integration
  • Boot-time validation using Zod schemas
  • Inspection tools for debugging config issues

Next: Learn about internationalization in i18n Standard.

On this page