Skip to main content

Declaring a configuration section

Every component / plugin / service in an application must declare its own section of the configuration — a top-level key in YAML and a schema that matches that key.

Step 1. Pick a section name

Naming conventions:

RecommendationExample
snake_casedatabase, payment_provider, rate_limit
Singular noundatabase, not databases
No _config / _settings suffixdatabase, not database_config
Short but unique2–3 words joined by _

For plugins, the section name is normative: <kind>.<name> (ADR-0001 §4).

Step 2. Write the schema

app/database.py
from pydantic import BaseModel, Field, field_validator


class DatabaseConfig(BaseModel):
host: str
port: int = Field(5432, ge=1, le=65535)
name: str
user: str
password: str = Field(..., min_length=1)
pool_size: int = Field(20, ge=1, le=1000)
ssl: bool = False

@field_validator("host")
@classmethod
def must_not_be_wildcard(cls, v: str) -> str:
if v in ("0.0.0.0", "*"):
raise ValueError("host must be a concrete address")
return v

Schema requirements:

  • Every required field is marked explicitly (Field(...) without a default in pydantic; z.string() without .default() in zod; validate:"required" in a Go struct tag).
  • Default values are spelled out for every optional field.
  • Range validation (min / max / pattern) is declarative, so you do not have to enforce it at runtime.
  • Typed fields — avoid Any / unknown; every field has an explicit type.

Step 3. Read the section

The method name differs across bindings (get_section / getSection / GetSection):

from dagstack.config import Config

config = Config.load("app-config.yaml")
db_cfg = config.get_section("database", DatabaseConfig)
# db_cfg is a DatabaseConfig instance, already validated.

The method:

  1. Extracts the subtree at the given path.
  2. Runs schema validation.
  3. Returns a typed object.

On failure → ConfigError(validation_failed) carrying the field, the expected type, and the actual value (with secret fields masked).

Step 4. Isolation — read only your own section

The rule: a component reads only its own section. Reading another component's section is an anti-pattern.

# Right — inside the database service:
db_cfg = config.get_section("database", DatabaseConfig)

# Wrong — the database service reads someone else's section:
cache_cfg = config.get_section("cache", CacheConfig)
# The database service now depends on the structure of cache.

Why:

  • Coupling. If service A reads service B's config, a schema change in service B breaks service A. A hidden coupling without explicit boundaries.
  • Testability. Mocking only the database section is easier than mocking an entire config with every other component's section.
  • Security. In production, services may have different read permissions for different sections (through a governance middleware); hard-coded reads of someone else's section bypass that policy.

The right alternative: if two services need to share a setting (for example, a common max_payload_mb), put it in a separate shared section and read it from both:

shared:
max_payload_mb: 10

database:
host: "localhost"
pool_size: 20

api:
host: "0.0.0.0"
port: 8080

Step 5. Defaults for optional fields

All optional fields declare their defaults in the schema, not in code:

class DatabaseConfig(BaseModel):
host: str # required
user: str # required
password: str # required
port: int = 5432 # default in the schema
pool_size: int = 20 # default in the schema
ssl: bool = False # default in the schema

The anti-pattern is to enforce defaults in code after the read:

# WRONG — defaults applied in code:
cfg = config.get_section("database", DatabaseConfig)
port = cfg.port or 5432 # ← this kind of default makes the schema dishonest
pool = cfg.pool_size or 20

If a default is set in the schema, get_section / getSection / GetSection always returns a fully populated object. The code does not have to guess.

Step 6. Document the section

Every section should be documented:

  • A description for every field — comment / docstring in the schema.
  • A config example — an app-config.yaml snippet in the component README.
  • Notes on ranges — why pool_size is bounded between 1 and 1000 instead of being unbounded above.
class DatabaseConfig(BaseModel):
"""Configuration for application database connection.

Note: password is masked in diagnostic output automatically.
"""

host: str = Field(
...,
description="Database server host.",
examples=["localhost", "db.internal.example.com"],
)
port: int = Field(
5432,
description="TCP port of the database server.",
ge=1,
le=65535,
)
pool_size: int = Field(
20,
description="Connection pool size. Range: 1-1000. Typical dev: 5-10, prod: 50-200.",
ge=1,
le=1000,
)

pydantic.Field's description / examples flow into the JSON schema, which is then surfaced in IDE tooltips and auto-generated documentation.

See also