ADR-003 — Configuration management
Status: Accepted
Context
The module must be configurable without code changes. The public interface accepts a dict as per §2 of the requirements. Internally, parameters must be validated and normalised robustly.
Parameter classification
Parameters are divided into three categories with distinct origins:
| Category | Parameters | Origin |
|---|---|---|
| Volatile / environment | endpoint_url, test_mode, test_prefix |
Environment variable (.env) |
| Secrets | (future credentials, e.g. API key, password) | Environment variable (.env) |
| Static / structural | all others | Dict passed by the caller or default values |
Volatile parameters and secrets must never be hardcoded in source code or committed files. They are read from environment variables, which in development are loaded from a .env file (not committed).
Options evaluated
| Option | Pros | Cons |
|---|---|---|
Plain dict |
Zero overhead | No validation, no type checking, silent errors |
pydantic.BaseModel |
Powerful validation, type coercion, clear errors | Unnecessary external dependency |
dataclasses stdlib + __post_init__ |
Explicit validation, zero dependencies, native type hints | Manual type coercion |
TypedDict |
Type hints without runtime overhead | No runtime validation |
Decision
We adopt a @dataclass GatewayConfig (stdlib) with validation in __post_init__. The public interface HanelWarehouseGateway(config: dict) converts the dict into a GatewayConfig at initialisation.
Volatile parameters and secrets are read from environment variables via python-dotenv, which automatically loads the .env file at the project root if present. python-dotenv is the only additional external dependency permitted alongside requests.
Parameters
@dataclass
class GatewayConfig:
endpoint_url: str
namespace_main: str = "http://main.jws.com.hanel.de"
namespace_xsd: str = "http://main.jws.com.hanel.de/xsd"
timeout_seconds: int = 30
retry_attempts: int = 3
retry_delay_seconds: float = 2.0
test_mode: bool = False
test_prefix: str = "TEST_"
log_level: str = "INFO"
log_soap_payloads: bool = False
validation_truncate: bool = False # see ADR-008
The validation_truncate parameter is an addition to the original requirements (see ADR-008).
Validation behaviour
__post_init__ verifies:
- endpoint_url is non-empty and starts with http:// or https://
- timeout_seconds > 0
- retry_attempts >= 1
- retry_delay_seconds >= 0
- log_level is one of DEBUG, INFO, WARNING, ERROR
Configuration errors raise ValueError with an explicit message (not HanelGatewayValidationError, which is reserved for business data validation before sending).
.env file
In development, volatile parameters and secrets are defined in a .env file at the project root:
# .env — DO NOT commit this file
HANEL_ENDPOINT_URL=http://192.168.1.100:8080/HanelService
HANEL_TEST_MODE=false
HANEL_TEST_PREFIX=TEST_
# Future secret example:
# HANEL_API_KEY=...
The .env file must not be committed. The repository includes a committed .env.example with placeholder values as a reference:
# .env.example — copy to .env and fill in the real values
HANEL_ENDPOINT_URL=http://<host>:<port>/HanelService
HANEL_TEST_MODE=false
HANEL_TEST_PREFIX=TEST_
.gitignore must contain the line .env.
Construction from environment variables + dict
GatewayConfig provides a factory method from_env() that:
1. Loads the .env file via python-dotenv (if present)
2. Reads environment variables with the HANEL_ prefix
3. Accepts an optional dict for static parameters (overrides or additional values)
4. Dict values take precedence over environment variables
@classmethod
def from_env(cls, overrides: dict | None = None) -> "GatewayConfig":
from dotenv import load_dotenv
load_dotenv() # no-op if .env does not exist
env_values = {
"endpoint_url": os.getenv("HANEL_ENDPOINT_URL"),
"test_mode": os.getenv("HANEL_TEST_MODE", "false").lower() == "true",
"test_prefix": os.getenv("HANEL_TEST_PREFIX", "TEST_"),
}
merged = {k: v for k, v in env_values.items() if v is not None}
if overrides:
merged.update(overrides)
return cls.from_dict(merged)
@classmethod
def from_dict(cls, d: dict) -> "GatewayConfig":
known_keys = {f.name for f in fields(cls)}
filtered = {k: v for k, v in d.items() if k in known_keys}
return cls(**filtered)
Unknown keys are silently ignored with a WARNING log, to allow forward compatibility if the caller passes additional parameters.
Typical usage
# Development: reads from .env
client = HanelWarehouseGateway(GatewayConfig.from_env())
# Production: environment variables injected by the orchestrator (Docker, k8s…)
client = HanelWarehouseGateway(GatewayConfig.from_env())
# Tests: explicit override without .env
client = HanelWarehouseGateway(GatewayConfig.from_env({
"endpoint_url": "http://mock-server/",
"test_mode": True,
}))
Consequences
endpoint_url,test_mode,test_prefixnever appear in committed files- Future secrets follow the same pattern without architectural changes
python-dotenvis the second (and last) permitted production external dependency- Adding a volatile parameter requires: adding the environment variable to
.env.example, updatingfrom_env(), updating this ADR andCLAUDE.md - Configuration errors surface at initialisation, not at first use
- Internal code always uses the typed
GatewayConfig, never a raw dict