Settings architecture: switch from inheritance to composition (pydantic-settings best practice)
Tracking follow-up to PR introducing 3-class Settings split with Environment enum (branch refactor/settings-decoupled).
Context
The current refactor uses class inheritance to share settings between three runtime profiles:
DatabaseSettings (alembic, migration job)
└── WorkerSettings (verification-functions)
└── WebSettings (api webapp)
Each level adds fields. A registry (configure_settings() + get_*_settings()) lets entry-points pick their profile and shared modules pull the right typed accessor.
This works, but research during the implementation surfaced that composition is the recommended pydantic-settings v2 pattern, not inheritance.
Research findings
Three credible sources, one consistent answer:
| Source |
Position |
Official pydantic-settings docs (https://docs.pydantic.dev/latest/concepts/pydantic_settings/) |
Shows composition (nested BaseModel as field values, env_nested_delimiter="__") as the primary documented pattern. Inheritance is shown only for single-class env_prefix examples. |
| Industry guidance (multiple blog posts and Stack Overflow answers from pydantic v2 era) |
Explicitly recommends composition over inheritance. Cited reasons: clear namespace (settings.database.url vs settings.db_url), modularity, no field-name collisions, reusable sub-configs. Inheritance described as "error-prone with multiple inheritance" and "harder to scale." |
| FastAPI+Celery monorepo guidance (closest analog to our 3-process setup) |
Recommends one shared Settings class in common/; configuration differences come from environment variables, not class hierarchy. |
Why the current inheritance design is sub-optimal
-
Pydantic-settings is built for composition. The whole env_nested_delimiter="__" mechanism exists for nested config models. Inheritance treats configuration as a class hierarchy problem; composition treats it as a data-structure problem (which is what it actually is).
-
The IS-A relationship doesn't hold semantically. "Webapp IS-A worker" reads cleanly as English but it's a category error in code. The webapp and the worker are peer processes that share some config fields; one isn't a subtype of the other.
-
Fields leak. A WebSettings instance has labs_verification_secret on it, even though no webapp code uses that field. The type checker can't say "this field only matters in the worker." With composition, the webapp's settings simply don't reference a LabsConfig sub-config (or it has whatever subset it actually uses).
-
Validators end up in the wrong place. Our _validate_web lives on WebSettings. If we ever needed a validator that depends on BOTH a worker field and a web field, it'd live on WebSettings and be invisible from WorkerSettings even though the worker has those fields too. With composition, validators live on the sub-model they validate.
-
The registry is a smell. configure_settings() + get_database_settings() / get_worker_settings() / get_web_settings() exists because Python doesn't naturally express "this process wants only the DB part of settings, please skip the OAuth validator." That's because inheritance is the wrong tool. With composition, each process instantiates a different top-level Settings class that only references the sub-configs it needs — no registry, no isinstance checks.
Proposed design
# Reusable sub-configs — small, focused, single source of truth per concern.
class DatabaseConfig(BaseModel):
url: str = ""
host: str = ""
port: int = 5432
database: str = "learntocloud"
user: str = ""
pool_size: int = 5
# ... etc
@model_validator(mode="after")
def _validate(self) -> Self:
if not self.url and not (self.host and self.user):
raise ValueError("Database configuration required.")
return self
class GitHubConfig(BaseModel):
"""Server-to-server GitHub API access."""
token: str = ""
class OAuthConfig(BaseModel):
"""User-facing OAuth login flow."""
client_id: str = ""
client_secret: str = ""
class LabsConfig(BaseModel):
verification_secret: str = ""
external_api_timeout: float = 15.0
# Three top-level Settings classes — one per process. NO inheritance.
# Each picks the sub-configs it actually uses.
class MigrationSettings(BaseSettings):
"""Loaded by api/alembic/env.py."""
model_config = SettingsConfigDict(env_file=".env", env_nested_delimiter="__", extra="ignore")
database: DatabaseConfig = DatabaseConfig()
class WorkerSettings(BaseSettings):
"""Loaded by apps/verification-functions/function_app.py."""
model_config = SettingsConfigDict(env_file=".env", env_nested_delimiter="__", extra="ignore")
database: DatabaseConfig = DatabaseConfig()
github: GitHubConfig = GitHubConfig()
labs: LabsConfig = LabsConfig()
class WebSettings(BaseSettings):
"""Loaded by api/src/learn_to_cloud/main.py."""
model_config = SettingsConfigDict(env_file=".env", env_nested_delimiter="__", extra="ignore")
environment: Environment = Environment.PRODUCTION
database: DatabaseConfig = DatabaseConfig()
github: GitHubConfig = GitHubConfig()
labs: LabsConfig = LabsConfig()
oauth: OAuthConfig = OAuthConfig()
session_secret_key: str = "dev-secret-key-change-in-production"
# ... web-specific fields
Each process instantiates its own top-level class directly. No registry. No configure_settings() global. No isinstance checks.
Migration scope (this is why it's a separate PR)
Switching to composition is a coordinated change that touches:
-
Every call site — settings.database_url becomes settings.database.url, settings.github_token becomes settings.github.token, etc. About 30+ access patterns across api + shared + verification-functions.
-
Every environment variable name:
api/.env.example: DATABASE_URL → DATABASE__URL, GITHUB_CLIENT_ID → OAUTH__CLIENT_ID, etc.
apps/verification-functions/local.settings.json
infra/migrations.tf (env vars on the migration job)
infra/container-apps.tf (env vars on the api container app)
infra/functions.tf (env vars on the verification-functions Functions app)
.github/workflows/deploy.yml
- Azure Key Vault references — the secret names may need to change
-
Coordinated production deploy. The new env vars must be set in prod BEFORE the new code rolls out. Otherwise the app fails to start because DATABASE__URL isn't found, only the old DATABASE_URL exists. Options:
- Two-phase deploy: deploy infra with BOTH old and new env var names; deploy code that reads only new names; deploy infra removing old names. Three releases, low risk.
- Single-phase with backfill: configure code to fall back to old names (
AliasChoices) for one release, then remove the fallback.
-
Test rewrites. All WebSettings(field=value) constructions become WebSettings(database=DatabaseConfig(url=...)) style. About a dozen test fixtures + test_config.py rewrites.
-
get_*_settings() accessors and configure_settings() registry go away. Replaced with direct instantiation at each entry-point: _settings = WebSettings() in api/main.py, MigrationSettings() in env.py, WorkerSettings() in function_app.py.
Recommendation
Treat this as a dedicated PR after the current refactor/settings-decoupled work lands. The current inheritance design is functional, documented as a known sub-optimal pattern, and unblocks the immediate goal (kill the DEBUG=true hack). The composition rewrite is a quality improvement that deserves its own focused review and its own coordinated deploy.
Cross-references
Settings architecture: switch from inheritance to composition (pydantic-settings best practice)
Tracking follow-up to PR introducing 3-class Settings split with
Environmentenum (branchrefactor/settings-decoupled).Context
The current refactor uses class inheritance to share settings between three runtime profiles:
Each level adds fields. A registry (
configure_settings()+get_*_settings()) lets entry-points pick their profile and shared modules pull the right typed accessor.This works, but research during the implementation surfaced that composition is the recommended pydantic-settings v2 pattern, not inheritance.
Research findings
Three credible sources, one consistent answer:
pydantic-settingsdocs (https://docs.pydantic.dev/latest/concepts/pydantic_settings/)BaseModelas field values,env_nested_delimiter="__") as the primary documented pattern. Inheritance is shown only for single-classenv_prefixexamples.settings.database.urlvssettings.db_url), modularity, no field-name collisions, reusable sub-configs. Inheritance described as "error-prone with multiple inheritance" and "harder to scale."Settingsclass incommon/; configuration differences come from environment variables, not class hierarchy.Why the current inheritance design is sub-optimal
Pydantic-settings is built for composition. The whole
env_nested_delimiter="__"mechanism exists for nested config models. Inheritance treats configuration as a class hierarchy problem; composition treats it as a data-structure problem (which is what it actually is).The IS-A relationship doesn't hold semantically. "Webapp IS-A worker" reads cleanly as English but it's a category error in code. The webapp and the worker are peer processes that share some config fields; one isn't a subtype of the other.
Fields leak. A
WebSettingsinstance haslabs_verification_secreton it, even though no webapp code uses that field. The type checker can't say "this field only matters in the worker." With composition, the webapp's settings simply don't reference aLabsConfigsub-config (or it has whatever subset it actually uses).Validators end up in the wrong place. Our
_validate_weblives onWebSettings. If we ever needed a validator that depends on BOTH a worker field and a web field, it'd live onWebSettingsand be invisible fromWorkerSettingseven though the worker has those fields too. With composition, validators live on the sub-model they validate.The registry is a smell.
configure_settings()+get_database_settings()/get_worker_settings()/get_web_settings()exists because Python doesn't naturally express "this process wants only the DB part of settings, please skip the OAuth validator." That's because inheritance is the wrong tool. With composition, each process instantiates a different top-level Settings class that only references the sub-configs it needs — no registry, no isinstance checks.Proposed design
Each process instantiates its own top-level class directly. No registry. No
configure_settings()global. Noisinstancechecks.Migration scope (this is why it's a separate PR)
Switching to composition is a coordinated change that touches:
Every call site —
settings.database_urlbecomessettings.database.url,settings.github_tokenbecomessettings.github.token, etc. About 30+ access patterns across api + shared + verification-functions.Every environment variable name:
api/.env.example:DATABASE_URL→DATABASE__URL,GITHUB_CLIENT_ID→OAUTH__CLIENT_ID, etc.apps/verification-functions/local.settings.jsoninfra/migrations.tf(env vars on the migration job)infra/container-apps.tf(env vars on the api container app)infra/functions.tf(env vars on the verification-functions Functions app).github/workflows/deploy.ymlCoordinated production deploy. The new env vars must be set in prod BEFORE the new code rolls out. Otherwise the app fails to start because
DATABASE__URLisn't found, only the oldDATABASE_URLexists. Options:AliasChoices) for one release, then remove the fallback.Test rewrites. All
WebSettings(field=value)constructions becomeWebSettings(database=DatabaseConfig(url=...))style. About a dozen test fixtures +test_config.pyrewrites.get_*_settings()accessors andconfigure_settings()registry go away. Replaced with direct instantiation at each entry-point:_settings = WebSettings()in api/main.py,MigrationSettings()in env.py,WorkerSettings()in function_app.py.Recommendation
Treat this as a dedicated PR after the current
refactor/settings-decoupledwork lands. The current inheritance design is functional, documented as a known sub-optimal pattern, and unblocks the immediate goal (kill theDEBUG=truehack). The composition rewrite is a quality improvement that deserves its own focused review and its own coordinated deploy.Cross-references
refactor/settings-decoupled