Python The 12-Factor App Practice Problems & Exercises
Practice: The 12-Factor App
← Back to lessonEasy
Write a config_audit(source_code) function that detects Factor III violations (config hard-coded in source).
import re
VIOLATION_PATTERNS = [
(r'postgresql://\w+:\w+@', "Hard-coded database URL in source"),
(r'(sk-|SG\.|AKIA)[A-Za-z0-9]+', "Hard-coded API key in source"),
(r'"(localhost|127\.0\.0\.1|0\.0\.0\.0)"', "Hard-coded host in source"),
(r'=\s*"https?://[a-zA-Z0-9\.\-]+/api', "Hard-coded service URL in source"),
]
CLEAN_PATTERNS = [
(r'os\.environ', "reads from environment"),
(r'os\.getenv', "reads from environment"),
]
def config_audit(source_code: str) -> list[str]:
results = []
for pattern, message in VIOLATION_PATTERNS:
if re.search(pattern, source_code):
results.append(f"VIOLATION: {message}")
for pattern, message in CLEAN_PATTERNS:
if re.search(pattern, source_code):
results.append(f"CLEAN: {message}")
return results
# Test
bad_code = """
DB_URL = "postgresql://admin:pass@prod-db:5432/app"
API_KEY = "sk-your-key-here"
HOST = "localhost"
debug = os.environ.get("DEBUG", "false")
port = int(os.environ.get("PORT", "8000"))
"""
for finding in config_audit(bad_code):
print(finding)Solution
The solution is above. Factor III is the most commonly violated factor. The rule: if a value changes between dev/staging/prod, it must come from the environment.
The test: Can you open-source your codebase without exposing credentials? If yes, your config is factor-compliant.
Common violations: database connection strings with embedded credentials, API keys, hard-coded hostnames, environment-specific file paths.
Expected Output
VIOLATION: Hard-coded database URL in source\nVIOLATION: Hard-coded API key in source\nVIOLATION: Hard-coded host in source\nCLEAN: debug reads from environment\nCLEAN: port reads from environmentHints
Hint 1: Factor III says all config that varies between deployments must come from the environment. Any string literal that looks like a URL, API key, or host address is a violation.
Hint 2: Parse the code as a string and look for patterns: postgresql://, http://, sk-, hard-coded ports as literals.
Configure Python logging to follow Factor XI: write structured JSON logs to stdout only.
import logging
import json
import sys
from datetime import datetime
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
log_entry = {
"timestamp": datetime.utcnow().isoformat() + "Z",
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info:
log_entry["exception"] = self.formatException(record.exc_info)
extra = getattr(record, "extra_fields", {})
if extra:
log_entry.update(extra)
return json.dumps(log_entry)
def create_logger(name: str) -> logging.Logger:
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
logger.addHandler(handler)
logger.propagate = False
return logger
class StructuredAdapter(logging.LoggerAdapter):
def process(self, msg, kwargs):
kwargs.setdefault("extra", {})
kwargs["extra"]["extra_fields"] = self.extra
return msg, kwargs
log_base = create_logger("order-service")
log = StructuredAdapter(log_base, {})
log.info("Service started")
order_log = StructuredAdapter(log_base, {"order_id": 42, "user_id": 7})
order_log.warning("Payment slow")
order_log.error("Payment failed")Solution
The solution is above. Key principles:
Why stdout and not files?
- The app does not know where it is running — Docker, Kubernetes, Heroku all aggregate logs differently.
- Managing log files means managing rotation, cleanup, disk space — platform concerns, not app concerns.
Why structured JSON?
{"latency_ms": 450}enableslatency_ms > 200queries in log aggregators."latency: 450ms"requires fragile regex parsing.- Every field is queryable, filterable, and alertable.
Expected Output
See solution for JSON log outputHints
Hint 1: Factor XI: apps should not manage log files. Write logs to stdout and let the execution environment aggregate them.
Hint 2: Structured logging (JSON lines) makes logs machine-parseable for log aggregation platforms.
Demonstrate the difference between in-memory (non-compliant) and shared-store (compliant) session management.
# BAD: in-memory sessions (not factor VI compliant)
class InMemorySessionStore:
def __init__(self):
self._sessions: dict = {}
def set(self, sid: str, data: dict) -> None:
self._sessions[sid] = data
def get(self, sid: str) -> dict:
return self._sessions.get(sid, {})
worker1_store = InMemorySessionStore()
worker2_store = InMemorySessionStore()
worker1_store.set("sess_abc", {"user": "alice"})
w2_result = worker2_store.get("sess_abc")
print(f"Stateful: Worker 1 has session, Worker 2 sees {'nothing (broken!)' if not w2_result else w2_result}")
# GOOD: shared backing service (factor VI compliant)
class SharedStore:
_shared: dict = {} # class variable simulates external store
def set(self, sid: str, data: dict) -> None:
SharedStore._shared[sid] = data
def get(self, sid: str) -> dict:
return SharedStore._shared.get(sid, {})
store_a = SharedStore()
store_b = SharedStore()
store_a.set("sess_xyz", {"user": "bob"})
print(f"Stateless: Both workers see the same session: {store_b.get('sess_xyz')}")Solution
The solution is above. Statelessness enables horizontal scaling:
Why statelessness matters:
- Any worker can handle any request — no sticky sessions, no routing constraints.
- Workers can crash and restart without losing user data.
- You can scale from 1 worker to 100 by starting more processes.
What belongs in backing services: Sessions, uploads, caches, queue state — anything that must survive a process restart or be visible across workers.
Expected Output
Stateful: Worker 1 has session, Worker 2 sees nothing\nStateless: Both workers see the same sessionHints
Hint 1: Factor VI: processes are stateless and share-nothing. Any state that persists across requests must live in a backing service (Redis, DB).
Hint 2: Show the failure mode: user logs in on worker 1, session is invisible to worker 2 if stored in memory.
Medium
Implement a backing service factory that creates the correct client from a URL, demonstrating Factor IV.
from dataclasses import dataclass
import os
@dataclass
class SqliteDatabase:
url: str
def describe(self) -> str: return f"Connected to: {self.url} (local)"
@dataclass
class PostgresDatabase:
url: str
def describe(self) -> str: return f"Connected to: {self.url} (remote)"
@dataclass
class RedisCache:
url: str
def describe(self) -> str: return f"Connected to: {self.url} (cache)"
def create_backing_service(url: str):
scheme = url.split("://")[0].lower()
if scheme == "sqlite":
return SqliteDatabase(url)
elif scheme in ("postgresql", "postgres"):
return PostgresDatabase(url)
elif scheme == "redis":
return RedisCache(url)
else:
raise ValueError(f"Unknown scheme: {scheme}")
urls = ["sqlite:///local.db", "postgresql://prod-db/app", "redis://cache:6379/0"]
for svc in [create_backing_service(u) for u in urls]:
print(svc.describe())
local_db = create_backing_service(os.environ.get("DATABASE_URL", "sqlite:///local.db"))
cloud_db = create_backing_service("postgresql://prod-db/app")
print("Swapping DB: only the URL changed")Solution
The solution is above. The key insight: the app makes no distinction between a local database and a managed cloud service. Both are just URLs stored in config.
What counts as a backing service: databases, message queues, caches, SMTP servers, object storage, external APIs.
Expected Output
Connected to: sqlite:///local.db (local)\nConnected to: postgresql://prod-db/app (remote)\nConnected to: redis://cache:6379/0 (cache)\nSwapping DB: only the URL changedHints
Hint 1: Factor IV: treat all backing services (DB, cache, queue) as attached resources accessed via URL in config.
Hint 2: A URL-based factory function allows swapping local sqlite for PostgreSQL by changing one env var.
Implement graceful shutdown that finishes in-flight work before exiting.
import threading
import time
import signal
class GracefulShutdown:
def __init__(self):
self._stop = threading.Event()
self._workers: list[threading.Thread] = []
def trigger(self) -> None:
print("Shutdown signal received")
self._stop.set()
@property
def should_stop(self) -> bool:
return self._stop.is_set()
def wait(self) -> None:
for t in self._workers:
t.join(timeout=5.0)
print("All workers done")
def worker(wid: int, mgr: GracefulShutdown) -> None:
print(f"Worker {wid} started")
while not mgr.should_stop:
time.sleep(0.1)
print(f"Worker {wid} finishing")
mgr = GracefulShutdown()
for i in range(1, 3):
t = threading.Thread(target=worker, args=(i, mgr), daemon=True)
t.start()
mgr._workers.append(t)
time.sleep(0.15)
mgr.trigger()
mgr.wait()Solution
The solution is above. Graceful shutdown prevents data loss and enables zero-downtime deployments.
The deployment scenario: Platform sends SIGTERM to old process. Old process stops accepting new requests, finishes current requests, then exits. Platform starts new version. Zero request failures.
Fast startup: The other half of Factor IX. Optimize: defer non-critical initialization, use connection pools, pre-warm caches asynchronously.
Expected Output
Worker 1 started\nWorker 2 started\nShutdown signal received\nWorker 1 finishing\nWorker 2 finishing\nAll workers doneHints
Hint 1: Factor IX: maximize robustness with fast startup and graceful shutdown. Stop accepting new work, finish in-flight requests, release resources.
Hint 2: Use threading.Event to coordinate shutdown. Workers poll the event; on shutdown they complete current work then exit.
Build a dev/prod parity checker that detects configuration drift between environments.
from dataclasses import dataclass, fields
from typing import Optional
@dataclass
class EnvProfile:
name: str
database_type: Optional[str] = None
cache_type: Optional[str] = None
queue_type: Optional[str] = None
python_version: Optional[str] = None
def check_parity(dev: EnvProfile, prod: EnvProfile) -> None:
violations = []
ok = []
for f in fields(dev):
if f.name == "name":
continue
dv = getattr(dev, f.name)
pv = getattr(prod, f.name)
if dv == pv:
ok.append(f"{f.name} matches ({dv})")
else:
violations.append(f"{f.name}: dev={dv}, prod={pv}")
if violations:
print("Parity violations:")
for v in violations:
print(f" {v}")
for o in ok:
print(f"OK: {o}")
dev = EnvProfile("dev", database_type="sqlite", cache_type=None, queue_type="rabbitmq", python_version="3.11")
prod = EnvProfile("prod", database_type="postgresql", cache_type="redis", queue_type="rabbitmq", python_version="3.10")
check_parity(dev, prod)Solution
The solution is above. Factor X's three gaps:
- Time gap: Deploy more frequently (continuous delivery) to keep dev and prod close.
- Personnel gap: Developers deploy to production (DevOps culture).
- Tools gap: Use Docker to ensure identical service versions in all environments.
The Docker solution: Define the same docker-compose.yml for dev and prod. Use environment variables only for credentials and endpoint URLs — not for service types.
Expected Output
Parity violations:\n database_type: dev=sqlite, prod=postgresql\n cache_type: dev=None, prod=redis\n python_version: dev=3.11, prod=3.10\nOK: queue_type matches (rabbitmq)Hints
Hint 1: Factor X: keep dev, staging, and production as similar as possible. Compare two environment profiles and report differences.
Hint 2: A violation is: different service types, or a service present in prod but missing in dev.
Hard
Implement a process manager that starts web and background worker processes and shuts them down gracefully.
import multiprocessing
import time
import os
def web_process(wid: int, stop_event) -> None:
print(f"[web-{wid}] started", flush=True)
while not stop_event.is_set():
time.sleep(0.1)
print(f"[web-{wid}] stopped", flush=True)
def worker_process(wid: int, stop_event) -> None:
print(f"[worker-{wid}] started", flush=True)
count = 0
while not stop_event.is_set():
count += 1
time.sleep(0.15)
print(f"[worker-{wid}] processed {count} jobs", flush=True)
class ProcessManager:
def __init__(self):
self._procs: list[multiprocessing.Process] = []
self._stop = multiprocessing.Event()
def spawn(self, target, args: tuple) -> None:
p = multiprocessing.Process(target=target, args=args + (self._stop,), daemon=True)
p.start()
self._procs.append(p)
def shutdown(self) -> None:
self._stop.set()
for p in self._procs:
p.join(timeout=3.0)
print("[manager] All processes stopped", flush=True)
if __name__ == "__main__":
mgr = ProcessManager()
mgr.spawn(web_process, (1,))
mgr.spawn(worker_process, (1,))
time.sleep(0.3)
mgr.shutdown()Solution
The solution is above. Factor VIII defines the process model:
Procfile pattern:
web: uvicorn app:app --workers 4
worker: celery -A tasks worker --concurrency 8
beat: celery -A tasks beat
Each line is a process type. Scale them independently: heroku ps:scale web=4 worker=8.
Why separate process types? Web processes need fast request/response. Workers can be slower but need high throughput. Batch jobs run periodically. Each has different resource requirements and scaling needs.
Expected Output
[web-1] started\n[worker-1] started\n[manager] All processes stoppedHints
Hint 1: Factor VIII: scale out via the process model. Web processes handle HTTP; worker processes handle background jobs.
Hint 2: Use multiprocessing.Process. Poll is_alive() to detect crashes. Set a stop Event for graceful shutdown.
Build a dependency auditor that checks requirements.txt for Factor II compliance.
def audit_requirements(content: str) -> dict:
unpinned = []
pinned = []
for line in content.splitlines():
line = line.strip()
if not line or line.startswith("#") or line.startswith("-"):
continue
if "==" in line:
pinned.append(line)
else:
unpinned.append(line)
return {"unpinned": unpinned, "pinned": pinned, "issues": len(unpinned)}
req = """
flask
requests
sqlalchemy==2.0.15
pydantic==2.1.0
"""
result = audit_requirements(req)
print(f"Unpinned: {result['unpinned']}")
print(f"Pinned: {result['pinned']}")
print(f"Issues: {result['issues']}")Solution
The solution is above. The two-file pattern for dependency management:
requirements.in <- human-written logical deps (flask>=3.0)
requirements.txt <- pip-compile generated, fully pinned including transitive deps
pip-compile from pip-tools generates a fully reproducible lock file. Commit it to version control. Run it to upgrade packages deliberately, not accidentally.
Expected Output
Unpinned: ['requests', 'flask']\nPinned: ['sqlalchemy==2.0.15', 'pydantic==2.1.0']\nIssues: 2Hints
Hint 1: Factor II: explicitly declare and isolate dependencies. All deps must be pinned to exact versions for reproducibility.
Hint 2: Parse requirements.txt line by line. A pinned dep has "==" in it.
Implement the build/release/run lifecycle model from Factor V.
from dataclasses import dataclass, field
from typing import Optional
from datetime import datetime
import hashlib, json
@dataclass
class BuildArtifact:
version: str
dependencies_hash: str
built_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
@property
def build_id(self) -> str:
return f"b{self.version}"
@dataclass
class Release:
build: BuildArtifact
environment: str
_config: dict
_release_id: str = ""
_released_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
_frozen: bool = False
def __post_init__(self):
config_hash = hashlib.sha256(
json.dumps(self._config, sort_keys=True).encode()
).hexdigest()[:6]
self._release_id = f"r-{config_hash}"
self._frozen = True
@property
def release_id(self) -> str:
return self._release_id
def update_config(self, key: str, value: str) -> None:
if self._frozen:
raise RuntimeError("Cannot modify released config")
self._config[key] = value
class ReleaseManager:
def __init__(self):
self._releases: dict[str, Release] = {}
def create_release(self, build: BuildArtifact, env: str, config: dict) -> Release:
release = Release(build=build, environment=env, _config=dict(config))
self._releases[release.release_id] = release
print(f"Release {release.release_id} created (build={build.build_id}, env={env})")
return release
def get(self, release_id: str) -> Optional[Release]:
return self._releases.get(release_id)
# Test
build = BuildArtifact(version="1.0.0", dependencies_hash="sha256:abc")
print(f"Build {build.build_id} created")
mgr = ReleaseManager()
prod_release = mgr.create_release(build, "production", {"DATABASE_URL": "postgresql://prod/app"})
staging_release = mgr.create_release(build, "staging", {"DATABASE_URL": "postgresql://staging/app"})
try:
prod_release.update_config("DATABASE_URL", "changed")
except RuntimeError as e:
print(f"Cannot modify released config")Solution
The solution is above. Factor V's three stages:
Build stage: Takes source code and produces a build artifact (Docker image, wheel, compiled binary). The build artifact has no config — it is environment-agnostic.
Release stage: Combines a build artifact with deployment-specific config. Each release has a unique ID. A release is immutable: you cannot change the config after creating the release. To change config, create a new release.
Run stage: Executes a release in an execution environment. Processes can crash and restart — they always come back with the same release (same build + config).
Why immutable releases? You can roll back to a previous release instantly. You always know exactly what code and config is running. Releases are auditable and reproducible.
from dataclasses import dataclass, field
from typing import Optional
from datetime import datetime
import hashlib
# Factor V: strictly separate build, release, and run stages.
# Build: compile/package the code into a build artifact
# Release: combine build + config = release (immutable)
# Run: execute a release
# Implement:
# 1. BuildArtifact (code + version + dependencies hash)
# 2. Release (build + config = immutable, identified by hash)
# 3. ReleaseManager (track releases, prevent config mutation on released builds)Expected Output
Build b1.0.0 created\nRelease r-abc123 created (build=b1.0.0, env=production)\nCannot modify released config\nRelease r-def456 created (build=b1.0.0, env=staging)Hints
Hint 1: A Release is immutable: once created, its config cannot change. To deploy different config, create a new Release from the same Build.
Hint 2: Compute release ID as a hash of build_id + config dict. Freeze the config dict on Release creation.
Implement a three-tier health check system (liveness, readiness, startup) for a 12-factor app.
from typing import Callable
from dataclasses import dataclass, field
import time
@dataclass
class CheckResult:
name: str
ok: bool
message: str = ""
class HealthCheckSystem:
def __init__(self):
self._readiness_checks: dict[str, Callable[[], CheckResult]] = {}
self._startup_complete: bool = False
self._startup_at: float = 0.0
def register_readiness(self, name: str, checker: Callable[[], CheckResult]) -> None:
self._readiness_checks[name] = checker
def mark_startup_complete(self) -> None:
self._startup_complete = True
self._startup_at = time.time()
def check_liveness(self) -> dict:
return {"status": 200, "body": {"alive": True}}
def check_readiness(self) -> dict:
results = []
all_ok = True
for name, checker in self._readiness_checks.items():
try:
result = checker()
except Exception as e:
result = CheckResult(name=name, ok=False, message=str(e))
results.append(result)
if not result.ok:
all_ok = False
status = 200 if all_ok else 503
label = "OK" if all_ok else "Service Unavailable"
return {
"status": status,
"label": label,
"checks": {r.name: ("ok" if r.ok else f"FAIL - {r.message}") for r in results},
}
def check_startup(self) -> dict:
if not self._startup_complete:
return {"status": 503, "body": {"ready": False}}
elapsed = round(time.time() - self._startup_at, 2)
return {"status": 200, "body": {"ready": True, "startup_seconds": elapsed}}
def check_database() -> CheckResult:
return CheckResult("database", ok=True)
def check_cache() -> CheckResult:
return CheckResult("cache", ok=False, message="Connection refused")
def check_queue() -> CheckResult:
return CheckResult("queue", ok=True)
hc = HealthCheckSystem()
hc.register_readiness("database", check_database)
hc.register_readiness("cache", check_cache)
hc.register_readiness("queue", check_queue)
live = hc.check_liveness()
print(f"Liveness: {live['status']} OK")
ready = hc.check_readiness()
print(f"Readiness: {ready['status']} {ready['label']}")
for name, status in ready["checks"].items():
print(f" {name}: {status}")
time.sleep(0.1)
hc.mark_startup_complete()
startup = hc.check_startup()
print(f"Startup: {startup['status']} OK (initialized after {startup['body']['startup_seconds']}s)")Solution
The solution is above. Kubernetes uses all three:
Liveness probe: GET /health/live. If this fails, Kubernetes restarts the pod. It should be ultra-simple — just return 200 if the process is running.
Readiness probe: GET /health/ready. If this fails, Kubernetes removes the pod from the load balancer — traffic stops going to it. Use this to check backing services: database connection, Redis, external API.
Startup probe: GET /health/startup. Disables liveness and readiness probes until startup completes. Prevents Kubernetes from killing a pod that is still initializing.
Practical rule: Liveness should never check external dependencies — a slow database should not cause a container restart loop. Readiness should check what the app needs to serve requests.
from typing import Callable
from dataclasses import dataclass, field
# Implement a health check system with:
# - Liveness: is the process alive? (just returns 200)
# - Readiness: is the process ready to serve? (checks backing services)
# - Startup: has initialization completed? (used by Kubernetes)
# Each check registers as a named component with a checker functionExpected Output
Liveness: 200 OK\nReadiness: 503 Service Unavailable\n database: ok\n cache: FAIL - Connection refused\n queue: ok\nStartup: 200 OK (initialized after 0.1s)Hints
Hint 1: Liveness just returns 200 (the process is alive if it can handle HTTP). Readiness runs all registered checks and returns 503 if any fail.
Hint 2: Store check functions in a dict. Run them all on readiness check, collect failures, return 200 if all pass, 503 if any fail.
Build a complete 12-factor compliant application that demonstrates all major factors in one coherent system.
import os
import sys
import json
import logging
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
# ── FACTOR XI: Structured stdout logging ─────────────────────────────────────
class JsonFormatter(logging.Formatter):
def format(self, record):
return json.dumps({
"ts": datetime.utcnow().isoformat() + "Z",
"level": record.levelname,
"msg": record.getMessage(),
})
def make_logger(name: str) -> logging.Logger:
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
h = logging.StreamHandler(sys.stdout)
h.setFormatter(JsonFormatter())
logger.addHandler(h)
logger.propagate = False
return logger
# ── FACTOR III: Config from environment ──────────────────────────────────────
@dataclass
class AppConfig:
port: int
debug: bool
app_version: str
db_url: str
log_level: str
def load_config(env: dict = None) -> AppConfig:
src = env or os.environ
return AppConfig(
port=int(src.get("PORT", "8000")),
debug=src.get("DEBUG", "false").lower() == "true",
app_version=src.get("APP_VERSION", "unknown"),
db_url=src.get("DATABASE_URL", "sqlite:///dev.db"),
log_level=src.get("LOG_LEVEL", "INFO"),
)
# ── FACTOR VI: Stateless request handler ─────────────────────────────────────
class RequestHandler:
"""No instance state — each request is self-contained."""
def __init__(self, config: AppConfig, logger: logging.Logger):
self._config = config
self._logger = logger
def handle(self, request: dict) -> dict:
path = request.get("path", "/")
self._logger.info(f"GET {path}")
if path == "/health":
return {"status": 200, "body": {"ok": True, "version": self._config.app_version}}
elif path == "/config":
return {"status": 200, "body": {
"port": self._config.port,
"debug": self._config.debug,
"db": self._config.db_url.split("://")[0], # redact credentials
}}
return {"status": 404, "body": {"error": "not found"}}
# ── FACTOR IX: Graceful shutdown ──────────────────────────────────────────────
class App:
def __init__(self, config: AppConfig):
self._config = config
self._logger = make_logger("app")
self._handler = RequestHandler(config, self._logger)
self._stop = threading.Event()
self._request_count = 0
def start(self) -> None:
self._logger.info(f"Starting on port {self._config.port} (version={self._config.app_version})")
def handle(self, request: dict) -> dict:
self._request_count += 1
return self._handler.handle(request)
def shutdown(self) -> None:
self._logger.info(f"Shutting down (handled {self._request_count} requests)")
self._stop.set()
# ── MAIN ──────────────────────────────────────────────────────────────────────
env = {
"PORT": "8080",
"APP_VERSION": "1.2.3",
"DATABASE_URL": "postgresql://prod-db/app",
"DEBUG": "false",
"LOG_LEVEL": "INFO",
}
config = load_config(env)
app = App(config)
app.start()
responses = [
app.handle({"path": "/health"}),
app.handle({"path": "/config"}),
app.handle({"path": "/unknown"}),
]
for r in responses:
print(f"Response {r['status']}: {r['body']}")
app.shutdown()Solution
The solution ties all 12-factor principles together. Summary of the factors demonstrated:
| Factor | Where |
|---|---|
| I: Codebase | app_version from env (represents a git tag) |
| III: Config | All values from env dict / os.environ |
| VI: Stateless | RequestHandler has no per-request state |
| VII: Port binding | Config declares the port to bind |
| IX: Disposability | app.shutdown() drains and exits cleanly |
| XI: Logs | JSON to stdout via JsonFormatter |
The two factors hardest to fully demonstrate in code (require infrastructure):
- Factor II (Dependencies): Enforced by
requirements.txtpinning and virtualenv. - Factor VIII (Concurrency): Enforced by running multiple process instances with a process manager.
In a real deployment, Kubernetes, Heroku, or Docker Compose handle factors VIII, IX, and XI automatically — your app just needs to comply with the contract.
# Build a complete 12-factor compliant mini-application demonstrating:
# Factor I: tracked codebase (show version from git-like metadata)
# Factor III: all config from environment
# Factor VI: stateless process (no in-memory state between requests)
# Factor VII: exports via port binding
# Factor IX: graceful shutdown
# Factor XI: structured logs to stdout
# Show the full lifecycle: configure -> start -> handle requests -> shutdownExpected Output
See solution for expected outputHints
Hint 1: Combine the config reader, JSON logger, graceful shutdown, and health check systems from the earlier problems.
Hint 2: The app class wires everything together: load config, start logger, bind port, register shutdown hook.
