12. Environment-based adapter selection
Date: 2026-03-16
Status
Accepted
Context
The proxy uses hexagonal architecture where main() wires concrete adapters into the service layer. Different environments need different adapters:
- Development: in-memory adapters with pre-populated test data — no external dependencies, immediate feedback
- Production: Cognito JWT adapters for real token validation, policy repositories backed by persistent stores
We need a mechanism to select the right adapter set at startup and to provide environment-specific default configuration.
Decision
We introduce an EnvState enum (dev, prod) controlled by the environment variable HDC_PROXY_ENV_STATE, defaulting to prod. Each environment has an associated .env.{state} file that provides default values.
Per-environment .env files
Settings are loaded in two phases by the load_settings() function:
def load_settings(env_file: str = ".env") -> ProxySettings:
load_dotenv(env_file, override=False) # Phase 1: base config
state = os.getenv("HDC_PROXY_ENV_STATE", "prod")
load_dotenv(f"{env_file}.{state}", override=True) # Phase 2: state overrides
return ProxySettings()
Why load_dotenv() instead of _env_file=: ProxySettings(_env_file=...) only delivers vars to the root model. Nested BaseSettings subclasses (CognitoSettings, SessionConfig, PKCEConfig) are instantiated via default_factory and read os.environ independently — they never see vars passed via _env_file to the parent. Using load_dotenv() puts all vars into os.environ first, so every nested class can read its own prefixed vars correctly.
Priority (highest to lowest):
- Process environment variables (already in
os.environbeforeload_dotenvis called) - Values from
.env.{state}file (override=True— applied on top of base) - Values from
.envbase file (override=False— do not clobber process env) - Field defaults in settings classes
.env is the base config file loaded automatically on every startup. .env.{state} provides state-specific overrides on top. Both files are optional — if a file does not exist, no error is raised.
HDC_PROXY_ENV_STATE=dev → loads .env first, then .env.dev on top
HDC_PROXY_ENV_STATE=prod → loads .env first, then .env.prod on top (default)
Example .env files
.env (primary config file — gitignored when it contains secrets):
HDC_PROXY_UPSTREAM_URL=https://upstream.internal.example.com
HDC_PROXY_LOG_LEVEL=INFO
COGNITO_USER_POOL_ID=eu-west-1_abc123
COGNITO_CLIENT_ID=my-app-client
COGNITO_REGION=eu-west-1
SESSION_SECRET_KEY=my-secret
.env.dev (committed to repo — no secrets, overrides base for dev):
HDC_PROXY_ENV_STATE=dev
HDC_PROXY_UPSTREAM_URL=http://localhost:8080
HDC_PROXY_LOG_LEVEL=DEBUG
.env is added to .gitignore when it contains secrets. .env.dev can be committed safely (no secrets, only dev-specific overrides).
Adapter selection hierarchy
The same ProxySettings class is used in all environments. Adapter selection follows a fixed priority order evaluated at startup in wire_services():
| Priority | Condition | Adapters | Use case |
|---|---|---|---|
| 1 | cognito.user_pool_id is set |
Cognito JWT validator + Cognito principal resolver | Production and local testing against a real User Pool |
| 2 | env_state = dev |
In-memory with pre-populated test data | Local development without external dependencies |
| 3 | (fallback) | Empty in-memory | Skeleton startup, all requests → 401 |
Cognito always takes precedence — if COGNITO_USER_POOL_ID is set in any environment (including dev), the proxy uses real Cognito token validation. This allows developers to test against a real User Pool locally by adding Cognito settings to .env.dev.
env_state controls default configuration (via .env.{state} files) and activates the dev test data only when no Cognito is configured.
if settings.cognito.user_pool_id:
# CognitoTokenValidator + CognitoPrincipalResolver for JWT
# In-memory for API keys and authorization (until persistent adapters exist)
elif settings.is_dev:
# In-memory adapters with test credentials, policy rules, resources
else:
# Empty in-memory adapters (all requests → 401)
EnvState enum
class EnvState(StrEnum):
DEV = "dev"
PROD = "prod"
Placed in adapters/settings.py alongside ProxySettings. A convenience property ProxySettings.is_dev avoids repeating settings.env_state is EnvState.DEV throughout the codebase.
Concrete scenarios
The combination of .env, .env.{state}, and COGNITO_USER_POOL_ID determines which adapters are wired and which files are loaded:
| Scenario | .env contains |
state resolved |
Also loads | Adapters wired |
|---|---|---|---|---|
| Dev (no Cognito) | HDC_PROXY_ENV_STATE=dev |
dev |
.env.dev |
In-memory (pre-populated) |
| Dev + real Cognito | HDC_PROXY_ENV_STATE=dev + COGNITO_* |
dev |
.env.dev |
Cognito (takes precedence) |
| Prod (implicit) | COGNITO_* (no ENV_STATE) |
prod |
.env.prod (if exists) |
Cognito |
| Prod (explicit) | HDC_PROXY_ENV_STATE=prod + COGNITO_* |
prod |
.env.prod (if exists) |
Cognito |
| Prod (no Cognito) | (nothing) | prod |
.env.prod (if exists) |
Empty in-memory → 401 |
In the Dev + real Cognito scenario, .env carries all Cognito credentials and secrets while .env.dev provides development-friendly overrides (log level, upstream URL). This is the recommended local setup for testing against a real User Pool.
Dev mode behavior
When env_state=dev and COGNITO_USER_POOL_ID is not set:
- Pre-populated token (
dev-token), API key (dev-api-key), principal, policy rules, and resources - WARNING banner logged at startup with test credentials
- Developers can immediately test 401, 403, 200/502 scenarios with curl
When env_state=dev and COGNITO_USER_POOL_ID is set:
- Cognito adapters are used for JWT validation (real User Pool)
.env.devstill provides other defaults (log level, upstream URL, etc.)- No dev banner or test credentials — authentication is handled by Cognito
Alternatives considered
Boolean dev_mode flag
Simpler but less extensible. A boolean doesn't communicate the concept of "environment state" and cannot accommodate future environments (e.g., staging, test) without breaking changes.
env_state controls wiring only (no .env files)
Pure environment variable configuration without .env files. Simpler, but forces developers to set multiple variables manually or use shell scripts. .env files are the standard convention for local development and integrate natively with pydantic-settings, Docker, and CI.
Separate settings classes per environment
DevSettings and ProdSettings with different defaults. Introduces class hierarchy and duplication. The adapter wiring logic must still branch somewhere — better to branch in main() on a single field than on class types.
Consequences
wire_services()evaluates a fixed priority: Cognito > dev in-memory > empty in-memory- Cognito configuration works in any environment — developers can test against a real User Pool by adding
COGNITO_*to.envor.env.dev .env.devprovides sensible defaults for local development (committed to repo).envserves as the primary config file for secrets and shared settings (gitignored when it contains secrets)load_dotenv()is used instead of_env_file=so that nested settings models (CognitoSettings,SessionConfig,PKCEConfig) can each read their own prefixed vars fromos.environ- Process environment variables always take highest priority, overriding both
.envand.env.{state}file values - Dev mode is self-documenting: the WARNING banner and logged credentials make it obvious
- Future environments (staging, test) can be added to
EnvStatewith a corresponding.env.{state}file - The decision is consistent with hexagonal architecture: same ports and services, different adapter implementations selected at the composition root
References
- ADR-0007 In-Memory Adapters — In-memory adapters as reference implementations
- ADR-0010 Cognito JWT Adapter — Cognito adapter for production JWT validation
- ADR-0011 Starlette for HTTP Adapter — HTTP adapter wired in
create_app()