Skip to main content

Python The 12-Factor App Practice Problems & Exercises

Practice: The 12-Factor App

11 problems3 Easy4 Medium4 Hard60–90 min
← Back to lesson

Easy

#1Factor III: Config AuditEasy
factor-3configenv-vars

Write a config_audit(source_code) function that detects Factor III violations (config hard-coded in source).

Python
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 environment
Hints

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.

#2Factor XI: Logs as Event StreamsEasy
factor-11loggingstdoutstructured-logging

Configure Python logging to follow Factor XI: write structured JSON logs to stdout only.

Python
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} enables latency_ms > 200 queries in log aggregators.
  • "latency: 450ms" requires fragile regex parsing.
  • Every field is queryable, filterable, and alertable.
Expected Output
See solution for JSON log output
Hints

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.

#3Factor VI: Stateless ProcessesEasy
factor-6statelessprocess-isolation

Demonstrate the difference between in-memory (non-compliant) and shared-store (compliant) session management.

Python
# 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 session
Hints

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

#4Factor IV: Backing Services as Attached ResourcesMedium
factor-4backing-servicesurl-config

Implement a backing service factory that creates the correct client from a URL, demonstrating Factor IV.

Python
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 changed
Hints

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.

#5Factor IX: Graceful ShutdownMedium
factor-9disposabilitygraceful-shutdown

Implement graceful shutdown that finishes in-flight work before exiting.

Python
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 done
Hints

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.

#6Factor X: Dev/Prod Parity CheckerMedium
factor-10dev-prod-parityenvironment-drift

Build a dev/prod parity checker that detects configuration drift between environments.

Python
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:

  1. Time gap: Deploy more frequently (continuous delivery) to keep dev and prod close.
  2. Personnel gap: Developers deploy to production (DevOps culture).
  3. 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

#7Factor VIII: Process ModelHard
factor-8process-modelconcurrency

Implement a process manager that starts web and background worker processes and shuts them down gracefully.

Python
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 stopped
Hints

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.

#8Factor II: Dependency Isolation AuditorHard
factor-2dependenciesreproducibility

Build a dependency auditor that checks requirements.txt for Factor II compliance.

Python
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: 2
Hints

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.

#9Factor V: Build, Release, Run StagesHard
factor-5build-release-runci-cd

Implement the build/release/run lifecycle model from Factor V.

Python
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.

#1012-Factor Health Check EndpointHard
health-check12-factormonitoringreadiness

Implement a three-tier health check system (liveness, readiness, startup) for a 12-factor app.

Python
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 function
Expected 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.

#11Full 12-Factor App ScaffoldHard
12-factorfull-appproduction-ready

Build a complete 12-factor compliant application that demonstrates all major factors in one coherent system.

Python
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:

FactorWhere
I: Codebaseapp_version from env (represents a git tag)
III: ConfigAll values from env dict / os.environ
VI: StatelessRequestHandler has no per-request state
VII: Port bindingConfig declares the port to bind
IX: Disposabilityapp.shutdown() drains and exits cleanly
XI: LogsJSON to stdout via JsonFormatter

The two factors hardest to fully demonstrate in code (require infrastructure):

  • Factor II (Dependencies): Enforced by requirements.txt pinning 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 -> shutdown
Expected Output
See solution for expected output
Hints

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.

© 2026 EngineersOfAI. All rights reserved.