Skip to content

Latest commit

 

History

History
240 lines (199 loc) · 6.97 KB

File metadata and controls

240 lines (199 loc) · 6.97 KB
id config-layers-schema
title Composable Configuration Layers
category environment-config
skillLevel intermediate
tags
environment
config
layers
composition
schema
dependency-injection
lessonOrder 5
rule
description
Composable Configuration Layers using Schema.
summary Real applications need configuration from multiple sources: default values, environment variables, config files, runtime overrides. Loading config becomes scattered across the codebase. Layers...

Problem

Real applications need configuration from multiple sources: default values, environment variables, config files, runtime overrides. Loading config becomes scattered across the codebase. Layers conflict, precedence is unclear. You need a composable, layered configuration system where each layer overrides the previous one in a predictable order: defaults → files → env vars → runtime.

Solution

import { Schema, Effect, Record as R } from "effect"
import * as fs from "fs"

// 1. Define configuration schema
const DatabaseConfig = Schema.Struct({
  host: Schema.String,
  port: Schema.pipe(Schema.Number, Schema.int(), Schema.between(1024, 65535)),
  database: Schema.String,
  maxConnections: Schema.pipe(Schema.Number, Schema.int(), Schema.between(1, 1000)),
})

const CacheConfig = Schema.Struct({
  ttl: Schema.pipe(Schema.Number, Schema.int(), Schema.between(0, 86400)),
  enabled: Schema.Boolean,
})

const AppConfig = Schema.Struct({
  database: DatabaseConfig,
  cache: CacheConfig,
  debug: Schema.Boolean,
})

type AppConfig = typeof AppConfig.Type

// 2. Define default configuration
const defaultConfig: AppConfig = {
  database: {
    host: "localhost",
    port: 5432,
    database: "app_db",
    maxConnections: 10,
  },
  cache: {
    ttl: 3600,
    enabled: true,
  },
  debug: false,
}

// 3. Load from config file
const loadConfigFile = (path: string): Effect.Effect<Partial<AppConfig>, Error> =>
  Effect.gen(function* () {
    return yield* Effect.tryPromise({
      try: async () => {
        const content = await fs.promises.readFile(path, "utf-8")
        return JSON.parse(content)
      },
      catch: (error) => {
        const msg = error instanceof Error ? error.message : String(error)
        return new Error(`Failed to load config file: ${msg}`)
      },
    })
  })

// 4. Override with environment variables
const loadEnvOverrides = (): Partial<AppConfig> => {
  const overrides: Partial<AppConfig> = {}

  if (process.env.DB_HOST) {
    overrides.database ??= {}
    overrides.database.host = process.env.DB_HOST
  }

  if (process.env.DB_PORT) {
    overrides.database ??= {}
    overrides.database.port = parseInt(process.env.DB_PORT, 10)
  }

  if (process.env.CACHE_TTL) {
    overrides.cache ??= {}
    overrides.cache.ttl = parseInt(process.env.CACHE_TTL, 10)
  }

  if (process.env.DEBUG) {
    overrides.debug = process.env.DEBUG === "true"
  }

  return overrides
}

// 5. Deep merge configuration layers
const mergeConfigs = (
  base: AppConfig,
  fileConfig: Partial<AppConfig>,
  envConfig: Partial<AppConfig>,
): AppConfig => {
  const merged = structuredClone(base)

  // Merge file config
  if (fileConfig.database) {
    merged.database = { ...merged.database, ...fileConfig.database }
  }
  if (fileConfig.cache) {
    merged.cache = { ...merged.cache, ...fileConfig.cache }
  }
  if (fileConfig.debug !== undefined) {
    merged.debug = fileConfig.debug
  }

  // Merge env overrides (highest priority)
  if (envConfig.database) {
    merged.database = { ...merged.database, ...envConfig.database }
  }
  if (envConfig.cache) {
    merged.cache = { ...merged.cache, ...envConfig.cache }
  }
  if (envConfig.debug !== undefined) {
    merged.debug = envConfig.debug
  }

  return merged
}

// 6. Load and validate complete configuration
const loadConfig = (configFilePath?: string): Effect.Effect<AppConfig, Error> =>
  Effect.gen(function* () {
    // Load file config (if provided)
    const fileConfig = configFilePath
      ? yield* loadConfigFile(configFilePath)
      : {}

    // Load env overrides
    const envConfig = loadEnvOverrides()

    // Merge all layers
    const merged = mergeConfigs(defaultConfig, fileConfig, envConfig)

    // Validate merged config
    const validated = yield* Effect.tryPromise({
      try: () => Schema.decodeUnknown(AppConfig)(merged),
      catch: (error) => {
        const msg = error instanceof Error ? error.message : String(error)
        return new Error(`Config validation failed: ${msg}`)
      },
    })

    // Log configuration layers
    console.log("Configuration loaded:")
    console.log(`  Defaults: ✓`)
    if (configFilePath) console.log(`  File: ✓`)
    if (Object.keys(envConfig).length > 0) console.log(`  Env: ✓`)

    return validated
  })

// 7. Configuration service
class ConfigService {
  constructor(readonly config: AppConfig) {}

  getDatabase = () => this.config.database
  getCache = () => this.config.cache
  isDebug = () => this.config.debug
}

const ConfigServiceLive = (configPath?: string) =>
  Effect.gen(function* () {
    const config = yield* loadConfig(configPath)
    return new ConfigService(config)
  }).pipe(Effect.layer)

// Usage
const appLogic = Effect.gen(function* () {
  const configService = yield* Effect.service(ConfigService)

  const db = configService.getDatabase()
  console.log(`Database: ${db.host}:${db.port}/${db.database}`)
  console.log(`Max connections: ${db.maxConnections}`)

  const cache = configService.getCache()
  console.log(`Cache enabled: ${cache.enabled}, TTL: ${cache.ttl}s`)

  console.log(`Debug mode: ${configService.isDebug()}`)

  return configService.config
})

// Run with explicit config file path
Effect.runPromise(
  appLogic.pipe(
    Effect.provide(ConfigServiceLive("./config.json"))
  )
)
  .then((config) => console.log("App initialized successfully"))
  .catch((error) => console.error(`Configuration error: ${error.message}`))

Why This Works

Concept Explanation
Default values Safe fallbacks for all configuration
File config Persistent configuration without env vars
Environment overrides Production-specific values at runtime
Merge strategy Clear precedence: defaults → file → env
Schema validation All layers validated after merging
Service layer Configuration accessed via dependency injection
Immutable after load Config locked after validation, prevents mutations
Type safety TypeScript enforces config shape throughout app

When to Use

  • Multi-environment deployments with different configs per environment
  • Development (local config file) vs. production (env vars)
  • Applications with both required and optional settings
  • Twelve-factor app methodology implementations
  • Microservices with environment-specific overrides
  • When you need audit trail of config sources

Related Patterns