Skip to content

3: Full-Stack App with Multiple Languages

Overview

In this tutorial you will build a task management application as a monorepo containing three major components, each following the DevOps Engineering Style Guide conventions for its respective language.

What You Will Build
====================
Component          Language       Framework      Style Guide Section
---------------------------------------------------------------------------
Backend API        Python         FastAPI        02_language_guides/python
Frontend SPA       TypeScript     React          02_language_guides/typescript
Infrastructure     HCL            Terraform      02_language_guides/terraform
Containers         Dockerfile     Docker         02_language_guides/docker
CI/CD Pipeline     YAML           GitHub Actions 02_language_guides/github_actions
Build Orchestration Makefile      GNU Make       02_language_guides/makefile
Time Breakdown
==============
Step 1: Monorepo Structure ........  5 min
Step 2: Python Backend ............ 15 min
Step 3: TypeScript Frontend ....... 15 min
Step 4: Terraform Infrastructure .. 10 min
Step 5: Docker Configuration ......  5 min
Step 6: Monorepo CI/CD ............  8 min
Step 7: Cross-Language Validation ..  2 min
                                    -------
Total                               60 min

Prerequisites

# Verify all prerequisites before starting
python3 --version    # Python 3.10+
node --version       # Node.js 20+
npm --version        # npm 9+
terraform --version  # Terraform 1.5+
docker --version     # Docker 20.10+
git --version        # Git 2.30+
# Install uv (Python package manager)
curl -LsSf https://astral.sh/uv/install.sh | sh

# Install pre-commit
pip install pre-commit

# Verify installations
uv --version
pre-commit --version

Step 1: Monorepo Structure (5 min)

Create the directory layout

# Create project root
mkdir -p taskflow && cd taskflow
git init

# Create full directory structure
mkdir -p backend/src/models
mkdir -p backend/src/routes
mkdir -p backend/src/services
mkdir -p backend/tests
mkdir -p frontend/src/components
mkdir -p frontend/src/hooks
mkdir -p frontend/src/types
mkdir -p frontend/src/services
mkdir -p frontend/public
mkdir -p infrastructure/modules/ecs
mkdir -p infrastructure/modules/rds
mkdir -p infrastructure/modules/networking
mkdir -p infrastructure/environments/dev
mkdir -p infrastructure/environments/prod
mkdir -p shared/schemas
mkdir -p .github/workflows
mkdir -p scripts
# Verify structure
tree -L 3 --dirsfirst
taskflow/
├── .github/
│   └── workflows/
├── backend/
│   ├── src/
│   │   ├── models/
│   │   ├── routes/
│   │   └── services/
│   └── tests/
├── frontend/
│   ├── public/
│   └── src/
│       ├── components/
│       ├── hooks/
│       ├── services/
│       └── types/
├── infrastructure/
│   ├── environments/
│   │   ├── dev/
│   │   └── prod/
│   └── modules/
│       ├── ecs/
│       ├── networking/
│       └── rds/
├── scripts/
└── shared/
    └── schemas/

Root .editorconfig

# .editorconfig - Universal editor configuration
root = true

[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8

[*.py]
indent_style = space
indent_size = 4
max_line_length = 100

[*.{ts,tsx,js,jsx,json}]
indent_style = space
indent_size = 2

[*.{tf,tfvars}]
indent_style = space
indent_size = 2

[*.{yml,yaml}]
indent_style = space
indent_size = 2

[*.md]
trim_trailing_whitespace = false

[Makefile]
indent_style = tab

[Dockerfile]
indent_style = space
indent_size = 4

Root .pre-commit-config.yaml

# .pre-commit-config.yaml - Multi-language pre-commit hooks
repos:
  # -- General file checks --
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
        args: ["--unsafe"]
      - id: check-json
      - id: check-added-large-files
        args: ["--maxkb=1000"]
      - id: check-merge-conflict
      - id: detect-private-key
      - id: mixed-line-ending

  # -- Python hooks --
  - repo: https://github.com/psf/black
    rev: "25.1.0"
    hooks:
      - id: black
        args: ["--line-length=100"]
        files: ^backend/

  - repo: https://github.com/pycqa/flake8
    rev: "7.1.2"
    hooks:
      - id: flake8
        args: ["--max-line-length=100", "--extend-ignore=E203,W503"]
        files: ^backend/

  # -- TypeScript/JavaScript hooks --
  - repo: https://github.com/pre-commit/mirrors-eslint
    rev: v9.20.0
    hooks:
      - id: eslint
        files: ^frontend/.*\.[jt]sx?$
        additional_dependencies:
          - eslint@9.20.0
          - typescript@5.7.3
          - "@typescript-eslint/parser@8.24.0"
          - "@typescript-eslint/eslint-plugin@8.24.0"

  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v4.0.0-alpha.8
    hooks:
      - id: prettier
        files: ^frontend/
        types_or: [javascript, jsx, ts, tsx, json, css]

  # -- Terraform hooks --
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.97.4
    hooks:
      - id: terraform_fmt
        files: ^infrastructure/
      - id: terraform_validate
        files: ^infrastructure/
      - id: terraform_docs
        files: ^infrastructure/

  # -- Shell hooks --
  - repo: https://github.com/shellcheck-py/shellcheck-py
    rev: v0.10.0.1
    hooks:
      - id: shellcheck
        files: ^scripts/

  # -- Markdown hooks --
  - repo: https://github.com/igorshubovych/markdownlint-cli
    rev: v0.44.0
    hooks:
      - id: markdownlint
        args: ["--fix"]

  # -- YAML hooks --
  - repo: https://github.com/adrienverge/yamllint
    rev: v1.35.1
    hooks:
      - id: yamllint
        args: ["-d", "{extends: default, rules: {line-length: {max: 120}, document-start: disable}}"]

Root Makefile

# Makefile - Monorepo build orchestration
#
# @module taskflow_makefile
# @description Root Makefile for multi-language monorepo build and validation
# @version 1.0.0
# @author Tyler Dukes
# @last_updated 2025-01-15
# @status stable

.DEFAULT_GOAL := help
SHELL := /bin/bash

# ============================================================================
# Variables
# ============================================================================

BACKEND_DIR     := backend
FRONTEND_DIR    := frontend
INFRA_DIR       := infrastructure
DOCKER_COMPOSE  := docker compose

# ============================================================================
# Help
# ============================================================================

.PHONY: help
help: ## Show this help message
  @echo "TaskFlow Monorepo Commands"
  @echo "========================="
  @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
    awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-20s\033[0m %s\n", $$1, $$2}'

# ============================================================================
# Setup
# ============================================================================

.PHONY: setup
setup: setup-backend setup-frontend setup-hooks ## Set up all components

.PHONY: setup-backend
setup-backend: ## Set up Python backend
  cd $(BACKEND_DIR) && uv sync

.PHONY: setup-frontend
setup-frontend: ## Set up TypeScript frontend
  cd $(FRONTEND_DIR) && npm ci

.PHONY: setup-hooks
setup-hooks: ## Install pre-commit hooks
  pre-commit install --install-hooks

# ============================================================================
# Development
# ============================================================================

.PHONY: dev
dev: ## Start all services in development mode
  $(DOCKER_COMPOSE) up --build

.PHONY: dev-backend
dev-backend: ## Start backend only
  cd $(BACKEND_DIR) && uv run uvicorn src.main:app --reload --port 8000

.PHONY: dev-frontend
dev-frontend: ## Start frontend only
  cd $(FRONTEND_DIR) && npm run dev

# ============================================================================
# Testing
# ============================================================================

.PHONY: test
test: test-backend test-frontend ## Run all tests

.PHONY: test-backend
test-backend: ## Run Python backend tests
  cd $(BACKEND_DIR) && uv run pytest tests/ -v --cov=src --cov-report=term-missing

.PHONY: test-frontend
test-frontend: ## Run TypeScript frontend tests
  cd $(FRONTEND_DIR) && npm test -- --coverage

# ============================================================================
# Linting and Formatting
# ============================================================================

.PHONY: lint
lint: lint-backend lint-frontend lint-infra ## Lint all components

.PHONY: lint-backend
lint-backend: ## Lint Python backend
  cd $(BACKEND_DIR) && uv run black --check src/ tests/
  cd $(BACKEND_DIR) && uv run flake8 src/ tests/

.PHONY: lint-frontend
lint-frontend: ## Lint TypeScript frontend
  cd $(FRONTEND_DIR) && npm run lint
  cd $(FRONTEND_DIR) && npm run format:check

.PHONY: lint-infra
lint-infra: ## Lint Terraform infrastructure
  cd $(INFRA_DIR) && terraform fmt -check -recursive

.PHONY: format
format: format-backend format-frontend format-infra ## Format all components

.PHONY: format-backend
format-backend: ## Format Python backend
  cd $(BACKEND_DIR) && uv run black src/ tests/

.PHONY: format-frontend
format-frontend: ## Format TypeScript frontend
  cd $(FRONTEND_DIR) && npm run format

.PHONY: format-infra
format-infra: ## Format Terraform infrastructure
  cd $(INFRA_DIR) && terraform fmt -recursive

# ============================================================================
# Infrastructure
# ============================================================================

.PHONY: infra-init
infra-init: ## Initialize Terraform
  cd $(INFRA_DIR)/environments/dev && terraform init

.PHONY: infra-plan
infra-plan: ## Plan Terraform changes
  cd $(INFRA_DIR)/environments/dev && terraform plan -out=tfplan

.PHONY: infra-apply
infra-apply: ## Apply Terraform changes
  cd $(INFRA_DIR)/environments/dev && terraform apply tfplan

# ============================================================================
# Docker
# ============================================================================

.PHONY: docker-build
docker-build: ## Build all Docker images
  $(DOCKER_COMPOSE) build

.PHONY: docker-up
docker-up: ## Start all containers
  $(DOCKER_COMPOSE) up -d

.PHONY: docker-down
docker-down: ## Stop all containers
  $(DOCKER_COMPOSE) down

# ============================================================================
# Validation (CI)
# ============================================================================

.PHONY: validate
validate: lint test ## Run full validation suite
  @echo "All validations passed."

.PHONY: pre-commit
pre-commit: ## Run pre-commit on all files
  pre-commit run --all-files

# ============================================================================
# Clean
# ============================================================================

.PHONY: clean
clean: ## Remove build artifacts and caches
  rm -rf $(BACKEND_DIR)/.pytest_cache
  rm -rf $(BACKEND_DIR)/src/__pycache__
  rm -rf $(BACKEND_DIR)/.coverage
  rm -rf $(FRONTEND_DIR)/node_modules
  rm -rf $(FRONTEND_DIR)/build
  $(DOCKER_COMPOSE) down --rmi local --volumes --remove-orphans

Checkpoint: Monorepo structure

# Verify all root config files exist
ls -la .editorconfig .pre-commit-config.yaml Makefile

# Verify directory structure
test -d backend/src/models && echo "PASS: backend structure"
test -d frontend/src/components && echo "PASS: frontend structure"
test -d infrastructure/modules/ecs && echo "PASS: infrastructure structure"
test -d .github/workflows && echo "PASS: workflows directory"

# Initialize pre-commit
pre-commit install --install-hooks

Step 2: Python Backend (15 min)

backend/pyproject.toml

[project]
name = "taskflow-backend"
version = "1.0.0"
description = "TaskFlow API - FastAPI backend for task management"
requires-python = ">=3.10"
dependencies = [
    "fastapi>=0.115.0",
    "uvicorn[standard]>=0.34.0",
    "pydantic>=2.10.0",
    "sqlalchemy>=2.0.0",
    "asyncpg>=0.30.0",
    "alembic>=1.14.0",
    "python-dotenv>=1.0.0",
    "httpx>=0.28.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.3.0",
    "pytest-asyncio>=0.25.0",
    "pytest-cov>=6.0.0",
    "black>=25.1.0",
    "flake8>=7.1.0",
    "mypy>=1.14.0",
    "httpx>=0.28.0",
]

[tool.black]
line-length = 100
target-version = ["py311"]

[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
addopts = "-v --tb=short"

[tool.mypy]
python_version = "3.11"
strict = true
warn_return_any = true
warn_unused_configs = true

[tool.coverage.run]
source = ["src"]
omit = ["tests/*"]

[tool.coverage.report]
fail_under = 80
show_missing = true

backend/src/__init__.py

"""
@module taskflow_backend
@description FastAPI backend for TaskFlow task management application
@version 1.0.0
@author Tyler Dukes
@last_updated 2025-01-15
@status stable
"""

backend/src/models/task.py

"""
@module task_models
@description Pydantic models for task CRUD operations with full type safety
@version 1.0.0
@author Tyler Dukes
@last_updated 2025-01-15
@status stable
"""

from datetime import datetime
from enum import Enum
from typing import Optional
from uuid import UUID, uuid4

from pydantic import BaseModel, Field, field_validator


class TaskStatus(str, Enum):
    """Valid task status values."""

    TODO = "todo"
    IN_PROGRESS = "in_progress"
    DONE = "done"
    ARCHIVED = "archived"


class TaskPriority(str, Enum):
    """Valid task priority values."""

    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    CRITICAL = "critical"


class TaskBase(BaseModel):
    """Shared fields for task creation and updates."""

    title: str = Field(
        ...,
        min_length=1,
        max_length=200,
        description="Task title",
        examples=["Implement user authentication"],
    )
    description: Optional[str] = Field(
        None,
        max_length=2000,
        description="Detailed task description",
    )
    status: TaskStatus = Field(
        default=TaskStatus.TODO,
        description="Current task status",
    )
    priority: TaskPriority = Field(
        default=TaskPriority.MEDIUM,
        description="Task priority level",
    )
    assignee: Optional[str] = Field(
        None,
        max_length=100,
        description="Assigned team member",
    )

    @field_validator("title")
    @classmethod
    def title_must_not_be_blank(cls, v: str) -> str:
        """Validate that title is not whitespace-only."""
        if not v.strip():
            raise ValueError("Title must contain non-whitespace characters")
        return v.strip()


class TaskCreate(TaskBase):
    """Request model for creating a new task."""

    pass


class TaskUpdate(BaseModel):
    """Request model for partial task updates."""

    title: Optional[str] = Field(None, min_length=1, max_length=200)
    description: Optional[str] = Field(None, max_length=2000)
    status: Optional[TaskStatus] = None
    priority: Optional[TaskPriority] = None
    assignee: Optional[str] = Field(None, max_length=100)


class TaskResponse(TaskBase):
    """Response model returned to API clients."""

    id: UUID = Field(default_factory=uuid4, description="Unique task identifier")
    created_at: datetime = Field(
        default_factory=datetime.utcnow,
        description="Timestamp of creation",
    )
    updated_at: datetime = Field(
        default_factory=datetime.utcnow,
        description="Timestamp of last update",
    )

    model_config = {"from_attributes": True}


class TaskListResponse(BaseModel):
    """Paginated list response for tasks."""

    tasks: list[TaskResponse]
    total: int = Field(description="Total number of tasks matching query")
    page: int = Field(ge=1, description="Current page number")
    per_page: int = Field(ge=1, le=100, description="Items per page")

    @property
    def total_pages(self) -> int:
        """Calculate total number of pages."""
        return (self.total + self.per_page - 1) // self.per_page

backend/src/main.py

"""
@module taskflow_api
@description FastAPI application entry point with health check and CORS configuration
@version 1.0.0
@author Tyler Dukes
@last_updated 2025-01-15
@status stable
"""

from contextlib import asynccontextmanager
from datetime import datetime
from typing import AsyncGenerator

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from src.routes.tasks import router as tasks_router


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
    """Application lifespan handler for startup and shutdown events."""
    # -- Startup --
    print("TaskFlow API starting up...")
    yield
    # -- Shutdown --
    print("TaskFlow API shutting down...")


app = FastAPI(
    title="TaskFlow API",
    description="Task management API built with the DevOps Engineering Style Guide",
    version="1.0.0",
    lifespan=lifespan,
    docs_url="/docs",
    redoc_url="/redoc",
)

# -- CORS Configuration --
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:3000",
        "http://localhost:5173",
    ],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# -- Router Registration --
app.include_router(tasks_router, prefix="/api/v1")


@app.get("/health", tags=["health"])
async def health_check() -> dict[str, str]:
    """Health check endpoint for load balancer and container orchestration probes.

    Returns:
        dict: Health status with service name and timestamp.
    """
    return {
        "status": "healthy",
        "service": "taskflow-api",
        "timestamp": datetime.utcnow().isoformat(),
    }


@app.get("/ready", tags=["health"])
async def readiness_check() -> dict[str, str]:
    """Readiness check endpoint for Kubernetes and ECS readiness probes.

    Returns:
        dict: Readiness status.
    """
    # In production, check database connectivity here
    return {
        "status": "ready",
        "service": "taskflow-api",
    }

backend/src/routes/__init__.py

"""
@module taskflow_routes
@description API route modules for TaskFlow backend
@version 1.0.0
@author Tyler Dukes
@last_updated 2025-01-15
@status stable
"""

backend/src/routes/tasks.py

"""
@module task_routes
@description CRUD API routes for task management with proper error handling
@version 1.0.0
@author Tyler Dukes
@last_updated 2025-01-15
@status stable
"""

from datetime import datetime
from typing import Optional
from uuid import UUID, uuid4

from fastapi import APIRouter, HTTPException, Query

from src.models.task import (
    TaskCreate,
    TaskListResponse,
    TaskPriority,
    TaskResponse,
    TaskStatus,
    TaskUpdate,
)

router = APIRouter(prefix="/tasks", tags=["tasks"])

# -- In-memory store (replace with database in production) --
_task_store: dict[UUID, TaskResponse] = {}


@router.post(
    "/",
    response_model=TaskResponse,
    status_code=201,
    summary="Create a new task",
)
async def create_task(task: TaskCreate) -> TaskResponse:
    """Create a new task with the provided details.

    Args:
        task: Task creation payload with title, description, status, and priority.

    Returns:
        TaskResponse: The newly created task with generated ID and timestamps.
    """
    now = datetime.utcnow()
    task_response = TaskResponse(
        id=uuid4(),
        title=task.title,
        description=task.description,
        status=task.status,
        priority=task.priority,
        assignee=task.assignee,
        created_at=now,
        updated_at=now,
    )
    _task_store[task_response.id] = task_response
    return task_response


@router.get(
    "/",
    response_model=TaskListResponse,
    summary="List tasks with pagination and filtering",
)
async def list_tasks(
    page: int = Query(1, ge=1, description="Page number"),
    per_page: int = Query(20, ge=1, le=100, description="Items per page"),
    status: Optional[TaskStatus] = Query(None, description="Filter by status"),
    priority: Optional[TaskPriority] = Query(None, description="Filter by priority"),
    assignee: Optional[str] = Query(None, description="Filter by assignee"),
) -> TaskListResponse:
    """List all tasks with optional filtering and pagination.

    Args:
        page: Page number (1-indexed).
        per_page: Number of items per page (max 100).
        status: Optional status filter.
        priority: Optional priority filter.
        assignee: Optional assignee filter.

    Returns:
        TaskListResponse: Paginated list of tasks matching filters.
    """
    tasks = list(_task_store.values())

    # -- Apply filters --
    if status is not None:
        tasks = [t for t in tasks if t.status == status]
    if priority is not None:
        tasks = [t for t in tasks if t.priority == priority]
    if assignee is not None:
        tasks = [t for t in tasks if t.assignee == assignee]

    # -- Apply pagination --
    total = len(tasks)
    start = (page - 1) * per_page
    end = start + per_page
    paginated_tasks = tasks[start:end]

    return TaskListResponse(
        tasks=paginated_tasks,
        total=total,
        page=page,
        per_page=per_page,
    )


@router.get(
    "/{task_id}",
    response_model=TaskResponse,
    summary="Get a task by ID",
)
async def get_task(task_id: UUID) -> TaskResponse:
    """Retrieve a single task by its UUID.

    Args:
        task_id: The unique identifier of the task.

    Returns:
        TaskResponse: The requested task.

    Raises:
        HTTPException: 404 if task not found.
    """
    task = _task_store.get(task_id)
    if task is None:
        raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
    return task


@router.patch(
    "/{task_id}",
    response_model=TaskResponse,
    summary="Update a task",
)
async def update_task(task_id: UUID, task_update: TaskUpdate) -> TaskResponse:
    """Update an existing task with partial data.

    Args:
        task_id: The unique identifier of the task to update.
        task_update: Partial update payload (only provided fields are changed).

    Returns:
        TaskResponse: The updated task.

    Raises:
        HTTPException: 404 if task not found.
    """
    existing = _task_store.get(task_id)
    if existing is None:
        raise HTTPException(status_code=404, detail=f"Task {task_id} not found")

    update_data = task_update.model_dump(exclude_unset=True)
    updated_task = existing.model_copy(
        update={
            **update_data,
            "updated_at": datetime.utcnow(),
        }
    )
    _task_store[task_id] = updated_task
    return updated_task


@router.delete(
    "/{task_id}",
    status_code=204,
    summary="Delete a task",
)
async def delete_task(task_id: UUID) -> None:
    """Delete a task by its UUID.

    Args:
        task_id: The unique identifier of the task to delete.

    Raises:
        HTTPException: 404 if task not found.
    """
    if task_id not in _task_store:
        raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
    del _task_store[task_id]

backend/src/models/__init__.py

"""
@module taskflow_models
@description Data models for TaskFlow backend
@version 1.0.0
@author Tyler Dukes
@last_updated 2025-01-15
@status stable
"""

backend/src/services/__init__.py

"""
@module taskflow_services
@description Business logic services for TaskFlow backend
@version 1.0.0
@author Tyler Dukes
@last_updated 2025-01-15
@status stable
"""

backend/tests/conftest.py

"""
@module test_conftest
@description Shared test fixtures for TaskFlow backend tests
@version 1.0.0
@author Tyler Dukes
@last_updated 2025-01-15
@status stable
"""

import pytest
from fastapi.testclient import TestClient
from httpx import ASGITransport, AsyncClient

from src.main import app
from src.routes.tasks import _task_store


@pytest.fixture
def client() -> TestClient:
    """Provide a synchronous test client for the FastAPI application."""
    _task_store.clear()
    return TestClient(app)


@pytest.fixture
async def async_client() -> AsyncClient:
    """Provide an asynchronous test client for the FastAPI application."""
    _task_store.clear()
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as ac:
        yield ac


@pytest.fixture
def sample_task_payload() -> dict:
    """Provide a valid task creation payload for testing."""
    return {
        "title": "Implement user authentication",
        "description": "Add JWT-based auth with refresh tokens",
        "status": "todo",
        "priority": "high",
        "assignee": "tdukes",
    }


@pytest.fixture
def sample_tasks_batch() -> list[dict]:
    """Provide a batch of task creation payloads for pagination tests."""
    return [
        {
            "title": f"Task {i}",
            "description": f"Description for task {i}",
            "status": "todo",
            "priority": "medium",
        }
        for i in range(25)
    ]

backend/tests/test_health.py

"""
@module test_health
@description Tests for health and readiness endpoints
@version 1.0.0
@author Tyler Dukes
@last_updated 2025-01-15
@status stable
"""

from fastapi.testclient import TestClient


def test_health_check_returns_healthy(client: TestClient) -> None:
    """Health endpoint should return 200 with healthy status."""
    response = client.get("/health")
    assert response.status_code == 200

    data = response.json()
    assert data["status"] == "healthy"
    assert data["service"] == "taskflow-api"
    assert "timestamp" in data


def test_readiness_check_returns_ready(client: TestClient) -> None:
    """Readiness endpoint should return 200 with ready status."""
    response = client.get("/ready")
    assert response.status_code == 200

    data = response.json()
    assert data["status"] == "ready"
    assert data["service"] == "taskflow-api"

backend/tests/test_tasks.py

"""
@module test_tasks
@description Tests for task CRUD API routes
@version 1.0.0
@author Tyler Dukes
@last_updated 2025-01-15
@status stable
"""

from fastapi.testclient import TestClient


def test_create_task(client: TestClient, sample_task_payload: dict) -> None:
    """POST /api/v1/tasks should create a task and return 201."""
    response = client.post("/api/v1/tasks/", json=sample_task_payload)
    assert response.status_code == 201

    data = response.json()
    assert data["title"] == sample_task_payload["title"]
    assert data["status"] == "todo"
    assert data["priority"] == "high"
    assert "id" in data
    assert "created_at" in data


def test_create_task_validates_blank_title(client: TestClient) -> None:
    """POST /api/v1/tasks should reject blank titles with 422."""
    response = client.post("/api/v1/tasks/", json={"title": "   "})
    assert response.status_code == 422


def test_list_tasks_empty(client: TestClient) -> None:
    """GET /api/v1/tasks should return empty list when no tasks exist."""
    response = client.get("/api/v1/tasks/")
    assert response.status_code == 200

    data = response.json()
    assert data["tasks"] == []
    assert data["total"] == 0


def test_list_tasks_with_pagination(
    client: TestClient, sample_tasks_batch: list[dict]
) -> None:
    """GET /api/v1/tasks should paginate results correctly."""
    for payload in sample_tasks_batch:
        client.post("/api/v1/tasks/", json=payload)

    response = client.get("/api/v1/tasks/?page=1&per_page=10")
    assert response.status_code == 200

    data = response.json()
    assert len(data["tasks"]) == 10
    assert data["total"] == 25
    assert data["page"] == 1
    assert data["per_page"] == 10


def test_list_tasks_filter_by_status(
    client: TestClient, sample_task_payload: dict
) -> None:
    """GET /api/v1/tasks should filter by status parameter."""
    client.post("/api/v1/tasks/", json=sample_task_payload)
    client.post(
        "/api/v1/tasks/",
        json={**sample_task_payload, "title": "Done task", "status": "done"},
    )

    response = client.get("/api/v1/tasks/?status=done")
    data = response.json()
    assert data["total"] == 1
    assert data["tasks"][0]["status"] == "done"


def test_get_task_by_id(client: TestClient, sample_task_payload: dict) -> None:
    """GET /api/v1/tasks/{id} should return the specified task."""
    create_response = client.post("/api/v1/tasks/", json=sample_task_payload)
    task_id = create_response.json()["id"]

    response = client.get(f"/api/v1/tasks/{task_id}")
    assert response.status_code == 200
    assert response.json()["id"] == task_id


def test_get_task_not_found(client: TestClient) -> None:
    """GET /api/v1/tasks/{id} should return 404 for missing tasks."""
    response = client.get("/api/v1/tasks/00000000-0000-0000-0000-000000000000")
    assert response.status_code == 404


def test_update_task(client: TestClient, sample_task_payload: dict) -> None:
    """PATCH /api/v1/tasks/{id} should partially update a task."""
    create_response = client.post("/api/v1/tasks/", json=sample_task_payload)
    task_id = create_response.json()["id"]

    response = client.patch(
        f"/api/v1/tasks/{task_id}",
        json={"status": "in_progress", "priority": "critical"},
    )
    assert response.status_code == 200

    data = response.json()
    assert data["status"] == "in_progress"
    assert data["priority"] == "critical"
    assert data["title"] == sample_task_payload["title"]


def test_delete_task(client: TestClient, sample_task_payload: dict) -> None:
    """DELETE /api/v1/tasks/{id} should remove the task and return 204."""
    create_response = client.post("/api/v1/tasks/", json=sample_task_payload)
    task_id = create_response.json()["id"]

    delete_response = client.delete(f"/api/v1/tasks/{task_id}")
    assert delete_response.status_code == 204

    get_response = client.get(f"/api/v1/tasks/{task_id}")
    assert get_response.status_code == 404


def test_delete_task_not_found(client: TestClient) -> None:
    """DELETE /api/v1/tasks/{id} should return 404 for missing tasks."""
    response = client.delete("/api/v1/tasks/00000000-0000-0000-0000-000000000000")
    assert response.status_code == 404

Checkpoint: Backend validation

# Navigate to backend and install
cd backend
uv sync

# Run linters
uv run black --check src/ tests/
uv run flake8 src/ tests/

# Run type checker
uv run mypy src/

# Run tests
uv run pytest tests/ -v --cov=src --cov-report=term-missing

# Expected output:
# tests/test_health.py::test_health_check_returns_healthy PASSED
# tests/test_health.py::test_readiness_check_returns_ready PASSED
# tests/test_tasks.py::test_create_task PASSED
# tests/test_tasks.py::test_create_task_validates_blank_title PASSED
# tests/test_tasks.py::test_list_tasks_empty PASSED
# tests/test_tasks.py::test_list_tasks_with_pagination PASSED
# tests/test_tasks.py::test_list_tasks_filter_by_status PASSED
# tests/test_tasks.py::test_get_task_by_id PASSED
# tests/test_tasks.py::test_get_task_not_found PASSED
# tests/test_tasks.py::test_update_task PASSED
# tests/test_tasks.py::test_delete_task PASSED
# tests/test_tasks.py::test_delete_task_not_found PASSED
#
# ---------- coverage: 80%+ ----------

Step 3: TypeScript Frontend (15 min)

frontend/package.json

{
  "name": "taskflow-frontend",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview",
    "lint": "eslint 'src/**/*.{ts,tsx}'",
    "format": "prettier --write 'src/**/*.{ts,tsx,css,json}'",
    "format:check": "prettier --check 'src/**/*.{ts,tsx,css,json}'",
    "test": "vitest run",
    "test:watch": "vitest"
  },
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@testing-library/jest-dom": "^6.6.0",
    "@testing-library/react": "^16.2.0",
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "@typescript-eslint/eslint-plugin": "^8.24.0",
    "@typescript-eslint/parser": "^8.24.0",
    "@vitejs/plugin-react": "^4.3.0",
    "eslint": "^9.20.0",
    "eslint-config-prettier": "^10.0.0",
    "eslint-plugin-react-hooks": "^5.1.0",
    "jsdom": "^26.0.0",
    "prettier": "^3.5.0",
    "typescript": "^5.7.0",
    "vite": "^6.1.0",
    "vitest": "^3.0.0"
  }
}

frontend/tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

frontend/.prettierrc

{
  "semi": true,
  "trailingComma": "all",
  "singleQuote": true,
  "printWidth": 100,
  "tabWidth": 2,
  "arrowParens": "always",
  "endOfLine": "lf"
}

frontend/src/types/task.ts

/**
 * @module task_types
 * @description TypeScript type definitions for the TaskFlow API
 * @version 1.0.0
 * @author Tyler Dukes
 * @last_updated 2025-01-15
 * @status stable
 */

/** Valid task status values matching the backend enum. */
export type TaskStatus = 'todo' | 'in_progress' | 'done' | 'archived';

/** Valid task priority values matching the backend enum. */
export type TaskPriority = 'low' | 'medium' | 'high' | 'critical';

/** Task entity returned from the API. */
export interface Task {
  readonly id: string;
  title: string;
  description: string | null;
  status: TaskStatus;
  priority: TaskPriority;
  assignee: string | null;
  readonly created_at: string;
  readonly updated_at: string;
}

/** Payload for creating a new task. */
export interface CreateTaskPayload {
  title: string;
  description?: string;
  status?: TaskStatus;
  priority?: TaskPriority;
  assignee?: string;
}

/** Payload for updating an existing task (all fields optional). */
export interface UpdateTaskPayload {
  title?: string;
  description?: string;
  status?: TaskStatus;
  priority?: TaskPriority;
  assignee?: string;
}

/** Paginated task list response from the API. */
export interface TaskListResponse {
  tasks: Task[];
  total: number;
  page: number;
  per_page: number;
}

/** Query parameters for listing tasks. */
export interface TaskListParams {
  page?: number;
  per_page?: number;
  status?: TaskStatus;
  priority?: TaskPriority;
  assignee?: string;
}

/** Map of priority values to display labels. */
export const PRIORITY_LABELS: Record<TaskPriority, string> = {
  low: 'Low',
  medium: 'Medium',
  high: 'High',
  critical: 'Critical',
};

/** Map of status values to display labels. */
export const STATUS_LABELS: Record<TaskStatus, string> = {
  todo: 'To Do',
  in_progress: 'In Progress',
  done: 'Done',
  archived: 'Archived',
};

frontend/src/services/api.ts

/**
 * @module api_client
 * @description Typed HTTP client for TaskFlow API with error handling
 * @version 1.0.0
 * @author Tyler Dukes
 * @last_updated 2025-01-15
 * @status stable
 */

import type {
  CreateTaskPayload,
  Task,
  TaskListParams,
  TaskListResponse,
  UpdateTaskPayload,
} from '../types/task';

const API_BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000/api/v1';

/** Custom error class for API responses. */
export class ApiError extends Error {
  constructor(
    message: string,
    public readonly status: number,
    public readonly detail?: string,
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

/**
 * Generic fetch wrapper with typed response and error handling.
 *
 * @param path - API path relative to base URL
 * @param options - Fetch options
 * @returns Parsed JSON response
 * @throws ApiError on non-2xx responses
 */
async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T> {
  const url = `${API_BASE_URL}${path}`;

  const response = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...options.headers,
    },
  });

  if (!response.ok) {
    const errorBody = await response.json().catch(() => null);
    throw new ApiError(
      `API request failed: ${response.status}`,
      response.status,
      errorBody?.detail ?? response.statusText,
    );
  }

  // Handle 204 No Content
  if (response.status === 204) {
    return undefined as T;
  }

  return response.json() as Promise<T>;
}

/** TaskFlow API client with typed methods for all CRUD operations. */
export const taskApi = {
  /**
   * List tasks with optional filtering and pagination.
   *
   * @param params - Query parameters for filtering and pagination
   * @returns Paginated list of tasks
   */
  async list(params: TaskListParams = {}): Promise<TaskListResponse> {
    const searchParams = new URLSearchParams();

    if (params.page !== undefined) searchParams.set('page', String(params.page));
    if (params.per_page !== undefined) searchParams.set('per_page', String(params.per_page));
    if (params.status !== undefined) searchParams.set('status', params.status);
    if (params.priority !== undefined) searchParams.set('priority', params.priority);
    if (params.assignee !== undefined) searchParams.set('assignee', params.assignee);

    const query = searchParams.toString();
    return fetchApi<TaskListResponse>(`/tasks/${query ? `?${query}` : ''}`);
  },

  /**
   * Get a single task by ID.
   *
   * @param id - Task UUID
   * @returns The requested task
   */
  async get(id: string): Promise<Task> {
    return fetchApi<Task>(`/tasks/${id}`);
  },

  /**
   * Create a new task.
   *
   * @param payload - Task creation data
   * @returns The newly created task
   */
  async create(payload: CreateTaskPayload): Promise<Task> {
    return fetchApi<Task>('/tasks/', {
      method: 'POST',
      body: JSON.stringify(payload),
    });
  },

  /**
   * Update an existing task.
   *
   * @param id - Task UUID
   * @param payload - Partial update data
   * @returns The updated task
   */
  async update(id: string, payload: UpdateTaskPayload): Promise<Task> {
    return fetchApi<Task>(`/tasks/${id}`, {
      method: 'PATCH',
      body: JSON.stringify(payload),
    });
  },

  /**
   * Delete a task by ID.
   *
   * @param id - Task UUID
   */
  async delete(id: string): Promise<void> {
    await fetchApi<void>(`/tasks/${id}`, { method: 'DELETE' });
  },
};

frontend/src/hooks/useTasks.ts

/**
 * @module use_tasks_hook
 * @description React hook for task CRUD operations with loading and error state
 * @version 1.0.0
 * @author Tyler Dukes
 * @last_updated 2025-01-15
 * @status stable
 */

import { useCallback, useEffect, useState } from 'react';

import { ApiError, taskApi } from '../services/api';
import type { CreateTaskPayload, Task, TaskListParams, UpdateTaskPayload } from '../types/task';

interface UseTasksState {
  tasks: Task[];
  total: number;
  loading: boolean;
  error: string | null;
}

interface UseTasksReturn extends UseTasksState {
  refresh: () => Promise<void>;
  createTask: (payload: CreateTaskPayload) => Promise<Task | null>;
  updateTask: (id: string, payload: UpdateTaskPayload) => Promise<Task | null>;
  deleteTask: (id: string) => Promise<boolean>;
}

/**
 * Custom hook for managing task state and API operations.
 *
 * @param params - Optional query parameters for task list
 * @returns Task state and CRUD operation functions
 */
export function useTasks(params: TaskListParams = {}): UseTasksReturn {
  const [state, setState] = useState<UseTasksState>({
    tasks: [],
    total: 0,
    loading: true,
    error: null,
  });

  const refresh = useCallback(async () => {
    setState((prev) => ({ ...prev, loading: true, error: null }));

    try {
      const response = await taskApi.list(params);
      setState({
        tasks: response.tasks,
        total: response.total,
        loading: false,
        error: null,
      });
    } catch (err) {
      const message = err instanceof ApiError ? err.detail ?? err.message : 'Failed to load tasks';
      setState((prev) => ({ ...prev, loading: false, error: message }));
    }
  }, [params.page, params.per_page, params.status, params.priority, params.assignee]);

  useEffect(() => {
    void refresh();
  }, [refresh]);

  const createTask = useCallback(
    async (payload: CreateTaskPayload): Promise<Task | null> => {
      try {
        const task = await taskApi.create(payload);
        await refresh();
        return task;
      } catch (err) {
        const message =
          err instanceof ApiError ? err.detail ?? err.message : 'Failed to create task';
        setState((prev) => ({ ...prev, error: message }));
        return null;
      }
    },
    [refresh],
  );

  const updateTask = useCallback(
    async (id: string, payload: UpdateTaskPayload): Promise<Task | null> => {
      try {
        const task = await taskApi.update(id, payload);
        await refresh();
        return task;
      } catch (err) {
        const message =
          err instanceof ApiError ? err.detail ?? err.message : 'Failed to update task';
        setState((prev) => ({ ...prev, error: message }));
        return null;
      }
    },
    [refresh],
  );

  const deleteTask = useCallback(
    async (id: string): Promise<boolean> => {
      try {
        await taskApi.delete(id);
        await refresh();
        return true;
      } catch (err) {
        const message =
          err instanceof ApiError ? err.detail ?? err.message : 'Failed to delete task';
        setState((prev) => ({ ...prev, error: message }));
        return false;
      }
    },
    [refresh],
  );

  return {
    ...state,
    refresh,
    createTask,
    updateTask,
    deleteTask,
  };
}

frontend/src/components/TaskList.tsx

/**
 * @module task_list_component
 * @description Task list component with filtering and status management
 * @version 1.0.0
 * @author Tyler Dukes
 * @last_updated 2025-01-15
 * @status stable
 */

import type { Task, TaskStatus } from '../types/task';
import { PRIORITY_LABELS, STATUS_LABELS } from '../types/task';

interface TaskListProps {
  readonly tasks: Task[];
  readonly onStatusChange: (id: string, status: TaskStatus) => void;
  readonly onDelete: (id: string) => void;
}

/** Priority badge color mapping for visual distinction. */
const PRIORITY_COLORS: Record<string, string> = {
  low: '#4caf50',
  medium: '#ff9800',
  high: '#f44336',
  critical: '#9c27b0',
};

export function TaskList({ tasks, onStatusChange, onDelete }: TaskListProps): JSX.Element {
  if (tasks.length === 0) {
    return (
      <div className="task-list-empty">
        <p>No tasks found. Create one to get started.</p>
      </div>
    );
  }

  return (
    <div className="task-list">
      {tasks.map((task) => (
        <div key={task.id} className="task-card" data-priority={task.priority}>
          <div className="task-header">
            <h3 className="task-title">{task.title}</h3>
            <span
              className="priority-badge"
              style={{ backgroundColor: PRIORITY_COLORS[task.priority] }}
            >
              {PRIORITY_LABELS[task.priority]}
            </span>
          </div>

          {task.description && <p className="task-description">{task.description}</p>}

          <div className="task-meta">
            {task.assignee && <span className="task-assignee">@{task.assignee}</span>}
            <span className="task-date">
              {new Date(task.created_at).toLocaleDateString()}
            </span>
          </div>

          <div className="task-actions">
            <select
              value={task.status}
              onChange={(e) => onStatusChange(task.id, e.target.value as TaskStatus)}
              className="status-select"
            >
              {Object.entries(STATUS_LABELS).map(([value, label]) => (
                <option key={value} value={value}>
                  {label}
                </option>
              ))}
            </select>

            <button
              type="button"
              onClick={() => onDelete(task.id)}
              className="delete-button"
              aria-label={`Delete task: ${task.title}`}
            >
              Delete
            </button>
          </div>
        </div>
      ))}
    </div>
  );
}

frontend/src/components/TaskForm.tsx

/**
 * @module task_form_component
 * @description Form component for creating new tasks with validation
 * @version 1.0.0
 * @author Tyler Dukes
 * @last_updated 2025-01-15
 * @status stable
 */

import { useState } from 'react';

import type { CreateTaskPayload, TaskPriority } from '../types/task';
import { PRIORITY_LABELS } from '../types/task';

interface TaskFormProps {
  readonly onSubmit: (payload: CreateTaskPayload) => Promise<void>;
  readonly disabled?: boolean;
}

export function TaskForm({ onSubmit, disabled = false }: TaskFormProps): JSX.Element {
  const [title, setTitle] = useState('');
  const [description, setDescription] = useState('');
  const [priority, setPriority] = useState<TaskPriority>('medium');
  const [submitting, setSubmitting] = useState(false);

  const handleSubmit = async (e: React.FormEvent): Promise<void> => {
    e.preventDefault();

    if (!title.trim()) return;

    setSubmitting(true);
    try {
      await onSubmit({
        title: title.trim(),
        description: description.trim() || undefined,
        priority,
      });
      // Reset form on success
      setTitle('');
      setDescription('');
      setPriority('medium');
    } finally {
      setSubmitting(false);
    }
  };

  const isDisabled = disabled || submitting;

  return (
    <form onSubmit={handleSubmit} className="task-form">
      <div className="form-group">
        <label htmlFor="task-title">Title *</label>
        <input
          id="task-title"
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="Enter task title"
          maxLength={200}
          required
          disabled={isDisabled}
        />
      </div>

      <div className="form-group">
        <label htmlFor="task-description">Description</label>
        <textarea
          id="task-description"
          value={description}
          onChange={(e) => setDescription(e.target.value)}
          placeholder="Optional description"
          maxLength={2000}
          rows={3}
          disabled={isDisabled}
        />
      </div>

      <div className="form-group">
        <label htmlFor="task-priority">Priority</label>
        <select
          id="task-priority"
          value={priority}
          onChange={(e) => setPriority(e.target.value as TaskPriority)}
          disabled={isDisabled}
        >
          {Object.entries(PRIORITY_LABELS).map(([value, label]) => (
            <option key={value} value={value}>
              {label}
            </option>
          ))}
        </select>
      </div>

      <button type="submit" disabled={isDisabled || !title.trim()} className="submit-button">
        {submitting ? 'Creating...' : 'Create Task'}
      </button>
    </form>
  );
}

frontend/src/App.tsx

/**
 * @module taskflow_app
 * @description Root application component for TaskFlow frontend
 * @version 1.0.0
 * @author Tyler Dukes
 * @last_updated 2025-01-15
 * @status stable
 */

import { useState } from 'react';

import { TaskForm } from './components/TaskForm';
import { TaskList } from './components/TaskList';
import { useTasks } from './hooks/useTasks';
import type { CreateTaskPayload, TaskStatus } from './types/task';

export default function App(): JSX.Element {
  const [statusFilter, setStatusFilter] = useState<TaskStatus | undefined>(undefined);

  const { tasks, total, loading, error, createTask, updateTask, deleteTask } = useTasks({
    status: statusFilter,
    per_page: 50,
  });

  const handleCreate = async (payload: CreateTaskPayload): Promise<void> => {
    await createTask(payload);
  };

  const handleStatusChange = async (id: string, status: TaskStatus): Promise<void> => {
    await updateTask(id, { status });
  };

  const handleDelete = async (id: string): Promise<void> => {
    await deleteTask(id);
  };

  return (
    <div className="app">
      <header className="app-header">
        <h1>TaskFlow</h1>
        <p className="app-subtitle">Task Management - DevOps Engineering Style Guide Demo</p>
      </header>

      <main className="app-main">
        <section className="create-section">
          <h2>New Task</h2>
          <TaskForm onSubmit={handleCreate} disabled={loading} />
        </section>

        <section className="list-section">
          <div className="list-header">
            <h2>Tasks ({total})</h2>
            <select
              value={statusFilter ?? ''}
              onChange={(e) =>
                setStatusFilter((e.target.value || undefined) as TaskStatus | undefined)
              }
              className="filter-select"
            >
              <option value="">All Statuses</option>
              <option value="todo">To Do</option>
              <option value="in_progress">In Progress</option>
              <option value="done">Done</option>
              <option value="archived">Archived</option>
            </select>
          </div>

          {error && <div className="error-banner" role="alert">{error}</div>}
          {loading && <div className="loading-spinner">Loading...</div>}

          {!loading && (
            <TaskList
              tasks={tasks}
              onStatusChange={handleStatusChange}
              onDelete={handleDelete}
            />
          )}
        </section>
      </main>
    </div>
  );
}

frontend/src/main.tsx

/**
 * @module taskflow_entry
 * @description Application entry point that mounts React to the DOM
 * @version 1.0.0
 * @author Tyler Dukes
 * @last_updated 2025-01-15
 * @status stable
 */

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';

import App from './App';

const rootElement = document.getElementById('root');

if (!rootElement) {
  throw new Error('Root element not found. Ensure index.html contains <div id="root"></div>.');
}

createRoot(rootElement).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

Checkpoint: Frontend validation

# Navigate to frontend and install
cd frontend
npm ci

# Run type checking
npx tsc --noEmit

# Run linter
npm run lint

# Run format check
npm run format:check

# Run tests
npm test

# Build production bundle
npm run build

# Expected: All commands exit 0

Step 4: Terraform Infrastructure (10 min)

infrastructure/modules/ecs/CONTRACT.md

# Module Contract: taskflow-ecs

> **Version**: 1.0.0
> **Last Updated**: 2025-01-15
> **Maintained by**: Platform Team
> **Status**: Active

## 1. Purpose

Provisions an ECS Fargate service with an Application Load Balancer for
running the TaskFlow application containers. Handles task definitions,
service discovery, and health check configuration.

## 2. Guarantees

### Resource Guarantees

- **G1**: Creates exactly 1 ECS cluster with Container Insights enabled
- **G2**: Creates 1 Fargate service with configurable desired count (default: 2)
- **G3**: Creates 1 ALB with HTTPS listener and health check
- **G4**: All containers run on Fargate (no EC2 instances)
- **G5**: Task definition includes CloudWatch log group with 30-day retention

### Security Guarantees

- **G6**: IAM task role follows least-privilege (only specified permissions)
- **G7**: Security groups restrict inbound to ALB port only
- **G8**: Container runs as non-root user

### Operational Guarantees

- **G9**: Health check path is configurable (default: /health)
- **G10**: Rolling deployment with minimum 50% healthy tasks

## 3. Test Mapping

| Guarantee | Test File                    | Test Function              |
|-----------|------------------------------|----------------------------|
| G1        | tests/ecs_cluster_test.go    | TestEcsClusterCreation     |
| G2        | tests/ecs_service_test.go    | TestFargateServiceCount    |
| G3        | tests/alb_test.go            | TestAlbHealthCheck         |
| G6        | tests/iam_test.go            | TestTaskRoleLeastPrivilege |
| G7        | tests/security_test.go       | TestSecurityGroupRules     |

infrastructure/modules/ecs/variables.tf

# @module taskflow_ecs_variables
# @description Input variables for the TaskFlow ECS Fargate module
# @version 1.0.0
# @author Tyler Dukes
# @last_updated 2025-01-15
# @status stable

variable "project_name" {
  description = "Project name used for resource naming and tagging"
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{2,28}[a-z0-9]$", var.project_name))
    error_message = "Project name must be 4-30 lowercase alphanumeric characters or hyphens."
  }
}

variable "environment" {
  description = "Deployment environment (dev, staging, prod)"
  type        = string

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be one of: dev, staging, prod."
  }
}

variable "vpc_id" {
  description = "VPC ID where ECS resources will be deployed"
  type        = string

  validation {
    condition     = can(regex("^vpc-[a-f0-9]{8,17}$", var.vpc_id))
    error_message = "VPC ID must be a valid AWS VPC identifier."
  }
}

variable "private_subnet_ids" {
  description = "List of private subnet IDs for Fargate tasks"
  type        = list(string)

  validation {
    condition     = length(var.private_subnet_ids) >= 2
    error_message = "At least 2 private subnets required for high availability."
  }
}

variable "public_subnet_ids" {
  description = "List of public subnet IDs for the ALB"
  type        = list(string)

  validation {
    condition     = length(var.public_subnet_ids) >= 2
    error_message = "At least 2 public subnets required for ALB."
  }
}

variable "container_image" {
  description = "Docker image URI for the backend container"
  type        = string
}

variable "container_port" {
  description = "Port the container listens on"
  type        = number
  default     = 8000

  validation {
    condition     = var.container_port > 0 && var.container_port <= 65535
    error_message = "Container port must be between 1 and 65535."
  }
}

variable "desired_count" {
  description = "Desired number of Fargate tasks (G2)"
  type        = number
  default     = 2

  validation {
    condition     = var.desired_count >= 1 && var.desired_count <= 10
    error_message = "Desired count must be between 1 and 10."
  }
}

variable "cpu" {
  description = "CPU units for the Fargate task (256, 512, 1024, 2048, 4096)"
  type        = number
  default     = 256

  validation {
    condition     = contains([256, 512, 1024, 2048, 4096], var.cpu)
    error_message = "CPU must be one of: 256, 512, 1024, 2048, 4096."
  }
}

variable "memory" {
  description = "Memory in MiB for the Fargate task"
  type        = number
  default     = 512

  validation {
    condition     = var.memory >= 512 && var.memory <= 30720
    error_message = "Memory must be between 512 and 30720 MiB."
  }
}

variable "health_check_path" {
  description = "Health check endpoint path (G9)"
  type        = string
  default     = "/health"
}

variable "tags" {
  description = "Common tags applied to all resources"
  type        = map(string)
  default     = {}
}

infrastructure/modules/ecs/main.tf

# @module taskflow_ecs
# @description ECS Fargate service with ALB for TaskFlow application
# @version 1.0.0
# @author Tyler Dukes
# @last_updated 2025-01-15
# @status stable

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

locals {
  name_prefix = "${var.project_name}-${var.environment}"

  common_tags = merge(var.tags, {
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "terraform"
    Module      = "taskflow-ecs"
  })
}

# ============================================================================
# ECS Cluster (G1)
# ============================================================================

resource "aws_ecs_cluster" "main" {
  name = "${local.name_prefix}-cluster"

  setting {
    name  = "containerInsights"
    value = "enabled"
  }

  tags = local.common_tags
}

# ============================================================================
# CloudWatch Log Group (G5)
# ============================================================================

resource "aws_cloudwatch_log_group" "ecs" {
  name              = "/ecs/${local.name_prefix}"
  retention_in_days = 30

  tags = local.common_tags
}

# ============================================================================
# IAM Roles (G6)
# ============================================================================

data "aws_iam_policy_document" "ecs_assume_role" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "task_execution" {
  name               = "${local.name_prefix}-task-execution"
  assume_role_policy = data.aws_iam_policy_document.ecs_assume_role.json

  tags = local.common_tags
}

resource "aws_iam_role_policy_attachment" "task_execution" {
  role       = aws_iam_role.task_execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

resource "aws_iam_role" "task_role" {
  name               = "${local.name_prefix}-task-role"
  assume_role_policy = data.aws_iam_policy_document.ecs_assume_role.json

  tags = local.common_tags
}

# ============================================================================
# Security Groups (G7)
# ============================================================================

resource "aws_security_group" "alb" {
  name_prefix = "${local.name_prefix}-alb-"
  vpc_id      = var.vpc_id
  description = "Security group for TaskFlow ALB"

  ingress {
    description = "HTTPS from internet"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "HTTP redirect"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    description = "All outbound"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-alb-sg"
  })

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_security_group" "ecs_tasks" {
  name_prefix = "${local.name_prefix}-ecs-"
  vpc_id      = var.vpc_id
  description = "Security group for TaskFlow ECS tasks"

  ingress {
    description     = "Allow traffic from ALB only"
    from_port       = var.container_port
    to_port         = var.container_port
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }

  egress {
    description = "All outbound"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-ecs-sg"
  })

  lifecycle {
    create_before_destroy = true
  }
}

# ============================================================================
# Application Load Balancer (G3)
# ============================================================================

resource "aws_lb" "main" {
  name               = "${local.name_prefix}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = var.public_subnet_ids

  tags = local.common_tags
}

resource "aws_lb_target_group" "main" {
  name        = "${local.name_prefix}-tg"
  port        = var.container_port
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"

  health_check {
    enabled             = true
    healthy_threshold   = 3
    unhealthy_threshold = 3
    timeout             = 5
    interval            = 30
    path                = var.health_check_path
    port                = "traffic-port"
    matcher             = "200"
  }

  tags = local.common_tags
}

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.main.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "redirect"

    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }

  tags = local.common_tags
}

# ============================================================================
# ECS Task Definition (G4, G5, G8)
# ============================================================================

resource "aws_ecs_task_definition" "main" {
  family                   = "${local.name_prefix}-task"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = var.cpu
  memory                   = var.memory
  execution_role_arn       = aws_iam_role.task_execution.arn
  task_role_arn            = aws_iam_role.task_role.arn

  container_definitions = jsonencode([
    {
      name      = "taskflow-api"
      image     = var.container_image
      essential = true

      portMappings = [
        {
          containerPort = var.container_port
          protocol      = "tcp"
        }
      ]

      # G8: Run as non-root
      user = "1000:1000"

      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = aws_cloudwatch_log_group.ecs.name
          "awslogs-region"        = data.aws_region.current.name
          "awslogs-stream-prefix" = "ecs"
        }
      }

      healthCheck = {
        command     = ["CMD-SHELL", "curl -f http://localhost:${var.container_port}${var.health_check_path} || exit 1"]
        interval    = 30
        timeout     = 5
        retries     = 3
        startPeriod = 60
      }

      environment = [
        {
          name  = "ENVIRONMENT"
          value = var.environment
        }
      ]
    }
  ])

  tags = local.common_tags
}

data "aws_region" "current" {}

# ============================================================================
# ECS Service (G2, G10)
# ============================================================================

resource "aws_ecs_service" "main" {
  name            = "${local.name_prefix}-service"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.main.arn
  desired_count   = var.desired_count
  launch_type     = "FARGATE"

  deployment_minimum_healthy_percent = 50
  deployment_maximum_percent         = 200

  network_configuration {
    subnets          = var.private_subnet_ids
    security_groups  = [aws_security_group.ecs_tasks.id]
    assign_public_ip = false
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.main.arn
    container_name   = "taskflow-api"
    container_port   = var.container_port
  }

  tags = local.common_tags

  depends_on = [aws_lb_listener.http]
}

infrastructure/modules/ecs/outputs.tf

# @module taskflow_ecs_outputs
# @description Output values for the TaskFlow ECS module
# @version 1.0.0
# @author Tyler Dukes
# @last_updated 2025-01-15
# @status stable

output "cluster_arn" {
  description = "ARN of the ECS cluster"
  value       = aws_ecs_cluster.main.arn
}

output "cluster_name" {
  description = "Name of the ECS cluster"
  value       = aws_ecs_cluster.main.name
}

output "service_name" {
  description = "Name of the ECS service"
  value       = aws_ecs_service.main.name
}

output "alb_dns_name" {
  description = "DNS name of the Application Load Balancer"
  value       = aws_lb.main.dns_name
}

output "alb_zone_id" {
  description = "Route53 zone ID of the ALB for DNS alias records"
  value       = aws_lb.main.zone_id
}

output "target_group_arn" {
  description = "ARN of the ALB target group"
  value       = aws_lb_target_group.main.arn
}

output "task_execution_role_arn" {
  description = "ARN of the ECS task execution IAM role"
  value       = aws_iam_role.task_execution.arn
}

output "task_role_arn" {
  description = "ARN of the ECS task IAM role"
  value       = aws_iam_role.task_role.arn
}

output "ecs_security_group_id" {
  description = "Security group ID for ECS tasks"
  value       = aws_security_group.ecs_tasks.id
}

output "log_group_name" {
  description = "CloudWatch log group name for ECS tasks"
  value       = aws_cloudwatch_log_group.ecs.name
}

infrastructure/environments/dev/main.tf

# @module taskflow_dev
# @description Development environment configuration for TaskFlow
# @version 1.0.0
# @author Tyler Dukes
# @last_updated 2025-01-15
# @status stable

terraform {
  required_version = ">= 1.5.0"

  backend "s3" {
    bucket         = "taskflow-terraform-state"
    key            = "dev/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

provider "aws" {
  region = "us-east-1"

  default_tags {
    tags = {
      Project     = "taskflow"
      Environment = "dev"
      ManagedBy   = "terraform"
    }
  }
}

# -- Data Sources --
data "aws_vpc" "main" {
  tags = { Name = "taskflow-dev-vpc" }
}

data "aws_subnets" "private" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.main.id]
  }
  tags = { Tier = "private" }
}

data "aws_subnets" "public" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.main.id]
  }
  tags = { Tier = "public" }
}

# -- ECS Module --
module "ecs" {
  source = "../../modules/ecs"

  project_name       = "taskflow"
  environment        = "dev"
  vpc_id             = data.aws_vpc.main.id
  private_subnet_ids = data.aws_subnets.private.ids
  public_subnet_ids  = data.aws_subnets.public.ids
  container_image    = "123456789012.dkr.ecr.us-east-1.amazonaws.com/taskflow:dev"
  container_port     = 8000
  desired_count      = 1
  cpu                = 256
  memory             = 512
  health_check_path  = "/health"

  tags = {
    CostCenter = "engineering"
  }
}

# -- Outputs --
output "alb_dns_name" {
  description = "ALB DNS name for the dev environment"
  value       = module.ecs.alb_dns_name
}

output "cluster_name" {
  description = "ECS cluster name"
  value       = module.ecs.cluster_name
}

Checkpoint: Infrastructure validation

# Navigate to infrastructure directory
cd infrastructure

# Format all Terraform files
terraform fmt -recursive

# Initialize modules for validation
cd modules/ecs && terraform init -backend=false
terraform validate

# Expected output:
# Success! The configuration is valid.

cd ../../environments/dev && terraform init -backend=false
terraform validate

# Verify CONTRACT.md exists
cat modules/ecs/CONTRACT.md | head -5
# Expected: "# Module Contract: taskflow-ecs"

Step 5: Docker Configuration (5 min)

backend/Dockerfile

# @module taskflow_backend_dockerfile
# @description Multi-stage Docker build for FastAPI backend
# @version 1.0.0
# @author Tyler Dukes
# @last_updated 2025-01-15
# @status stable

# ============================================================================
# Stage 1: Build dependencies
# ============================================================================
FROM python:3.11-slim AS builder

WORKDIR /build

# Install uv for fast dependency resolution
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

# Copy dependency files first for layer caching
COPY pyproject.toml ./

# Install production dependencies only
RUN uv sync --no-dev --frozen

# ============================================================================
# Stage 2: Production image
# ============================================================================
FROM python:3.11-slim AS production

# Security: Run as non-root user (G8)
RUN groupadd --gid 1000 appuser && \
    useradd --uid 1000 --gid 1000 --create-home appuser

WORKDIR /app

# Copy virtual environment from builder
COPY --from=builder /build/.venv /app/.venv

# Copy application source
COPY src/ ./src/

# Set environment variables
ENV PATH="/app/.venv/bin:$PATH" \
    PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1

# Switch to non-root user
USER appuser

# Expose API port
EXPOSE 8000

# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1

# Start the application
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

frontend/Dockerfile

# @module taskflow_frontend_dockerfile
# @description Multi-stage Docker build for React frontend
# @version 1.0.0
# @author Tyler Dukes
# @last_updated 2025-01-15
# @status stable

# ============================================================================
# Stage 1: Install dependencies
# ============================================================================
FROM node:20-alpine AS deps

WORKDIR /app

# Copy dependency manifests for layer caching
COPY package.json package-lock.json ./

# Install production + dev dependencies (needed for build)
RUN npm ci

# ============================================================================
# Stage 2: Build application
# ============================================================================
FROM node:20-alpine AS builder

WORKDIR /app

# Copy dependencies from previous stage
COPY --from=deps /app/node_modules ./node_modules

# Copy source files
COPY . .

# Build production bundle
ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}

RUN npm run build

# ============================================================================
# Stage 3: Production image with nginx
# ============================================================================
FROM nginx:1.27-alpine AS production

# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Copy built assets from builder
COPY --from=builder /app/dist /usr/share/nginx/html

# Security: Run as non-root
RUN chown -R nginx:nginx /usr/share/nginx/html && \
    chown -R nginx:nginx /var/cache/nginx && \
    chown -R nginx:nginx /var/log/nginx && \
    touch /var/run/nginx.pid && \
    chown -R nginx:nginx /var/run/nginx.pid

USER nginx

EXPOSE 80

HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
    CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1

CMD ["nginx", "-g", "daemon off;"]

frontend/nginx.conf

server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # SPA routing: serve index.html for all non-file routes
    location / {
        try_files $uri $uri/ /index.html;
    }

    # API proxy to backend
    location /api/ {
        proxy_pass http://backend:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Cache static assets
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}

docker-compose.yml (root)

# docker-compose.yml - Local development environment with hot reload
#
# @module taskflow_docker_compose
# @description Docker Compose configuration for local development
# @version 1.0.0
# @author Tyler Dukes
# @last_updated 2025-01-15
# @status stable

services:
  # -- Backend API --
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
      target: production
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql+asyncpg://taskflow:taskflow@db:5432/taskflow
      - ENVIRONMENT=development
    volumes:
      - ./backend/src:/app/src:ro
    command: >
      uvicorn src.main:app
      --host 0.0.0.0
      --port 8000
      --reload
      --reload-dir /app/src
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s

  # -- Frontend SPA --
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
      target: deps
    ports:
      - "3000:3000"
    environment:
      - VITE_API_URL=http://localhost:8000/api/v1
    volumes:
      - ./frontend/src:/app/src:ro
      - ./frontend/public:/app/public:ro
    command: npx vite --host 0.0.0.0 --port 3000
    depends_on:
      - backend

  # -- PostgreSQL Database --
  db:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: taskflow
      POSTGRES_PASSWORD: taskflow
      POSTGRES_DB: taskflow
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U taskflow"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  pgdata:
    driver: local

Checkpoint: Docker validation

# Build all images
docker compose build

# Start services
docker compose up -d

# Verify all containers are healthy
docker compose ps

# Expected output:
# NAME                 STATUS
# taskflow-backend-1   Up (healthy)
# taskflow-frontend-1  Up
# taskflow-db-1        Up (healthy)

# Test backend health
curl http://localhost:8000/health
# Expected: {"status":"healthy","service":"taskflow-api","timestamp":"..."}

# Test frontend
curl -s http://localhost:3000 | head -5
# Expected: HTML content with <div id="root">

# Tear down
docker compose down

Step 6: Monorepo CI/CD (8 min)

.github/workflows/ci.yml

# .github/workflows/ci.yml - Monorepo CI pipeline with path-based triggers
#
# @module taskflow_ci
# @description GitHub Actions CI pipeline for multi-language monorepo
# @version 1.0.0
# @author Tyler Dukes
# @last_updated 2025-01-15
# @status stable

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# Cancel in-progress runs for the same branch
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # ========================================================================
  # Detect changed paths
  # ========================================================================
  changes:
    name: Detect Changes
    runs-on: ubuntu-latest
    outputs:
      backend: ${{ steps.filter.outputs.backend }}
      frontend: ${{ steps.filter.outputs.frontend }}
      infrastructure: ${{ steps.filter.outputs.infrastructure }}
      docker: ${{ steps.filter.outputs.docker }}
    steps:
      - uses: actions/checkout@v4

      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            backend:
              - 'backend/**'
              - 'shared/**'
            frontend:
              - 'frontend/**'
              - 'shared/**'
            infrastructure:
              - 'infrastructure/**'
            docker:
              - 'docker-compose.yml'
              - 'backend/Dockerfile'
              - 'frontend/Dockerfile'

  # ========================================================================
  # Pre-commit (runs on all changes)
  # ========================================================================
  pre-commit:
    name: Pre-commit Checks
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - uses: pre-commit/action@v3.0.1

  # ========================================================================
  # Backend CI
  # ========================================================================
  backend:
    name: Backend (Python)
    runs-on: ubuntu-latest
    needs: [changes]
    if: needs.changes.outputs.backend == 'true'
    defaults:
      run:
        working-directory: backend
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install uv
        uses: astral-sh/setup-uv@v5

      - name: Install dependencies
        run: uv sync

      - name: Lint with black
        run: uv run black --check src/ tests/

      - name: Lint with flake8
        run: uv run flake8 src/ tests/

      - name: Type check with mypy
        run: uv run mypy src/

      - name: Run tests with coverage
        run: uv run pytest tests/ -v --cov=src --cov-report=xml --cov-report=term-missing

      - name: Upload coverage
        if: github.event_name == 'pull_request'
        uses: actions/upload-artifact@v4
        with:
          name: backend-coverage
          path: backend/coverage.xml

  # ========================================================================
  # Frontend CI
  # ========================================================================
  frontend:
    name: Frontend (TypeScript)
    runs-on: ubuntu-latest
    needs: [changes]
    if: needs.changes.outputs.frontend == 'true'
    defaults:
      run:
        working-directory: frontend
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
          cache-dependency-path: frontend/package-lock.json

      - name: Install dependencies
        run: npm ci

      - name: Type check
        run: npx tsc --noEmit

      - name: Lint
        run: npm run lint

      - name: Format check
        run: npm run format:check

      - name: Run tests
        run: npm test -- --coverage

      - name: Build production bundle
        run: npm run build

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: frontend-build
          path: frontend/dist
          retention-days: 7

  # ========================================================================
  # Infrastructure CI
  # ========================================================================
  infrastructure:
    name: Infrastructure (Terraform)
    runs-on: ubuntu-latest
    needs: [changes]
    if: needs.changes.outputs.infrastructure == 'true'
    defaults:
      run:
        working-directory: infrastructure
    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.9.0"

      - name: Terraform format check
        run: terraform fmt -check -recursive

      - name: Validate ECS module
        run: |
          cd modules/ecs
          terraform init -backend=false
          terraform validate

      - name: Validate dev environment
        run: |
          cd environments/dev
          terraform init -backend=false
          terraform validate

  # ========================================================================
  # Docker Build Validation
  # ========================================================================
  docker:
    name: Docker Build
    runs-on: ubuntu-latest
    needs: [changes]
    if: needs.changes.outputs.docker == 'true'
    steps:
      - uses: actions/checkout@v4

      - name: Build backend image
        run: docker build -t taskflow-backend:test ./backend

      - name: Build frontend image
        run: docker build -t taskflow-frontend:test ./frontend

      - name: Verify backend health
        run: |
          docker run -d --name backend-test -p 8000:8000 taskflow-backend:test
          sleep 5
          curl -f http://localhost:8000/health
          docker stop backend-test

  # ========================================================================
  # CI Status Gate
  # ========================================================================
  ci-status:
    name: CI Status
    runs-on: ubuntu-latest
    needs: [pre-commit, backend, frontend, infrastructure, docker]
    if: always()
    steps:
      - name: Check job results
        run: |
          echo "Pre-commit: ${{ needs.pre-commit.result }}"
          echo "Backend:    ${{ needs.backend.result }}"
          echo "Frontend:   ${{ needs.frontend.result }}"
          echo "Infra:      ${{ needs.infrastructure.result }}"
          echo "Docker:     ${{ needs.docker.result }}"

          # Fail if any required job failed (skipped is ok)
          if [[ "${{ needs.pre-commit.result }}" == "failure" ]]; then
            echo "::error::Pre-commit checks failed"
            exit 1
          fi
          if [[ "${{ needs.backend.result }}" == "failure" ]]; then
            echo "::error::Backend CI failed"
            exit 1
          fi
          if [[ "${{ needs.frontend.result }}" == "failure" ]]; then
            echo "::error::Frontend CI failed"
            exit 1
          fi
          if [[ "${{ needs.infrastructure.result }}" == "failure" ]]; then
            echo "::error::Infrastructure CI failed"
            exit 1
          fi
          if [[ "${{ needs.docker.result }}" == "failure" ]]; then
            echo "::error::Docker build failed"
            exit 1
          fi

          echo "All CI checks passed!"

.github/workflows/deploy.yml

# .github/workflows/deploy.yml - Deployment pipeline for staging and production
#
# @module taskflow_deploy
# @description Deployment workflow triggered after CI passes on main
# @version 1.0.0
# @author Tyler Dukes
# @last_updated 2025-01-15
# @status stable

name: Deploy

on:
  workflow_run:
    workflows: ["CI"]
    types: [completed]
    branches: [main]

jobs:
  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    if: github.event.workflow_run.conclusion == 'success'
    environment: staging
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1

      - name: Login to ECR
        id: ecr-login
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push backend image
        env:
          ECR_REGISTRY: ${{ steps.ecr-login.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/taskflow-backend:$IMAGE_TAG ./backend
          docker push $ECR_REGISTRY/taskflow-backend:$IMAGE_TAG

      - name: Build and push frontend image
        env:
          ECR_REGISTRY: ${{ steps.ecr-login.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build \
            --build-arg VITE_API_URL=https://api.staging.taskflow.example.com/api/v1 \
            -t $ECR_REGISTRY/taskflow-frontend:$IMAGE_TAG \
            ./frontend
          docker push $ECR_REGISTRY/taskflow-frontend:$IMAGE_TAG

      - name: Deploy to ECS
        run: |
          aws ecs update-service \
            --cluster taskflow-staging-cluster \
            --service taskflow-staging-service \
            --force-new-deployment \
            --region us-east-1

Checkpoint: CI/CD validation

# Validate workflow syntax
# Install actionlint: https://github.com/rhysd/actionlint
actionlint .github/workflows/ci.yml
actionlint .github/workflows/deploy.yml

# Verify YAML syntax
yamllint .github/workflows/ci.yml
yamllint .github/workflows/deploy.yml

# Expected: No errors

Step 7: Cross-Language Validation (2 min)

Shared JSON Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "TaskFlow Task",
  "description": "Shared schema for task validation across backend and frontend",
  "type": "object",
  "required": ["title"],
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid",
      "description": "Unique task identifier"
    },
    "title": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200,
      "description": "Task title"
    },
    "description": {
      "type": ["string", "null"],
      "maxLength": 2000,
      "description": "Task description"
    },
    "status": {
      "type": "string",
      "enum": ["todo", "in_progress", "done", "archived"],
      "default": "todo"
    },
    "priority": {
      "type": "string",
      "enum": ["low", "medium", "high", "critical"],
      "default": "medium"
    },
    "assignee": {
      "type": ["string", "null"],
      "maxLength": 100
    },
    "created_at": {
      "type": "string",
      "format": "date-time"
    },
    "updated_at": {
      "type": "string",
      "format": "date-time"
    }
  }
}

The shared schema in shared/schemas/task.schema.json ensures that both the Python backend (Pydantic models) and the TypeScript frontend (type definitions) stay in sync.

Root validation script

#!/usr/bin/env bash
# scripts/validate-all.sh - Cross-language validation orchestrator
#
# @module validate_all
# @description Runs validation for all monorepo components
# @version 1.0.0
# @author Tyler Dukes
# @last_updated 2025-01-15
# @status stable

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

FAILED=0

log_pass() { echo -e "${GREEN}PASS${NC}: $1"; }
log_fail() { echo -e "${RED}FAIL${NC}: $1"; FAILED=1; }
log_info() { echo -e "${YELLOW}INFO${NC}: $1"; }

echo "============================================="
echo "TaskFlow Monorepo Validation"
echo "============================================="
echo ""

# -- Backend --
log_info "Validating Python backend..."
cd "$ROOT_DIR/backend"
if uv run black --check src/ tests/ 2>/dev/null; then
    log_pass "Backend formatting (black)"
else
    log_fail "Backend formatting (black)"
fi

if uv run flake8 src/ tests/ 2>/dev/null; then
    log_pass "Backend linting (flake8)"
else
    log_fail "Backend linting (flake8)"
fi

if uv run pytest tests/ -q 2>/dev/null; then
    log_pass "Backend tests (pytest)"
else
    log_fail "Backend tests (pytest)"
fi

# -- Frontend --
log_info "Validating TypeScript frontend..."
cd "$ROOT_DIR/frontend"
if npx tsc --noEmit 2>/dev/null; then
    log_pass "Frontend type checking (tsc)"
else
    log_fail "Frontend type checking (tsc)"
fi

if npm run lint 2>/dev/null; then
    log_pass "Frontend linting (eslint)"
else
    log_fail "Frontend linting (eslint)"
fi

if npm test 2>/dev/null; then
    log_pass "Frontend tests (vitest)"
else
    log_fail "Frontend tests (vitest)"
fi

# -- Infrastructure --
log_info "Validating Terraform infrastructure..."
cd "$ROOT_DIR/infrastructure"
if terraform fmt -check -recursive 2>/dev/null; then
    log_pass "Terraform formatting (fmt)"
else
    log_fail "Terraform formatting (fmt)"
fi

# -- Pre-commit --
log_info "Running pre-commit hooks..."
cd "$ROOT_DIR"
if pre-commit run --all-files 2>/dev/null; then
    log_pass "Pre-commit hooks"
else
    log_fail "Pre-commit hooks"
fi

echo ""
echo "============================================="
if [ "$FAILED" -eq 0 ]; then
    echo -e "${GREEN}All validations passed!${NC}"
    exit 0
else
    echo -e "${RED}Some validations failed.${NC}"
    exit 1
fi

Run full validation

# Using Make
make validate

# Or using the validation script directly
bash scripts/validate-all.sh

# Expected output:
# =============================================
# TaskFlow Monorepo Validation
# =============================================
#
# INFO: Validating Python backend...
# PASS: Backend formatting (black)
# PASS: Backend linting (flake8)
# PASS: Backend tests (pytest)
# INFO: Validating TypeScript frontend...
# PASS: Frontend type checking (tsc)
# PASS: Frontend linting (eslint)
# PASS: Frontend tests (vitest)
# INFO: Validating Terraform infrastructure...
# PASS: Terraform formatting (fmt)
# INFO: Running pre-commit hooks...
# PASS: Pre-commit hooks
#
# =============================================
# All validations passed!

Checkpoint: Final Verification

Run through this checklist to confirm everything is correctly set up.

Final Verification Checklist
=============================
[ ] Root .editorconfig exists with language-specific settings
[ ] Root .pre-commit-config.yaml covers Python, TypeScript, Terraform, Shell, YAML, Markdown
[ ] Root Makefile has targets: setup, dev, test, lint, format, validate, clean
[ ] Root docker-compose.yml starts backend, frontend, and database

Backend (Python):
[ ] pyproject.toml has project metadata, dependencies, and tool config
[ ] src/main.py has FastAPI app with /health and /ready endpoints
[ ] src/models/task.py has Pydantic models with field validation
[ ] src/routes/tasks.py has full CRUD (POST, GET, PATCH, DELETE)
[ ] All .py files have @module metadata tags
[ ] Tests pass: uv run pytest tests/ -v
[ ] Linting passes: uv run black --check src/ tests/ && uv run flake8 src/ tests/

Frontend (TypeScript):
[ ] tsconfig.json has strict: true
[ ] package.json has lint, format, format:check, test, build scripts
[ ] src/types/task.ts has typed interfaces matching backend models
[ ] src/services/api.ts has typed API client with error handling
[ ] src/hooks/useTasks.ts has custom hook with CRUD operations
[ ] src/components/TaskList.tsx and TaskForm.tsx are fully typed
[ ] src/App.tsx composes all components
[ ] All .ts/.tsx files have @module JSDoc metadata

Infrastructure (Terraform):
[ ] modules/ecs/CONTRACT.md has numbered guarantees (G1-G10)
[ ] modules/ecs/variables.tf has validation blocks on all inputs
[ ] modules/ecs/main.tf references guarantee numbers in comments
[ ] modules/ecs/outputs.tf exports all required values
[ ] environments/dev/main.tf uses the ECS module
[ ] terraform fmt -check -recursive passes
[ ] terraform validate passes for all modules

Docker:
[ ] backend/Dockerfile is multi-stage with non-root user
[ ] frontend/Dockerfile is multi-stage with nginx serving
[ ] docker-compose.yml has health checks on backend and database
[ ] docker compose build succeeds

CI/CD:
[ ] .github/workflows/ci.yml uses path-based triggers
[ ] CI runs backend/frontend/infra jobs in parallel
[ ] CI has a status gate job that aggregates results
[ ] .github/workflows/deploy.yml triggers after CI passes
# Quick automated check
echo "--- Checking file existence ---"
for f in \
  .editorconfig \
  .pre-commit-config.yaml \
  Makefile \
  docker-compose.yml \
  backend/pyproject.toml \
  backend/src/main.py \
  backend/src/models/task.py \
  backend/src/routes/tasks.py \
  backend/tests/test_tasks.py \
  frontend/package.json \
  frontend/tsconfig.json \
  frontend/src/App.tsx \
  frontend/src/types/task.ts \
  frontend/src/services/api.ts \
  infrastructure/modules/ecs/CONTRACT.md \
  infrastructure/modules/ecs/main.tf \
  infrastructure/modules/ecs/variables.tf \
  infrastructure/modules/ecs/outputs.tf \
  infrastructure/environments/dev/main.tf \
  .github/workflows/ci.yml \
  .github/workflows/deploy.yml; do
  if [ -f "$f" ]; then
    echo "  FOUND: $f"
  else
    echo "  MISSING: $f"
  fi
done

Common Troubleshooting

Problem: Pre-commit hooks fail on TypeScript files

Symptom: ESLint errors about missing parser or plugin
# Solution: Install ESLint dependencies in the frontend directory
cd frontend && npm install

# Then retry pre-commit
pre-commit run --all-files

Problem: Docker Compose backend fails to connect to database

Symptom: asyncpg.exceptions.ConnectionDoesNotExistError
# Solution: Ensure the database is healthy before starting the backend
docker compose up db -d
docker compose exec db pg_isready -U taskflow

# Once healthy, start the backend
docker compose up backend -d

Problem: Terraform validate fails with provider errors

Symptom: "Failed to query available provider packages"
# Solution: Initialize with -backend=false for local validation
cd infrastructure/modules/ecs
terraform init -backend=false
terraform validate

Problem: Frontend build fails with type errors after backend model changes

Symptom: TypeScript errors in api.ts or types/task.ts
# Solution: Regenerate types from the shared schema
# Compare shared/schemas/task.schema.json with frontend types
diff <(jq '.properties | keys' shared/schemas/task.schema.json) \
     <(grep -oP 'readonly \K\w+|^\s+\K\w+(?=[\?:])'  frontend/src/types/task.ts | sort -u)

# Update frontend/src/types/task.ts to match any new fields

Problem: CI workflow skips all jobs

Symptom: All backend/frontend/infra jobs show as "skipped"
# Cause: The paths-filter detects no changes in the relevant directories.
# This is expected behavior when changes only affect files outside
# backend/, frontend/, and infrastructure/.
#
# The pre-commit job always runs regardless of path changes.
# The ci-status gate treats "skipped" as passing.

Problem: Makefile targets fail with "command not found"

Symptom: make: uv: command not found
# Solution: Ensure uv is installed and in PATH
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"

# Add to your shell profile
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc

# Verify
uv --version
make setup-backend

Problem: Port conflicts when running locally

Symptom: "address already in use" errors on ports 8000, 3000, or 5432
# Find and kill processes using the ports
lsof -i :8000 -i :3000 -i :5432

# Or use different ports in docker-compose override
# Create docker-compose.override.yml:
cat > docker-compose.override.yml << 'OVERRIDE'
services:
  backend:
    ports:
      - "8001:8000"
  frontend:
    ports:
      - "3001:3000"
  db:
    ports:
      - "5433:5432"
OVERRIDE

Next Steps

After completing this tutorial, consider these follow-up activities.

Recommended Next Steps
======================
1. Add database migrations with Alembic (backend/alembic/)
2. Add end-to-end tests with Playwright (e2e/)
3. Add Terraform modules for RDS and networking
4. Set up staging environment in infrastructure/environments/staging/
5. Add monitoring with CloudWatch dashboards
6. Implement JWT authentication in the backend
7. Add the Dukes metadata validation script to the CI pipeline
Reference                                    Section
-----------------------------------------------------
Python conventions                           02_language_guides/python
TypeScript conventions                       02_language_guides/typescript
Terraform conventions                        02_language_guides/terraform
Docker conventions                           02_language_guides/docker
GitHub Actions conventions                   02_language_guides/github_actions
Makefile conventions                         02_language_guides/makefile
CONTRACT.md template                         04_templates/contract_template
IaC testing standards                        05_ci_cd/iac_testing