Skip to content

Configuration and secrets

Load configuration once at the composition root, validate it before the application accepts traffic, and pass the resulting values into resource factories. Use cases and adapters should not read environment variables directly.

Define a validated settings model

Pydantic is already a Tenchi dependency, so a small application can validate its environment without another package:

import os
from collections.abc import Mapping
from typing import Literal

from pydantic import BaseModel, Field, SecretStr


class Settings(BaseModel):
    environment: Literal["development", "staging", "production"]
    database_url: str = Field(min_length=1)
    auth_secret: SecretStr
    request_timeout_seconds: float = Field(default=10.0, gt=0)


def load_settings(env: Mapping[str, str] = os.environ) -> Settings:
    return Settings.model_validate(
        {
            "environment": env.get("APP_ENV"),
            "database_url": env.get("APP_DATABASE_URL"),
            "auth_secret": env.get("APP_AUTH_SECRET"),
            "request_timeout_seconds": env.get(
                "APP_REQUEST_TIMEOUT_SECONDS",
                "10",
            ),
        }
    )

Call load_settings() while composing the application. A missing database URL, missing secret, invalid environment name, or non-positive timeout then stops startup with a Pydantic validation error.

For layered files, cloud secret managers, or custom settings sources, use a dedicated package such as pydantic-settings. Keep the result as one typed application value regardless of where the values came from.

Wire settings into lifespan

Process-scoped resources should receive only the settings they need:

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass

from app.infra.database import DatabasePool, open_database_pool
from app.infra.tokens import TokenVerifier


@dataclass(frozen=True, slots=True)
class Runtime:
    database: DatabasePool
    tokens: TokenVerifier


settings = load_settings()


@asynccontextmanager
async def lifespan() -> AsyncIterator[Runtime]:
    database = await open_database_pool(settings.database_url)
    tokens = TokenVerifier(settings.auth_secret.get_secret_value())
    try:
        yield Runtime(database=database, tokens=tokens)
    finally:
        await database.close()

The object yielded by lifespan is passed to a one-argument context factory. That factory creates request-scoped repositories from the pool and makes the token verifier available to the authentication hook through the context.

Keep configuration out of AppContext unless a use case genuinely needs a value as application input. Database URLs, credentials, telemetry endpoints, and SDK options are wiring concerns; the constructed ports belong in context.

Treat secrets as capabilities

Do not log the environment mapping

Request headers can also contain credentials. Log selected, redacted fields rather than serializing settings, os.environ, or complete header maps.

Separate deploy-time and runtime configuration

Deploy-time values select infrastructure and security policy: database URLs, issuer metadata, trusted hosts, CORS origins, telemetry exporters, and feature rollout configuration. Runtime request data belongs in validated contract inputs or authenticated identity.

Changing a deploy-time value should create a new process generation. This makes rollbacks predictable and lets readiness checks verify the exact resources that generation opened.

Test configuration failures

Pass a plain mapping to load_settings() in tests:

import pytest
from pydantic import ValidationError


def test_requires_the_auth_secret() -> None:
    with pytest.raises(ValidationError):
        load_settings(
            {
                "APP_ENV": "production",
                "APP_DATABASE_URL": "postgresql://db.example/app",
            }
        )

Test the model separately from resource-opening integration tests. A unit test should not need the process environment or a secret manager.