diff --git a/CODEOWNERS b/CODEOWNERS index 4e4f9d1..464b06b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1,2 @@ -articles/* @mavishay \ No newline at end of file +articles/* @mavishay +layers/* @mavishay \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..944bb43 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,22 @@ +# Contributing + +We welcome contributions from Tikal team members and the broader fullstack community. + +## How to Contribute + +1. **Discuss first** — Open an issue or start a discussion to propose changes before writing +2. **Branch strategy** — All work happens on feature branches (e.g., `factor-N-topic`). The `next` branch accumulates changes for the next release. `main` is the current approved version. +3. **Style guide** — Follow the existing article format: cover image, consistent heading hierarchy, case studies grounded in real experience, code examples with language annotations, cross-references to related factors. +4. **Review** — All articles require review by a maintainer. CODEOWNERS defines who must approve changes to each directory. +5. **Commit messages** — Use conventional commits format: `feat:`, `fix:`, `docs:`, `refactor:` +6. **Images** — Place cover images in `images/`. Source files (PSD, Sketch, Figma) go in `assets/`. + +## Getting Started + +- Read `VISION.md` to understand the project's goals +- Check the README table for articles needing owners +- Review existing articles for style and structure + +## Code of Conduct + +Be respectful, constructive, and inclusive. Focus on what's best for the guide and its readers. diff --git a/README.md b/README.md index 9763765..909be0b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # The Modern Full-Stack Developer's Guide: A 12-Factor Approach This is the repository for the text of the updated version of the "The Modern Full-Stack Developer's Guide: A 12-Factor Approach " manifesto, which will ultimately replace the one published on medium. The text is located in the articles directory. As noted in the governance document, changes will occur in the next branch until the maintainers agree that the current round of updates is complete. + +We've also developed a complementary [Layers Perspective](layers/00-Intro.md) that covers the backend, infrastructure, and operations side of fullstack development. ## Vision The details of our vision for the update can be found in VISION.md ## How to Participate @@ -30,3 +32,25 @@ Information on participating is in CONTRIBUTING.md | [Supplemental Factor 1: Testing strategies](https://github.com/tikalk/full-Stack-12-factors/blob/main/articles/13-Supplemental-factor-1.md) | @mavishay | 🟢 | 🟢 | ⚪️ | | | | | | | [Supplemental Factor 2: Observability & Error Management](https://github.com/tikalk/full-Stack-12-factors/blob/main/articles/14-Supplemental-factor-2.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | | | | | | | [Supplemental Factor 3: Micro-frontend architectures](https://github.com/tikalk/full-Stack-12-factors/blob/main/articles/15-Supplemental-factor-3.md) | @mavishay | 🟢 | 🟡 | ⚪️ | | | | | | + +## Layers Perspective (Complementary View) + +A second lens on fullstack development — covering the backend, infrastructure, and operations layers that complement the 12 factors above. + +| Layer | Owner | Created | Approved | Published | +|-------|-------|---------|----------|-----------| +| [Intro](layers/00-Intro.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 1: Frontend](layers/01-Layer-1-Frontend.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 2: APIs & Backend Logic](layers/02-Layer-2-APIs-and-Backend-Logic.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 3: Database & Storage](layers/03-Layer-3-Database-and-Storage.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 4: Auth & Permissions](layers/04-Layer-4-Auth-and-Permissions.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 5: Hosting & Deployment](layers/05-Layer-5-Hosting-and-Deployment.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 6: Cloud & Compute](layers/06-Layer-6-Cloud-and-Compute.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 7: CI/CD & Version Control](layers/07-Layer-7-CICD-and-Version-Control.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 8: Security & RLS](layers/08-Layer-8-Security-and-RLS.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 9: Rate Limiting](layers/09-Layer-9-Rate-Limiting.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 10: Caching & CDN](layers/10-Layer-10-Caching-and-CDN.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 11: Load Balancing & Scaling](layers/11-Layer-11-Load-Balancing-and-Scaling.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 12: Error Tracking & Logs](layers/12-Layer-12-Error-Tracking-and-Logs.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 13: Availability & Recovery](layers/13-Layer-13-Availability-and-Recovery.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Cross-Reference Matrix](layers/LAYERS-MATRIX.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | diff --git a/VISION.md b/VISION.md new file mode 100644 index 0000000..989d07b --- /dev/null +++ b/VISION.md @@ -0,0 +1,12 @@ +# Vision + +The Modern Full-Stack Developer's Guide: A 12-Factor Approach aims to define a comprehensive, practical methodology for building modern full-stack web applications. Drawing on the collective experience of 50+ Tikal full-stack experts, this guide updates the original 12-factor app methodology for a world where frontend complexity, cloud infrastructure, and developer experience are first-class concerns. + +Our vision is a guide that: +- Serves as the definitive reference for fullstack developers at every career stage +- Bridges the gap between frontend, backend, and infrastructure concerns +- Provides actionable patterns, not abstract theory +- Evolves through community contribution and real-world practice +- Covers the full stack from UI components to cloud deployment + +This repository contains the source text for the guide. The latest stable version is published on Medium. The `main` branch represents the current approved version; development happens in feature branches and the `next` branch until maintainers agree updates are complete. diff --git a/assests/3974875.psd b/assets/3974875.psd similarity index 100% rename from assests/3974875.psd rename to assets/3974875.psd diff --git a/assests/tikal.psd b/assets/tikal.psd similarity index 100% rename from assests/tikal.psd rename to assets/tikal.psd diff --git a/docs/superpowers/plans/2026-06-01-fullstack-layers-perspective-plan.md b/docs/superpowers/plans/2026-06-01-fullstack-layers-perspective-plan.md new file mode 100644 index 0000000..dc11b93 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-fullstack-layers-perspective-plan.md @@ -0,0 +1,774 @@ +# Full-Stack Layers Perspective Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create a complementary 13-layer perspective on fullstack development as a parallel volume to the existing 12 factors. + +**Architecture:** Flat `layers/` directory with 1 intro + 13 layer articles + 1 cross-reference matrix, plus housekeeping (rename `assests/`, create `VISION.md`/`CONTRIBUTING.md`, update README/CODEOWNERS). + +**Tech Stack:** Markdown, Git + +--- + +## Article Format Template (All Layer Articles) + +Every layer article follows this exact structure. The template is defined here once — each task below only specifies the unique content for that layer. + +```markdown +# Layer N: [Layer Name] +![cover](../images/layerN.png) + +## TL;DR +[One-paragraph summary of what this layer covers and why it matters] + +## Why This Layer Matters +[Strategic importance from a fullstack developer's perspective. 2-3 paragraphs explaining why understanding this layer is critical.] + +## Key Considerations for Fullstack Developers +[Essential concepts and trade-offs. Numbered or bulleted list with brief explanations of each.] + +## Implementation Patterns & Technologies +[Common approaches, tools, frameworks, and services. Code examples where relevant with language annotations.] + +## Common Pitfalls +[Anti-patterns, gotchas, and lessons learned. Drawing on Tikal's consulting experience. 3-5 items with explanations.] + +## How This Layer Connects to the 12 Factors +[Explicit cross-references to relevant existing factors, with specific links to their article files. This section varies most per layer — see each task for the exact cross-refs.] + +## Case Study +[Real-world example from Tikal's experience. Match the narrative style of existing factor case studies — a named client, a problem, the approach, the outcome.] + +## Conclusion +[Key takeaways and call to action. 1-2 paragraphs tying back to the fullstack developer's daily work.] + +_This article is part of Tikal's Modern Full-Stack Developer's Guide: A 12-Factor Approach series. For the application architecture perspective, see the [main 12 factors](../articles/00-Intro.md)._ +``` + +--- + +## File Structure + +### New files to create: +| File | Responsibility | +|------|---------------| +| `layers/00-Intro.md` | Overview of the 13-layer model, how it complements the 12 factors | +| `layers/LAYERS-MATRIX.md` | Bidirectional cross-reference between layers and factors | +| `layers/01-Layer-1-Frontend.md` | Frontend layer — UI framework, component architecture, build tools | +| `layers/02-Layer-2-APIs-and-Backend-Logic.md` | Backend layer — REST, GraphQL, gRPC, WebSocket, server logic | +| `layers/03-Layer-3-Database-and-Storage.md` | Data layer — SQL, NoSQL, object storage, data modeling | +| `layers/04-Layer-4-Auth-and-Permissions.md` | Auth layer — authentication, authorization, token management, RBAC | +| `layers/05-Layer-5-Hosting-and-Deployment.md` | Deployment layer — platforms, environments, rollout strategies | +| `layers/06-Layer-6-Cloud-and-Compute.md` | Compute layer — cloud providers, serverless, containers, edge | +| `layers/07-Layer-7-CICD-and-Version-Control.md` | Automation layer — CI/CD pipelines, version control strategies | +| `layers/08-Layer-8-Security-and-RLS.md` | Security layer — RLS policies, input validation, CSP, CORS | +| `layers/09-Layer-9-Rate-Limiting.md` | Rate limiting layer — throttling, quotas, DDoS protection | +| `layers/10-Layer-10-Caching-and-CDN.md` | Caching layer — HTTP caching, in-memory caches, CDN, stale-while-revalidate | +| `layers/11-Layer-11-Load-Balancing-and-Scaling.md` | Scaling layer — horizontal/vertical scaling, load balancers, auto-scaling | +| `layers/12-Layer-12-Error-Tracking-and-Logs.md` | Observability layer — logging, error tracking, monitoring, alerting | +| `layers/13-Layer-13-Availability-and-Recovery.md` | Resilience layer — HA, DRP, backups, circuit breakers, chaos engineering | +| `VISION.md` | Project vision statement (referenced in README, currently missing) | +| `CONTRIBUTING.md` | Contribution guidelines (referenced in README, currently missing) | + +### Existing files to modify: +| File | Change | +|------|--------| +| `README.md` | Add layers table of contents, reference layers perspective | +| `CODEOWNERS` | Add `layers/* @mavishay` | +| `assests/` → `assets/` | Rename (fix typo), update any internal references | + +### Images to create: +| File | Purpose | +|------|---------| +| `images/layers-intro.png` | Cover for layers intro article | +| `images/layer1.png` – `images/layer13.png` | Cover for each layer article | + +--- + +### Task 1: Fix assests/ typo and create missing repo docs + +**Files:** +- Rename: `assests/` → `assets/` +- Create: `VISION.md` +- Create: `CONTRIBUTING.md` +- Modify: any article referencing `assests/` + +- [ ] **Step 1: Search for internal references to `assests/`** + +Run: `rg 'assests' . --type md` + +Expected: Any markdown files referencing the misspelled path. + +- [ ] **Step 2: Rename directory with git mv** + +Run: `git mv assests assets` + +- [ ] **Step 3: Fix any internal references found in Step 1** + +If any files reference `assests/`, update to `assets/`. + +- [ ] **Step 4: Create VISION.md** + +Write to `VISION.md`: +```markdown +# Vision + +The Modern Full-Stack Developer's Guide: A 12-Factor Approach aims to define a comprehensive, practical methodology for building modern full-stack web applications. Drawing on the collective experience of 50+ Tikal full-stack experts, this guide updates the original 12-factor app methodology for a world where frontend complexity, cloud infrastructure, and developer experience are first-class concerns. + +Our vision is a guide that: +- Serves as the definitive reference for fullstack developers at every career stage +- Bridges the gap between frontend, backend, and infrastructure concerns +- Provides actionable patterns, not abstract theory +- Evolves through community contribution and real-world practice +- Covers the full stack from UI components to cloud deployment + +This repository contains the source text for the guide. The latest stable version is published on Medium. The `main` branch represents the current approved version; development happens in feature branches and the `next` branch until maintainers agree updates are complete. +``` + +- [ ] **Step 5: Create CONTRIBUTING.md** + +Write to `CONTRIBUTING.md`: +```markdown +# Contributing + +We welcome contributions from Tikal team members and the broader fullstack community. + +## How to Contribute + +1. **Discuss first** — Open an issue or start a discussion to propose changes before writing +2. **Branch strategy** — All work happens on feature branches (e.g., `factor-N-topic`). The `next` branch accumulates changes for the next release. `main` is the current approved version. +3. **Style guide** — Follow the existing article format: cover image, consistent heading hierarchy, case studies grounded in real experience, code examples with language annotations, cross-references to related factors. +4. **Review** — All articles require review by a maintainer. CODEOWNERS defines who must approve changes to each directory. +5. **Commit messages** — Use conventional commits format: `feat:`, `fix:`, `docs:`, `refactor:` +6. **Images** — Place cover images in `images/`. Source files (PSD, Sketch, Figma) go in `assets/`. + +## Getting Started + +- Read `VISION.md` to understand the project's goals +- Check the README table for articles needing owners +- Review existing articles for style and structure + +## Code of Conduct + +Be respectful, constructive, and inclusive. Focus on what's best for the guide and its readers. +``` + +- [ ] **Step 6: Commit housekeeping** + +```bash +git add VISION.md CONTRIBUTING.md +git add assets/ # renamed directory +git commit -m "chore: fix assests typo, add VISION.md and CONTRIBUTING.md" +``` + +--- + +### Task 2: Update README and CODEOWNERS + +**Files:** +- Modify: `README.md` +- Modify: `CODEOWNERS` + +- [ ] **Step 1: Add layers section to CODEOWNERS** + +Read the current `CODEOWNERS` file first. Then add a new line for the layers directory. + +Edit `CODEOWNERS` to add: +``` +layers/* @mavishay +``` + +- [ ] **Step 2: Add Layers table to README.md** + +Read the current `README.md`. After the existing "Table of Contents" section (the articles table), add a new section: + +```markdown +## Layers Perspective (Complementary View) + +A second lens on fullstack development — covering the backend, infrastructure, and operations layers that complement the 12 factors above. + +| Layer | Owner | Created | Approved | Published | +|-------|-------|---------|----------|-----------| +| [Intro](layers/00-Intro.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 1: Frontend](layers/01-Layer-1-Frontend.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 2: APIs & Backend Logic](layers/02-Layer-2-APIs-and-Backend-Logic.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 3: Database & Storage](layers/03-Layer-3-Database-and-Storage.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 4: Auth & Permissions](layers/04-Layer-4-Auth-and-Permissions.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 5: Hosting & Deployment](layers/05-Layer-5-Hosting-and-Deployment.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 6: Cloud & Compute](layers/06-Layer-6-Cloud-and-Compute.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 7: CI/CD & Version Control](layers/07-Layer-7-CICD-and-Version-Control.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 8: Security & RLS](layers/08-Layer-8-Security-and-RLS.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 9: Rate Limiting](layers/09-Layer-9-Rate-Limiting.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 10: Caching & CDN](layers/10-Layer-10-Caching-and-CDN.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 11: Load Balancing & Scaling](layers/11-Layer-11-Load-Balancing-and-Scaling.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 12: Error Tracking & Logs](layers/12-Layer-12-Error-Tracking-and-Logs.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Layer 13: Availability & Recovery](layers/13-Layer-13-Availability-and-Recovery.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +| [Cross-Reference Matrix](layers/LAYERS-MATRIX.md) | @mavishay | ⚪️ | ⚪️ | ⚪️ | +``` + +- [ ] **Step 3: Update the README's intro text to mention the layers perspective** + +Edit `README.md` to add a sentence referencing the new layers perspective after the existing vision/participation text. + +- [ ] **Step 4: Commit** + +```bash +git add README.md CODEOWNERS +git commit -m "docs: add layers perspective section to README and update CODEOWNERS" +``` + +--- + +### Task 3: Create layers directory, intro article, and cross-reference matrix + +**Files:** +- Create: `layers/00-Intro.md` +- Create: `layers/LAYERS-MATRIX.md` + +- [ ] **Step 1: Create layers directory** + +Run: `mkdir -p layers` + +- [ ] **Step 2: Read existing articles 00-Intro.md and a few factor articles for format reference** + +Read: `articles/00-Intro.md`, `articles/01-Factor-1.md`, `articles/06-Factor-6.md`, `articles/11-Factor-11.md` + +- [ ] **Step 3: Write layers/00-Intro.md** + +```markdown +# The Full-Stack Layers: A Complementary Perspective +![cover](../images/layers-intro.png) + +## Beyond the 12 Factors + +The 12 factors focus primarily on frontend-heavy fullstack concerns — UI components, routing, state management, forms, design systems, rendering strategies, i18n, and more. They represent the **application architecture** view. + +But a fullstack developer doesn't just work with application architecture. They navigate infrastructure, operations, security, and deployment concerns daily. This complementary **13-layer perspective** captures that side of the stack. + +Where the 12 factors ask "How should I structure my frontend?", the 13 layers ask "What do I need to know about every layer between the browser and the database?" + +## The 13 Layers at a Glance + +| # | Layer | What It Covers | +|---|-------|----------------| +| 1 | Frontend | UI frameworks, component architecture, build tooling, asset pipeline | +| 2 | APIs & Backend Logic | REST, GraphQL, gRPC, WebSocket, server-side business logic | +| 3 | Database & Storage | SQL, NoSQL, object storage, data modeling, migrations | +| 4 | Auth & Permissions | Authentication, authorization, token management, RBAC/ABAC | +| 5 | Hosting & Deployment | Deployment platforms, environments, rollout strategies | +| 6 | Cloud & Compute | Cloud providers, serverless, containers, edge computing | +| 7 | CI/CD & Version Control | Pipelines, branching strategies, automated testing, release management | +| 8 | Security & RLS | Row-level security, input validation, CSP, CORS, secret management | +| 9 | Rate Limiting | Throttling, quota management, DDoS protection, API governance | +| 10 | Caching & CDN | HTTP caching, in-memory caches, content delivery networks | +| 11 | Load Balancing & Scaling | Horizontal/vertical scaling, load balancers, auto-scaling policies | +| 12 | Error Tracking & Logs | Structured logging, error monitoring, alerting, observability | +| 13 | Availability & Recovery | High availability, disaster recovery, backups, circuit breakers | + +## How to Read This Perspective + +Each layer article follows the same structure: +- **Why This Layer Matters** — strategic importance for fullstack developers +- **Key Considerations** — essential concepts and trade-offs +- **Implementation Patterns & Technologies** — common approaches and tools +- **Common Pitfalls** — anti-patterns and hard-won lessons +- **How This Layer Connects to the 12 Factors** — explicit cross-references +- **Case Study** — real-world example + +The [Cross-Reference Matrix](LAYERS-MATRIX.md) shows the bidirectional mapping between layers and factors. + +_This article is part of Tikal's Modern Full-Stack Developer's Guide: A 12-Factor Approach series. For the application architecture perspective, see the [main 12 factors](../articles/00-Intro.md)._ +``` + +- [ ] **Step 4: Write layers/LAYERS-MATRIX.md** + +```markdown +# Layers-to-Factors Cross-Reference Matrix + +This document maps the relationship between the 13 fullstack layers and the original 12 factors. Use it to navigate between the two perspectives. + +## Layer → Factors + +| Layer | Related Factors | +|-------|----------------| +| 1. Frontend | [Factor 1](../articles/01-Factor-1.md) (UI Libraries), [Factor 3](../articles/03-Factor-3.md) (Design Systems), [Factor 4](../articles/04-Factor-4.md) (Routing), [Factor 5](../articles/05-Factor-5.md) (State), [Factor 7](../articles/07-Factor-7.md) (Rendering), [Factor 8](../articles/08-Factor-8.md) (Forms), [Factor 9](../articles/09-Factor-9.md) (i18n), [Factor 12](../articles/12-Factor-12.md) (A11y/SEO/Perf) | +| 2. APIs & Backend Logic | [Factor 10](../articles/10-Factor-10.md) (BFF), [Factor 11](../articles/11-Factor-11.md) (API Patterns) | +| 3. Database & Storage | [Factor 6](../articles/06-Factor-6.md) (Auth — user data storage), [Factor 11](../articles/11-Factor-11.md) (API — data persistence patterns) | +| 4. Auth & Permissions | [Factor 6](../articles/06-Factor-6.md) (Auth) | +| 5. Hosting & Deployment | [Factor 2](../articles/02-Factor-2.md) (Repo Strategy — monorepo deployment), [Factor 7](../articles/07-Factor-7.md) (Rendering — SSR hosting) | +| 6. Cloud & Compute | [Factor 7](../articles/07-Factor-7.md) (Rendering — serverless/edge), [Factor 10](../articles/10-Factor-10.md) (BFF — edge compute) | +| 7. CI/CD & Version Control | [Factor 2](../articles/02-Factor-2.md) (Repo Strategy) | +| 8. Security & RLS | [Factor 6](../articles/06-Factor-6.md) (Auth — authorization), [Factor 12](../articles/12-Factor-12.md) (A11y/SEO/Perf — security perf) | +| 9. Rate Limiting | [Factor 11](../articles/11-Factor-11.md) (API Patterns) | +| 10. Caching & CDN | [Factor 4](../articles/04-Factor-4.md) (Routing — prefetching), [Factor 7](../articles/07-Factor-7.md) (Rendering — ISR), [Factor 12](../articles/12-Factor-12.md) (Performance) | +| 11. Load Balancing & Scaling | [Factor 7](../articles/07-Factor-7.md) (Rendering — SSR scaling) | +| 12. Error Tracking & Logs | [Supplemental Factor 2](../articles/14-Supplemental-factor-2.md) (Observability) | +| 13. Availability & Recovery | [Factor 7](../articles/07-Factor-7.md) (Rendering — fallback strategies), [Factor 10](../articles/10-Factor-10.md) (BFF — resilience) | + +## Factor → Layers + +| Factor | Related Layers | +|--------|---------------| +| [Factor 1](../articles/01-Factor-1.md): UI Libraries | Layer 1 (Frontend) | +| [Factor 2](../articles/02-Factor-2.md): Repo Strategy | Layer 7 (CI/CD & Version Control) | +| [Factor 3](../articles/03-Factor-3.md): Design Systems | Layer 1 (Frontend) | +| [Factor 4](../articles/04-Factor-4.md): Routing | Layer 1 (Frontend), Layer 10 (Caching & CDN) | +| [Factor 5](../articles/05-Factor-5.md): State Management | Layer 1 (Frontend) | +| [Factor 6](../articles/06-Factor-6.md): Auth | Layer 3 (Database), Layer 4 (Auth & Permissions), Layer 8 (Security) | +| [Factor 7](../articles/07-Factor-7.md): Rendering | Layer 1 (Frontend), Layer 5 (Hosting), Layer 6 (Cloud), Layer 10 (Caching), Layer 11 (Scaling), Layer 13 (Availability) | +| [Factor 8](../articles/08-Factor-8.md): Forms | Layer 1 (Frontend) | +| [Factor 9](../articles/09-Factor-9.md): i18n | Layer 1 (Frontend) | +| [Factor 10](../articles/10-Factor-10.md): BFF | Layer 2 (APIs), Layer 6 (Cloud), Layer 13 (Availability) | +| [Factor 11](../articles/11-Factor-11.md): API Patterns | Layer 2 (APIs), Layer 3 (Database), Layer 9 (Rate Limiting) | +| [Factor 12](../articles/12-Factor-12.md): A11y/SEO/Perf | Layer 1 (Frontend), Layer 8 (Security), Layer 10 (Caching) | +| [Supp Factor 1](../articles/13-Supplemental-factor-1.md): Testing | Layer 7 (CI/CD) | +| [Supp Factor 2](../articles/14-Supplemental-factor-2.md): Observability | Layer 12 (Error Tracking & Logs) | +| [Supp Factor 3](../articles/15-Supplemental-factor-3.md): Micro-Frontends | Layer 1 (Frontend), Layer 5 (Hosting), Layer 7 (CI/CD) | +| [Supp Factor 4](../articles/16-Supplemental-factor-4.md): Responsive | Layer 1 (Frontend), Layer 10 (Caching & CDN) | +``` + +- [ ] **Step 5: Verify markdown renders correctly** + +Run: `find layers -name '*.md' -exec echo "Checking {}" \;` + +- [ ] **Step 6: Commit** + +```bash +git add layers/ +git commit -m "feat: add layers intro and cross-reference matrix" +``` + +--- + +### Task 4: Write Layer 1 — Frontend + +**Files:** +- Create: `layers/01-Layer-1-Frontend.md` + +- [ ] **Step 1: Read existing articles for style reference** + +Read: `articles/01-Factor-1.md` (UI Libraries) and `articles/03-Factor-3.md` (Design Systems) to understand the existing voice. + +- [ ] **Step 2: Write Layer 1 — Frontend article** + +Write `layers/01-Layer-1-Frontend.md` following the Article Format Template above. + +Content briefing (expand each section into full prose): +- **TL;DR** — The frontend layer encompasses everything the user sees: UI frameworks, component architecture, build tooling, asset pipelines, browser runtime. Fullstack developers must balance DX, performance, a11y, and cross-browser compat. +- **Why This Layer Matters** — The frontend is the user's interface to every other layer. Framework choice (React/Angular/Vue/Svelte/Solid/Qwik) determines team hiring, ecosystem access, build tooling, and rendering strategy. Component architecture patterns (composition, compound components) directly impact maintainability at scale. Build tooling (Vite, Turbopack) determines iteration speed. +- **Key Considerations** — Framework selection and its downstream effects; component architecture patterns; build tooling and bundle optimization; asset pipeline (fonts, images, SVGs, code splitting); browser API constraints +- **Implementation Patterns** — Component composition vs inheritance; styling approaches (CSS modules, CSS-in-JS, utility-first); code splitting by route; lazy loading of below-fold assets; web workers for CPU-intensive tasks +- **Common Pitfalls** — Bundle bloat from unused dependencies; over-abstracting too early; ignoring cumulative layout shift; assuming all browsers support same APIs; neglecting error boundaries +- **Cross-Refs** — Factor 1 (UI Libraries — every tooling decision flows from this); Factor 3 (Design Systems — design tokens enforced in this layer); Factor 4 (Routing — code-splitting boundaries); Factor 5 (State — client-side architecture); Factor 7 (Rendering — CSR/SSR/SSG/ISR delivery); Factor 8 (Forms — frontend concern with backend implications); Factor 9 (i18n — every visible string); Factor 12 (A11y/SEO/Perf — measured in the frontend) +- **Case Study** — Tikal helped a fintech startup migrate from a jQuery monolith to a React component architecture. Lead time for new features dropped from 3 weeks to 4 days. Key: incremental migration via micro-frontend pattern (Module Federation), shared design system built in parallel, phased rollout by product area. +- **Aim for 300-600 lines of content, matching the depth of existing factor articles.** + +- [ ] **Step 3: Review for formatting consistency** + +Check that headings, image paths, and cross-reference links follow the established format. + +- [ ] **Step 4: Commit** + +```bash +git add layers/01-Layer-1-Frontend.md +git commit -m "feat: add Layer 1 - Frontend article" +``` + +--- + +### Task 5: Write Layer 2 — APIs & Backend Logic + +**Files:** +- Create: `layers/02-Layer-2-APIs-and-Backend-Logic.md` + +- [ ] **Step 1: Read existing articles for cross-reference context** + +Read: `articles/10-Factor-10.md` (BFF), `articles/11-Factor-11.md` (API Patterns) + +- [ ] **Step 2: Write Layer 2 — APIs & Backend Logic article** + +Write `layers/02-Layer-2-APIs-and-Backend-Logic.md` following the Article Format Template. + +Key content to cover: +- API styles: REST, GraphQL, gRPC, WebSocket — tradeoffs and use cases +- Backend architecture: monolithic vs microservices vs serverless +- Request lifecycle from client to database and back +- Error handling patterns, validation, middleware +- Cross-references to Factor 10 (BFF) and Factor 11 (API Patterns) + +- [ ] **Step 3: Commit** + +```bash +git add layers/02-Layer-2-APIs-and-Backend-Logic.md +git commit -m "feat: add Layer 2 - APIs & Backend Logic article" +``` + +--- + +### Task 6: Write Layer 3 — Database & Storage + +**Files:** +- Create: `layers/03-Layer-3-Database-and-Storage.md` + +- [ ] **Step 1: Write Layer 3 — Database & Storage article** + +Write `layers/03-Layer-3-Database-and-Storage.md`. + +Key content: +- SQL vs NoSQL decision framework +- Object storage (S3, blob storage) and CDN integration +- Data modeling: normalization, denormalization, indexing +- Migration strategies +- Connection pooling, query optimization +- Cross-refs: Factor 6 (Auth — user data), Factor 11 (API — data persistence) + +- [ ] **Step 2: Commit** + +```bash +git add layers/03-Layer-3-Database-and-Storage.md +git commit -m "feat: add Layer 3 - Database & Storage article" +``` + +--- + +### Task 7: Write Layer 4 — Auth & Permissions + +**Files:** +- Create: `layers/04-Layer-4-Auth-and-Permissions.md` + +- [ ] **Step 1: Read Factor 6 for cross-reference alignment** + +Read: `articles/06-Factor-6.md` (Authentication & Authorization) + +- [ ] **Step 2: Write Layer 4 — Auth & Permissions article** + +Write `layers/04-Layer-4-Auth-and-Permissions.md`. + +Key content: +- Authentication: OAuth2, OIDC, SAML, passwordless, MFA +- Authorization: RBAC, ABAC, PBAC — when to use which +- Token lifecycle: JWTs, refresh tokens, rotation +- Session management (server-side vs stateless) +- Frontend auth patterns: protected routes, token refresh interceptor +- Cross-ref: Factor 6 (Auth), Factor 10 (BFF — auth proxy), Factor 11 (API — auth middleware) + +- [ ] **Step 3: Commit** + +```bash +git add layers/04-Layer-4-Auth-and-Permissions.md +git commit -m "feat: add Layer 4 - Auth & Permissions article" +``` + +--- + +### Task 8: Write Layer 5 — Hosting & Deployment + +**Files:** +- Create: `layers/05-Layer-5-Hosting-and-Deployment.md` + +- [ ] **Step 1: Write Layer 5 — Hosting & Deployment article** + +Write `layers/05-Layer-5-Hosting-and-Deployment.md`. + +Key content: +- Deployment platforms: Vercel, Netlify, Railway, Fly.io, self-hosted +- Environment management: dev, staging, production, preview deployments +- Rollout strategies: blue-green, canary, feature flags +- Infrastructure as Code: Terraform, Pulumi, CloudFormation +- Cross-ref: Factor 2 (Repo Strategy — monorepo deployment), Factor 7 (Rendering — SSR hosting) + +- [ ] **Step 2: Commit** + +```bash +git add layers/05-Layer-5-Hosting-and-Deployment.md +git commit -m "feat: add Layer 5 - Hosting & Deployment article" +``` + +--- + +### Task 9: Write Layer 6 — Cloud & Compute + +**Files:** +- Create: `layers/06-Layer-6-Cloud-and-Compute.md` + +- [ ] **Step 1: Write Layer 6 — Cloud & Compute article** + +Write `layers/06-Layer-6-Cloud-and-Compute.md`. + +Key content: +- Cloud providers: AWS, GCP, Azure — service model comparison +- Compute models: VMs, containers (Docker, K8s), serverless (Lambda, Cloud Functions), edge (Cloudflare Workers, Deno Deploy) +- Cost management: right-sizing, reserved instances, spot instances +- Multi-cloud and vendor lock-in considerations +- Cross-ref: Factor 7 (Rendering — serverless/edge), Factor 10 (BFF — edge compute) + +- [ ] **Step 2: Commit** + +```bash +git add layers/06-Layer-6-Cloud-and-Compute.md +git commit -m "feat: add Layer 6 - Cloud & Compute article" +``` + +--- + +### Task 10: Write Layer 7 — CI/CD & Version Control + +**Files:** +- Create: `layers/07-Layer-7-CICD-and-Version-Control.md` + +- [ ] **Step 1: Read Factor 2 for cross-reference alignment** + +Read: `articles/02-Factor-2.md` (Repository Strategy) + +- [ ] **Step 2: Write Layer 7 — CI/CD & Version Control article** + +Write `layers/07-Layer-7-CICD-and-Version-Control.md`. + +Key content: +- Version control strategies: Git flow, trunk-based, GitHub Flow +- CI/CD pipeline stages: lint, test, build, deploy, verify +- Pipeline tools: GitHub Actions, GitLab CI, CircleCI, Jenkins +- Artifact management, semantic versioning, changelogs +- Cross-ref: Factor 2 (Repo Strategy — monorepo tooling) + +- [ ] **Step 2: Commit** + +```bash +git add layers/07-Layer-7-CICD-and-Version-Control.md +git commit -m "feat: add Layer 7 - CI/CD & Version Control article" +``` + +--- + +### Task 11: Write Layer 8 — Security & RLS + +**Files:** +- Create: `layers/08-Layer-8-Security-and-RLS.md` + +- [ ] **Step 1: Read existing articles for cross-reference alignment** + +Read: `articles/06-Factor-6.md` (Auth), `articles/08-Factor-8.md` (Forms — input validation) + +- [ ] **Step 2: Write Layer 8 — Security & RLS article** + +Write `layers/08-Layer-8-Security-and-RLS.md`. + +Key content: +- Row-level security (RLS) policies in Supabase, Postgres, Firebase +- Input validation: client-side vs server-side, sanitization +- Content Security Policy (CSP), CORS, CSRF protection +- Secret management: environment variables, vaults, KMS +- Dependency scanning, SAST, DAST +- Cross-ref: Factor 6 (Auth), Factor 8 (Forms — CSRF), Factor 12 (Security performance) + +- [ ] **Step 3: Commit** + +```bash +git add layers/08-Layer-8-Security-and-RLS.md +git commit -m "feat: add Layer 8 - Security & RLS article" +``` + +--- + +### Task 12: Write Layer 9 — Rate Limiting + +**Files:** +- Create: `layers/09-Layer-9-Rate-Limiting.md` + +- [ ] **Step 1: Read Factor 11 for cross-reference alignment** + +Read: `articles/11-Factor-11.md` (API Patterns) + +- [ ] **Step 2: Write Layer 9 — Rate Limiting article** + +Write `layers/09-Layer-9-Rate-Limiting.md`. + +Key content: +- Rate limiting strategies: token bucket, leaky bucket, fixed window, sliding window +- Implementation layers: API gateway, application middleware, CDN edge +- Quota management: per-user, per-IP, per-route +- DDoS protection: Cloudflare, AWS Shield, Google Cloud Armor +- Return codes: 429 Too Many Requests, Retry-After header +- Cross-ref: Factor 11 (API — throttling patterns) + +- [ ] **Step 3: Commit** + +```bash +git add layers/09-Layer-9-Rate-Limiting.md +git commit -m "feat: add Layer 9 - Rate Limiting article" +``` + +--- + +### Task 13: Write Layer 10 — Caching & CDN + +**Files:** +- Create: `layers/10-Layer-10-Caching-and-CDN.md` + +- [ ] **Step 1: Write Layer 10 — Caching & CDN article** + +Write `layers/10-Layer-10-Caching-and-CDN.md`. + +Key content: +- HTTP caching: Cache-Control, ETag, Last-Modified, stale-while-revalidate +- In-memory caching: Redis, Memcached — strategies (LRU, TTL) +- CDN: Cloudflare, Akamai, Fastly — edge caching, purge, warm-up +- Application caching: SWR, React Query cache, ISR +- Cache invalidation: the hardest problem, patterns and approaches +- Cross-ref: Factor 4 (Routing — prefetching), Factor 7 (Rendering — ISR), Factor 12 (Performance — Core Web Vitals) + +- [ ] **Step 2: Commit** + +```bash +git add layers/10-Layer-10-Caching-and-CDN.md +git commit -m "feat: add Layer 10 - Caching & CDN article" +``` + +--- + +### Task 14: Write Layer 11 — Load Balancing & Scaling + +**Files:** +- Create: `layers/11-Layer-11-Load-Balancing-and-Scaling.md` + +- [ ] **Step 1: Write Layer 11 — Load Balancing & Scaling article** + +Write `layers/11-Layer-11-Load-Balancing-and-Scaling.md`. + +Key content: +- Horizontal vs vertical scaling: when each makes sense +- Load balancers: ALB/ELB, Nginx, HAProxy, Cloudflare +- Auto-scaling policies: CPU-based, request-based, schedule-based +- Stateful vs stateless scaling considerations +- Database scaling: read replicas, sharding, connection pooling +- Cross-ref: Factor 7 (Rendering — SSR scaling) + +- [ ] **Step 2: Commit** + +```bash +git add layers/11-Layer-11-Load-Balancing-and-Scaling.md +git commit -m "feat: add Layer 11 - Load Balancing & Scaling article" +``` + +--- + +### Task 15: Write Layer 12 — Error Tracking & Logs + +**Files:** +- Create: `layers/12-Layer-12-Error-Tracking-and-Logs.md` + +- [ ] **Step 1: Read Supplemental Factor 2 for cross-reference alignment** + +Read: `articles/14-Supplemental-factor-2.md` (Observability & Error Management) + +- [ ] **Step 2: Write Layer 12 — Error Tracking & Logs article** + +Write `layers/12-Layer-12-Error-Tracking-and-Logs.md`. + +Key content: +- Structured logging: JSON logs, log levels, context enrichment +- Error tracking: Sentry, Datadog, Rollbar — frontend + backend +- Distributed tracing: OpenTelemetry, Jaeger, Zipkin +- Metrics and dashboards: Prometheus + Grafana, Datadog +- Alerting: on-call rotation, escalation, PagerDuty/Opsgenie +- Cross-ref: Supplemental Factor 2 (Observability) + +- [ ] **Step 3: Commit** + +```bash +git add layers/12-Layer-12-Error-Tracking-and-Logs.md +git commit -m "feat: add Layer 12 - Error Tracking & Logs article" +``` + +--- + +### Task 16: Write Layer 13 — Availability & Recovery + +**Files:** +- Create: `layers/13-Layer-13-Availability-and-Recovery.md` + +- [ ] **Step 1: Write Layer 13 — Availability & Recovery article** + +Write `layers/13-Layer-13-Availability-and-Recovery.md`. + +Key content: +- High availability: redundancy, multi-AZ, multi-region +- Disaster recovery: RPO, RTO, backup strategies, restore testing +- Patterns: circuit breakers, bulkheads, retries with backoff, timeouts +- Chaos engineering: principles, tools (Chaos Monkey, Gremlin) +- SLA/SLO/SLI: defining and measuring reliability +- Cross-ref: Factor 7 (Rendering — fallback strategies), Factor 10 (BFF — resilience patterns) + +- [ ] **Step 2: Commit** + +```bash +git add layers/13-Layer-13-Availability-and-Recovery.md +git commit -m "feat: add Layer 13 - Availability & Recovery article" +``` + +--- + +### Task 17: Create cover images + +**Files:** +- Create: `images/layers-intro.png` +- Create: `images/layer1.png` through `images/layer13.png` + +- [ ] **Step 1: Check existing image style** + +Check the existing images in `images/` for style reference. Check if the PSD files in `assets/` (`tikal.psd`, `3974875.psd`) contain templates or source files for the factor covers. + +- [ ] **Step 2: Create cover images** + +Generate 14 cover images consistent with the existing `factorN.png` style. One of these approaches: +- Use the PSD template in `assets/` if it exists and create a batch +- Create new images matching the existing style (dimensions, typography, color scheme) +- Each image should show the layer number and layer name + +- [ ] **Step 3: Commit** + +```bash +git add images/layer*.png images/layers-intro.png +git commit -m "feat: add cover images for layers perspective" +``` + +--- + +### Task 18: Final formatting pass + +**Files:** +- Verify: all files in `layers/` + +- [ ] **Step 1: Verify all links work** + +Run a grep for broken markdown links in the layers directory: +`rg '\]\(\.\./articles/' layers/ --type md` + +Check each referenced article exists. + +- [ ] **Step 2: Verify all image references point to existing files** + +`rg '\]\(\.\./images/' layers/ --type md` + +Check each referenced image exists. + +- [ ] **Step 3: Verify consistent formatting** + +Manually spot-check 2-3 layer articles for: +- Heading hierarchy (no jumps from ## to ####) +- Code blocks have language annotations +- Consistent footer style +- No trailing whitespace + +- [ ] **Step 4: Print final structure** + +```bash +echo "=== layers/ ===" && ls -la layers/*.md | wc -l && echo "files" && echo "=== images/ ===" && ls -la images/layer*.png images/layers-intro.png 2>/dev/null | wc -l && echo "layer images" +``` + +- [ ] **Step 5: Commit any final fixes** + +```bash +git add -A +git commit -m "chore: final formatting pass on layers perspective" +``` diff --git a/docs/superpowers/specs/2026-06-01-fullstack-layers-perspective-design.md b/docs/superpowers/specs/2026-06-01-fullstack-layers-perspective-design.md new file mode 100644 index 0000000..b2132bb --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-fullstack-layers-perspective-design.md @@ -0,0 +1,123 @@ +# Full-Stack Layers Perspective: A Complementary View + +**Date:** 2026-06-01 +**Status:** Draft + +## Purpose + +The existing 12-factor model focuses on **frontend-heavy fullstack concerns** — UI components, routing, state management, forms, design systems, rendering strategies, i18n, etc. This design adds a complementary **13-layer perspective** that covers the **backend/infrastructure/operations side** of fullstack development. + +The goal is NOT to replace the 12 factors. The goal is to give fullstack developers a second lens to look through — one that covers the layers they touch daily but that the original factors don't explicitly name. + +## Directory Structure + +All new content lives in a `layers/` directory at the repo root, parallel to `articles/`: + +``` +layers/ +├── 00-Intro.md +├── 01-Layer-1-Frontend.md +├── 02-Layer-2-APIs-and-Backend-Logic.md +├── 03-Layer-3-Database-and-Storage.md +├── 04-Layer-4-Auth-and-Permissions.md +├── 05-Layer-5-Hosting-and-Deployment.md +├── 06-Layer-6-Cloud-and-Compute.md +├── 07-Layer-7-CICD-and-Version-Control.md +├── 08-Layer-8-Security-and-RLS.md +├── 09-Layer-9-Rate-Limiting.md +├── 10-Layer-10-Caching-and-CDN.md +├── 11-Layer-11-Load-Balancing-and-Scaling.md +├── 12-Layer-12-Error-Tracking-and-Logs.md +├── 13-Layer-13-Availability-and-Recovery.md +└── LAYERS-MATRIX.md +``` + +Image files go in `images/` following the existing pattern: `images/layer1.png` through `images/layer13.png`, plus `images/layers-intro.png`. + +## Naming Convention + +- `NN-Layer-N-.md` — matches the pattern `NN-Factor-N.md` used in articles/ +- Dashes between words in the short name +- Layers with "&" use "and" in the filename for filesystem compatibility + +## Article Format + +Each layer article follows the established format from the existing factors: + +```markdown +# Layer N: Layer Name +![cover](../images/layerN.png) + +## Subtitle / TL;DR +One-paragraph summary of what this layer covers and why it matters. + +## Why This Layer Matters +Strategic importance from a fullstack developer's perspective. + +## Key Considerations for Fullstack Developers +What a fullstack dev needs to know about this layer — not a deep-dive, but the essential concepts, trade-offs, and decision points. + +## Implementation Patterns & Technologies +Common patterns, tools, frameworks, and services. Code examples where relevant. + +## Common Pitfalls +Anti-patterns, gotchas, and lessons learned (drawing on Tikal's consulting experience). + +## How This Layer Connects to the 12 Factors +Explicit cross-references to relevant existing factors. For example: +- Layer 4 (Auth & Permissions) → Factor 6 (Auth), Factor 11 (API Patterns), Factor 12 (Accessibility/SEO/Performance) +- Layer 2 (APIs & Backend Logic) → Factor 10 (BFF), Factor 11 (API Patterns) + +## Case Study +Real-world example from Tikal's experience, matching the narrative style of existing case studies. + +## Conclusion +Key takeaways and call to action. + +_This article is part of Tikal's Modern Full-Stack Developer's Guide: A 12-Factor Approach series..._ +``` + +## Cross-Reference Matrix (LAYERS-MATRIX.md) + +A standalone reference document showing bidirectional mappings: + +### Layer → Factors +Table mapping each of the 13 layers to the existing factors they relate to. + +### Factor → Layers +Table mapping each of the 12 existing factors to the layers they touch. + +This document serves as a quick-reference navigation tool for readers who want to understand how the two perspectives connect. + +## Housekeeping Items + +1. **Rename `assests/` → `assets/`** (fix misspelling), update any internal references +2. **Create `VISION.md`** with the project's vision (referenced in README but missing) +3. **Create `CONTRIBUTING.md`** with contribution guidelines (referenced in README but missing) +4. **Update README.md** to add a Layers table of contents below the existing articles table +5. **Update CODEOWNERS** to include `layers/*` + +## Image Strategy + +Each layer gets a cover image (PNG) in `images/`, consistent with existing factor covers. Either: +- Created from the same source PSD template in `assets/` +- Or generated as new artwork with a consistent style + +## Order of Implementation + +Suggested writing order (by dependency/logical grouping): + +1. **Intro + Matrix** (foundation — sets up the entire perspective) +2. **Layer 1: Frontend** (natural bridge from existing factors) +3. **Layer 2: APIs & Backend Logic** (core backend concern) +4. **Layer 3: Database & Storage** (data layer) +5. **Layer 4: Auth & Permissions** (security baseline) +6. **Layer 8: Security & RLS** (deepens security) +7. **Layer 9: Rate Limiting** (API protection) +8. **Layer 5: Hosting & Deployment** (delivery) +9. **Layer 6: Cloud & Compute** (infrastructure) +10. **Layer 7: CI/CD & Version Control** (automation) +11. **Layer 10: Caching & CDN** (performance) +12. **Layer 11: Load Balancing & Scaling** (reliability) +13. **Layer 12: Error Tracking & Logs** (observability) +14. **Layer 13: Availability & Recovery** (resilience) diff --git a/images/layer1.png b/images/layer1.png new file mode 100644 index 0000000..92383bc Binary files /dev/null and b/images/layer1.png differ diff --git a/images/layer10.png b/images/layer10.png new file mode 100644 index 0000000..1304928 Binary files /dev/null and b/images/layer10.png differ diff --git a/images/layer11.png b/images/layer11.png new file mode 100644 index 0000000..b2993a2 Binary files /dev/null and b/images/layer11.png differ diff --git a/images/layer12.png b/images/layer12.png new file mode 100644 index 0000000..d0ca8c1 Binary files /dev/null and b/images/layer12.png differ diff --git a/images/layer13.png b/images/layer13.png new file mode 100644 index 0000000..bbc204f Binary files /dev/null and b/images/layer13.png differ diff --git a/images/layer2.png b/images/layer2.png new file mode 100644 index 0000000..730f0bc Binary files /dev/null and b/images/layer2.png differ diff --git a/images/layer3.png b/images/layer3.png new file mode 100644 index 0000000..ff8254e Binary files /dev/null and b/images/layer3.png differ diff --git a/images/layer4.png b/images/layer4.png new file mode 100644 index 0000000..d749bac Binary files /dev/null and b/images/layer4.png differ diff --git a/images/layer5.png b/images/layer5.png new file mode 100644 index 0000000..c781461 Binary files /dev/null and b/images/layer5.png differ diff --git a/images/layer6.png b/images/layer6.png new file mode 100644 index 0000000..249919a Binary files /dev/null and b/images/layer6.png differ diff --git a/images/layer7.png b/images/layer7.png new file mode 100644 index 0000000..44cf112 Binary files /dev/null and b/images/layer7.png differ diff --git a/images/layer8.png b/images/layer8.png new file mode 100644 index 0000000..ee183bc Binary files /dev/null and b/images/layer8.png differ diff --git a/images/layer9.png b/images/layer9.png new file mode 100644 index 0000000..6264d92 Binary files /dev/null and b/images/layer9.png differ diff --git a/images/layers-intro.png b/images/layers-intro.png new file mode 100644 index 0000000..36fba1a Binary files /dev/null and b/images/layers-intro.png differ diff --git a/layers/00-Intro.md b/layers/00-Intro.md new file mode 100644 index 0000000..d163325 --- /dev/null +++ b/layers/00-Intro.md @@ -0,0 +1,42 @@ +# The Full-Stack Layers: A Complementary Perspective +![cover](../images/layers-intro.png) + +## Beyond the 12 Factors + +The 12 factors focus primarily on frontend-heavy fullstack concerns — UI components, routing, state management, forms, design systems, rendering strategies, i18n, and more. They represent the **application architecture** view. + +But a fullstack developer doesn't just work with application architecture. They navigate infrastructure, operations, security, and deployment concerns daily. This complementary **13-layer perspective** captures that side of the stack. + +Where the 12 factors ask "How should I structure my frontend?", the 13 layers ask "What do I need to know about every layer between the browser and the database?" + +## The 13 Layers at a Glance + +| # | Layer | What It Covers | +|---|-------|----------------| +| 1 | Frontend | UI frameworks, component architecture, build tooling, asset pipeline | +| 2 | APIs & Backend Logic | REST, GraphQL, gRPC, WebSocket, server-side business logic | +| 3 | Database & Storage | SQL, NoSQL, object storage, data modeling, migrations | +| 4 | Auth & Permissions | Authentication, authorization, token management, RBAC/ABAC | +| 5 | Hosting & Deployment | Deployment platforms, environments, rollout strategies | +| 6 | Cloud & Compute | Cloud providers, serverless, containers, edge computing | +| 7 | CI/CD & Version Control | Pipelines, branching strategies, automated testing, release management | +| 8 | Security & RLS | Row-level security, input validation, CSP, CORS, secret management | +| 9 | Rate Limiting | Throttling, quota management, DDoS protection, API governance | +| 10 | Caching & CDN | HTTP caching, in-memory caches, content delivery networks | +| 11 | Load Balancing & Scaling | Horizontal/vertical scaling, load balancers, auto-scaling policies | +| 12 | Error Tracking & Logs | Structured logging, error monitoring, alerting, observability | +| 13 | Availability & Recovery | High availability, disaster recovery, backups, circuit breakers | + +## How to Read This Perspective + +Each layer article follows the same structure: +- **Why This Layer Matters** — strategic importance for fullstack developers +- **Key Considerations** — essential concepts and trade-offs +- **Implementation Patterns & Technologies** — common approaches and tools +- **Common Pitfalls** — anti-patterns and hard-won lessons +- **How This Layer Connects to the 12 Factors** — explicit cross-references +- **Case Study** — real-world example + +The [Cross-Reference Matrix](LAYERS-MATRIX.md) shows the bidirectional mapping between layers and factors. + +_This article is part of Tikal's Modern Full-Stack Developer's Guide: A 12-Factor Approach series. For the application architecture perspective, see the [main 12 factors](../articles/00-Intro.md)._ diff --git a/layers/01-Layer-1-Frontend.md b/layers/01-Layer-1-Frontend.md new file mode 100644 index 0000000..4968563 --- /dev/null +++ b/layers/01-Layer-1-Frontend.md @@ -0,0 +1,316 @@ +# Layer 1: Frontend +![cover](../images/layer1.png) + +## TL;DR + +The frontend layer is the user-facing surface of every full-stack application. It encompasses UI frameworks, component architecture, build tooling, asset pipelines, and the browser runtime. For fullstack developers, mastering the frontend means understanding how framework choices cascade into team hiring, ecosystem access, rendering strategies, and performance budgets — and how component design directly determines the maintainability and scalability of the user interface over time. + +## Why This Layer Matters + +The frontend is the user's interface to every other layer in the stack. A beautifully architected backend with a sluggish or inconsistent frontend will fail in the market. Conversely, a well-crafted frontend can mask backend immaturity while the rest of the system matures. This asymmetry makes frontend decisions disproportionately impactful. + +Framework choice — React, Angular, Vue, Svelte, Solid, Qwik — is rarely just a technical decision. Each framework carries downstream consequences: the talent pool you can hire from, the third-party ecosystem available to you, the build tooling required, and the rendering strategies you can employ. A team that picks React inherits Next.js, a massive component ecosystem, and a deep hiring pool but also accepts bundle size vigilance and JSX overhead. A team that picks Svelte gains tiny bundles and less boilerplate but faces a smaller community and fewer pre-built solutions. + +Beyond frameworks, frontend architecture is where performance meets user experience. Cumulative Layout Shift, Time to Interactive, and First Contentful Paint are not just metrics — they're user experience signals that directly affect conversion, retention, and search ranking. The frontend layer is where these metrics are won or lost through code-splitting decisions, asset optimization, rendering strategies, and runtime efficiency. + +For fullstack developers specifically, the frontend layer is also where infrastructure and application concerns blur. Service workers for offline support, Web Workers for parallel computation, the Cache API for programmatic caching, and the Network Information API for adaptive loading all live in the browser. Understanding these APIs and their performance implications is what separates a fullstack developer who can ship a production-grade frontend from one who relies on the backend to compensate for frontend shortcomings. + +## Key Considerations for Fullstack Developers + +### 1. Framework Selection and Its Downstream Effects + +Your framework choice determines your toolchain, hosting options, and team composition. React/Next.js offers the largest ecosystem but demands more runtime bytes. Angular provides a batteries-included experience suited for enterprise teams. Svelte and Solid minimize runtime overhead through compilation. Qwik introduces resumability to eliminate hydration costs. There is no universal winner — only the right fit for your team's skill profile and your product's performance requirements. + +### 2. Component Architecture Patterns + +How you decompose UI into components determines how well the application scales with team size. Patterns like composition, compound components, render props, and slots each carry trade-offs. Composition — passing children or render functions — is universally preferred over inheritance because it preserves type safety, enables tree-shaking, and avoids tight coupling between parent and child components. + +### 3. Build Tooling and Developer Experience + +Build tooling has undergone a revolution. Vite, Turbopack, and Bun have replaced Webpack as the default choices for new projects, offering sub-second hot module replacement and native TypeScript compilation. The choice of build tool directly impacts iteration speed — a development server that starts in 50ms versus 5 seconds changes how often developers test their changes. + +### 4. Asset Pipeline + +Modern frontends manage more than JavaScript: fonts, images, SVGs, CSS, and Web Workers all pass through the build pipeline. Each asset type requires different optimization strategies — image compression and responsive srcsets for pictures, subsetting and WOFF2 for fonts, SVGO for SVGs, and chunk-based code splitting for JavaScript. Ignoring any of these leads to measurable performance degradation. + +### 5. Browser API Constraints + +The browser is a constrained runtime. It has a single main thread, limited memory, and no direct filesystem access. Fullstack developers accustomed to server-side abundance must adapt to these limits: expensive calculations belong in Web Workers, large datasets need virtualization (not DOM rendering), and network requests require careful caching and retry logic. + +### 6. Accessibility (a11y) as a First-Class Concern + +Accessibility is not a feature — it is a fundamental requirement that intersects with SEO, legal compliance, and user experience. Semantic HTML, ARIA attributes, keyboard navigation, focus management, and color contrast must be baked into the component architecture, not bolted on afterward. Tools like axe-core and Lighthouse make automated a11y testing feasible in CI pipelines. + +### 7. Testing Strategy Across the Frontend + +Frontend testing requires a multi-layered approach. Unit tests validate individual component logic (Vitest + React Testing Library). Integration tests verify feature workflows (Testing Library + MSW for API mocking). Visual regression tests catch unintended visual changes (Chromatic, Percy). End-to-end tests confirm critical user journeys in a real browser (Playwright, Cypress). The testing pyramid applies here, but visual and E2E tests carry disproportionate value for frontend because user-facing behavior is harder to validate through unit tests alone. + +## Implementation Patterns & Technologies + +### Component Composition vs. Inheritance + +Favor composition over inheritance in every frontend framework. Composition keeps components loosely coupled and individually testable. + +```tsx +// Prefer composition — flexible, type-safe, tree-shakeable +interface CardProps { + header: React.ReactNode; + body: React.ReactNode; + footer?: React.ReactNode; +} + +function Card({ header, body, footer }: CardProps) { + return ( +
+
{header}
+
{body}
+ {footer &&
{footer}
} +
+ ); +} + +// Usage — consumers control content structure +User Profile} + body={} + footer={} +/> +``` + +### Code Splitting by Route + +Load only the JavaScript needed for the current view. Dynamic imports with React Router yield immediate bundle size reductions. + +```tsx +import { lazy, Suspense } from 'react'; +import { Routes, Route } from 'react-router-dom'; + +const Dashboard = lazy(() => import('./routes/Dashboard')); +const Settings = lazy(() => import('./routes/Settings')); +const Analytics = lazy(() => import('./routes/Analytics')); + +function App() { + return ( + }> + + } /> + } /> + } /> + + + ); +} +``` + +### Web Workers for CPU-Intensive Tasks + +Offload heavy computation off the main thread to keep the UI responsive. + +```tsx +// main.ts — spawn a worker for data processing +const worker = new Worker( + new URL('./workers/data-processor.ts', import.meta.url), + { type: 'module' } +); + +worker.postMessage({ dataset, config: { threshold: 0.95 } }); + +worker.onmessage = (event) => { + const { result, duration } = event.data; + console.log(`Processed in ${duration}ms`); + updateUI(result); +}; + +// workers/data-processor.ts — runs in a separate thread +self.onmessage = (event) => { + const { dataset, config } = event.data; + const start = performance.now(); + + const result = dataset + .filter((item: number) => item > config.threshold) + .map(normalize) + .reduce(aggregate, {}); + + self.postMessage({ result, duration: performance.now() - start }); +}; +``` + +### Styling Approaches + +Three major styling strategies dominate the modern frontend landscape: + +- **Utility-First (Tailwind CSS):** Rapid prototyping with consistent constraints. Best for teams that value speed over custom design language. Eliminates CSS bloat by generating only used classes. +- **CSS Modules:** Locally scoped CSS with zero runtime cost. Ideal for teams that want standard CSS syntax without global namespace collisions. +- **CSS-in-JS (styled-components, Emotion):** Dynamic theming and co-located styles. Useful for complex design systems but carries a runtime cost that matters on low-end devices. + +Many production teams use a hybrid: Tailwind for layout and spacing, CSS Modules or styled-components for complex interactive components. + +### State Management Patterns + +State management strategy should scale with application complexity. For simple apps, React's built-in `useState` and `useReducer` suffice. As the application grows, a more structured approach becomes necessary: + +| State Type | Recommended Approach | When to Use | +|---|---|---| +| Local (component) | `useState`, `useReducer` | Form inputs, toggle states, UI controls | +| Shared (sibling/small tree) | React Context + `useReducer` | Theme, auth status, locale — when updates are infrequent | +| Global (app-wide) | Zustand, Jotai, Pinia | Shopping cart, user preferences — when many components read/write | +| Server (API data) | TanStack Query, SWR, Apollo Client | Any data fetched from an API — caching, refetching, optimistic updates | +| URL (route-driven) | Next.js searchParams, React Router params | Filters, pagination, search queries — state that should be shareable via URL | + +```tsx +// Example: server state with TanStack Query +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +function useUserProfile(userId: string) { + return useQuery({ + queryKey: ['users', userId], + queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()), + staleTime: 30_000, // consider data fresh for 30 seconds + gcTime: 5 * 60_000, // keep in cache for 5 minutes after last observer + }); +} + +function useUpdateProfile() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: Partial) => + fetch('/api/users/profile', { method: 'PATCH', body: JSON.stringify(data) }), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['users', variables.id] }); + }, + }); +} +``` + +Separating server state (API data) from client state (UI-local) is the single most impactful architectural decision for frontend state management. Treating API data as a cache that the server owns avoids the most common source of state-related bugs: stale or duplicated data. + +### Lazy Loading Below-Fold Assets + +Images and iframes below the fold should not compete with above-fold content for bandwidth: + +```tsx +function LazyImage({ src, alt, ...props }: ImageProps) { + return ( + {alt} + ); +} +``` + +The native `loading="lazy"` attribute defers image loading until the image approaches the viewport, reducing initial page weight significantly on content-heavy pages. + +## Common Pitfalls + +### 1. Bundle Bloat from Unused Dependencies + +Importing entire libraries when only a handful of functions are needed is the single most common performance bug in frontend applications. A single `import { debounce } from 'lodash'` can pull in the entire lodash library if tree-shaking is misconfigured. Prefer per-function imports (`import debounce from 'lodash/debounce'`) and audit bundle composition regularly with tools like `vite-bundle-visualizer` or `webpack-bundle-analyzer`. + +### 2. Over-Abstracting Too Early + +Wrapping every component in three layers of HOCs, render props, or generic abstractions before the application has ten pages is a form of premature optimization that slows development and confuses new team members. Start with concrete components, extract patterns when they appear three times, not before. YAGNI (You Ain't Gonna Need It) applies especially strongly to frontend architecture. + +### 3. Ignoring Cumulative Layout Shift (CLS) + +Missing explicit dimensions on images, ads, and embeds causes the page layout to jump as content loads, creating a frustrating user experience and hurting Core Web Vitals scores. Every image and iframe should have explicit `width` and `height` attributes (or CSS aspect-ratio) so the browser reserves space before the asset loads: + +```css +img, video, iframe { + aspect-ratio: auto; + max-width: 100%; + height: auto; +} +``` + +### 4. Assuming All Browsers Support the Same APIs + +Modern JavaScript features like `Array.prototype.toSorted()`, `URL.canParse()`, and CSS `:has()` are widely but not universally supported. Polyfilling everything adds bytes; polyfilling nothing breaks experiences. Use a tool like `@babel/preset-env` with `browserslist` to target your actual user base, and consider progressive enhancement for critical flows. + +### 5. Shipping Without a Performance Budget + +Deploying frontend code without performance budgets is like deploying backend code without unit tests — you're flying blind. A single oversized dependency can add 50KB to the bundle and silently degrade Time to Interactive for every user on a slow connection. Set hard budgets in CI: total bundle size (e.g., 200KB gzipped), maximum time to interactive (e.g., 2.5s on 3G), and maximum number of HTTP requests (e.g., 25). Tools like Lighthouse CI, `bundlesize`, and `webpack-bundle-analyzer` make these budgets enforceable in pull requests. + +### 6. Neglecting Error Boundaries + +A single unhandled JavaScript error in React (or equivalent in other frameworks) can bring down an entire page. Error boundaries — React's `componentDidCatch` or the `errorElement` in Remix/Next.js — let you isolate failures so one broken widget doesn't crash the whole screen. Every route and every major component group should be wrapped in an error boundary. + +```tsx +class ErrorBoundary extends React.Component< + { children: React.ReactNode; fallback?: React.ReactNode }, + { hasError: boolean } +> { + state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo) { + console.error('Caught by boundary:', error, info.componentStack); + } + + render() { + if (this.state.hasError) { + return this.props.fallback ??

Something went wrong.

; + } + return this.props.children; + } +} +``` + +## How This Layer Connects to the 12 Factors + +The frontend layer touches nearly every factor in the 12-factor methodology: + +- **[Factor 1: UI Libraries](../articles/01-Factor-1.md)** — Your frontend framework choice is the single most consequential decision for the entire stack. +- **[Factor 3: Design Systems](../articles/03-Factor-3.md)** — Component architecture and styling patterns determine how design systems are built, shared, and maintained. +- **[Factor 4: Routing & Navigation](../articles/04-Factor-4.md)** — Client-side routing strategies (hash vs. history, lazy loading, prefetching) live entirely in the frontend layer. +- **[Factor 5: State Management](../articles/05-Factor-5.md)** — Every UI framework needs a strategy for local, global, and server state — from React Context to Zustand to TanStack Query. +- **[Factor 7: Rendering](../articles/07-Factor-7.md)** — The rendering strategy (CSR, SSR, SSG, ISR, streaming) is a frontend concern that determines hosting model, Time to First Byte, and user-perceived performance. +- **[Factor 8: Forms](../articles/08-Factor-8.md)** — Form handling spans controlled inputs, validation libraries (Zod, React Hook Form), and submission state management. +- **[Factor 9: i18n](../articles/09-Factor-9.md)** — Internationalization libraries and patterns (ICU messages, runtime vs. compile-time, RTL support) are implemented in the frontend. +- **[Factor 12: A11y/SEO/Perf](../articles/12-Factor-12.md)** — Accessibility trees, meta tags, structured data, and performance budgets are all frontend-layer responsibilities. +- **[Supplemental Factor 3: Micro-Frontends](../articles/15-Supplemental-factor-3.md)** — Module Federation, iframes, and web component integration strategies are frontend architecture patterns. +- **[Supplemental Factor 4: Responsive Design](../articles/16-Supplemental-factor-4.md)** — Responsive layouts, container queries, and adaptive loading are frontend-implementation details. + +## Case Study + +Tikal helped a fintech startup migrate from a jQuery monolith to a modern React component architecture. The legacy application had grown organically over five years: 200,000+ lines of jQuery, inline styles, and hand-written AJAX calls. Every new feature required three weeks on average — one week to understand the existing spaghetti and two weeks to implement without breaking anything. + +**The challenge:** Migrate without stopping development. The company was raising a Series A and needed to ship new compliance features every two weeks while simultaneously modernizing the frontend. + +### Our approach: + +1. **Incremental micro-frontend migration** — We used Webpack Module Federation to isolate the legacy jQuery application and mount new React micro-frontends alongside it. Each new feature was built as a standalone React module, served independently, and integrated via a shared shell. + +2. **Parallel design system construction** — While the first micro-frontends were being built, a separate track extracted the existing visual language into a Chakra UI-based design system with design tokens, a Storybook catalog, and accessibility conformance. This design system became the shared foundation for every subsequent micro-frontend. + +3. **Phased rollout by product area** — We prioritized migration by user impact: the customer dashboard (highest visibility) first, then account settings, then internal admin tools. Each phase took two to three weeks and shipped independently. + +4. **Feature parity gating** — Every migrated feature had automated visual regression tests (Chromatic) and a server-side feature flag so it could be rolled back instantly without a deploy. + +### Results after six months: + +- **Lead time for new features dropped from 3 weeks to 4 days** — the component library eliminated the "understand the spaghetti" phase entirely. +- **Developer onboarding went from 4 weeks to 1 week** — new hires learned the design system and React patterns, not jQuery internals. +- **Performance improved 55%** — route-based code splitting and lazy image loading cut Time to Interactive in half on mobile devices. +- **Accessibility compliance reached WCAG AA** — the previous application had never been audited for a11y. +- **The legacy jQuery codebase was fully decommissioned** — all product areas migrated within eight months, on schedule. + +The key lesson: a micro-frontend migration backed by a shared design system lets you modernize the frontend layer incrementally, without freezing product development. + +## Conclusion + +The frontend layer is where architectural decisions become user-visible experiences. Framework choice, component decomposition, build tooling, and asset optimization determine how fast your application loads, how quickly your team can ship features, and how maintainable the codebase remains at scale. + +For fullstack developers, the frontend layer demands a shift in mindset: from the abundance of server-side resources to the constraints of the browser runtime. Mastering this layer means understanding that every kilobyte shipped, every component boundary drawn, and every render strategy chosen has a direct impact on real users, not just on abstractions. + +Start with a framework that matches your team's strengths and your product's performance requirements. Compose components, don't inherit them. Split code by routes before you need to. Set explicit dimensions on media. Wrap every major UI section in an error boundary. And when you're ready to modernize an existing frontend, reach for incremental migration patterns — not rewrites. + +--- + +_This article is part of Tikal's Modern Full-Stack Developer's Guide: A 12-Factor Approach series. For the application architecture perspective, see the [main 12 factors](../articles/00-Intro.md)._ diff --git a/layers/02-Layer-2-APIs-and-Backend-Logic.md b/layers/02-Layer-2-APIs-and-Backend-Logic.md new file mode 100644 index 0000000..9bb5f76 --- /dev/null +++ b/layers/02-Layer-2-APIs-and-Backend-Logic.md @@ -0,0 +1,412 @@ +# Layer 2: APIs & Backend Logic +![cover](../images/layer2.png) + +## TL;DR + +The APIs and backend logic layer handles data processing, business rules, and client-server communication across every full-stack application. This layer encompasses the API surface (REST, GraphQL, gRPC, WebSockets), server-side business logic, request lifecycle management, input validation, error handling, and the architectural patterns that govern how clients interact with server resources. For fullstack developers, mastering this layer means understanding how API style choices cascade into client complexity, developer experience, backend architecture determines team structure and deployment complexity, and how well-structured backend logic directly affects system reliability, debuggability, and the ability to evolve the system over time without breaking existing clients. + +## Why This Layer Matters + +The API and backend logic layer is where business value is delivered. The frontend may be what users see, but the backend is what users rely on — it processes payments, enforces access control, orchestrates workflows, validates data, and coordinates with external systems. Every decision made in this layer has a direct and measurable impact on the product's capabilities, the team's shipping velocity, and the system's operational stability. + +The choice of API style — REST, GraphQL, gRPC, or WebSockets — is one of the most consequential architectural decisions a team can make. REST remains the most widely understood and interoperable approach: it leverages HTTP semantics, is cacheable by design, and has mature tooling in every language. But REST's fixed response shapes often force clients to either over-fetch (receiving more data than needed) or under-fetch (requiring multiple round-trips to assemble a complete view). GraphQL solves these problems by letting clients declaratively specify exactly what data they need, but it introduces complexity in query parsing, resolver performance, and caching — the server no longer controls response shapes, which makes performance optimization harder. gRPC offers high-performance, strongly-typed, streaming-capable communication ideal for microservice-to-microservice traffic, but it requires protobuf schema management, generates client stubs, and has limited browser support without a proxy. WebSockets enable real-time bidirectional communication for chat, live dashboards, and collaborative editing, but they introduce stateful connection management and different scaling characteristics than stateless HTTP. There is no universal winner — the right choice depends on client requirements, team expertise, and operational constraints. + +Beyond API style, the backend architecture itself — monolithic, microservices, or serverless — determines how teams are structured, how deployment works, and how operational costs scale. A monolith offers simplicity in development, deployment, and debugging: one codebase, one deployment unit, one place to trace a request from HTTP to database. This simplicity is optimal for early-stage products and small teams. Monoliths only become a problem when they grow beyond a team's ability to reason about them. Microservices decompose the system into independently deployable services, enabling team autonomy and independent scaling, but they introduce distributed systems complexity: network failures, partial failures, eventual consistency, service discovery, observability overhead, and the challenge of maintaining data integrity across service boundaries. Serverless functions (AWS Lambda, Cloudflare Workers, Vercel Functions) remove infrastructure management entirely, scaling from zero to thousands of requests per second automatically, but they introduce cold starts, execution time limits, statelessness constraints, and debugging difficulty. The architecture decision should always follow Conway's Law: services will mirror the team structure, not the other way around. + +## Key Considerations for Fullstack Developers + +### 1. API Style Tradeoffs + +Choose your API communication protocol based on the specific needs of your clients and server environment. REST is the safest default — it has broadest interoperability, leverages HTTP caching, and is understood by every developer. GraphQL excels when clients have diverse data needs and you want to minimize round-trips, but requires discipline in resolver performance and query cost analysis. gRPC is ideal for internal microservice-to-microservice communication where performance and type safety matter more than human readability. WebSockets are necessary for real-time features but add connection state management complexity. + +### 2. Monolith vs. Microservices vs. Serverless + +Start with a monolith. The vast majority of applications never reach the scale that requires microservices, and the operational complexity of distributed systems is almost always underestimated. Extract services only when there is a clear boundary — a module that needs independent scaling, a team that needs autonomous deployment, or a subsystem with different performance characteristics. Serverless is compelling for event-driven workloads, bursty traffic patterns, and teams that want to minimize infrastructure management, but it imposes constraints on execution duration, state management, and local development fidelity. + +### 3. Request Lifecycle + +Every API request follows a path from client to database and back: HTTP parsing, routing, middleware chain execution, authentication, authorization, input validation, business logic execution, data access, response serialization, and response transmission. Understanding this lifecycle is critical because bugs can be introduced at any stage. A validation bug at the input layer can allow malicious data to reach the database. An authorization gap in the middleware can expose sensitive data. A serialization issue can leak internal data structures to clients. Fullstack developers should be able to trace a request through every layer of the stack when debugging. + +### 4. Error Handling Patterns + +How your API handles and communicates errors is a user experience concern, not just a debugging concern. Structured error responses — with consistent formats, error codes, human-readable messages, and machine-readable metadata — enable clients to handle failures gracefully. A generic 500 error tells the client nothing useful. A structured response with `{ error: "payment_failed", message: "Insufficient funds", details: { balance: 5.00, required: 29.99 } }` lets the client display a meaningful message to the user and offer actionable next steps. Error handling should be global, not ad-hoc: a centralized error handler catches thrown exceptions, maps them to appropriate HTTP status codes, and formats the response consistently. + +### 5. Input Validation + +The API boundary is the last place you can reject invalid data before it reaches your business logic and database. Input validation should be declarative, schema-driven, and happen as early as possible in the request lifecycle — ideally in dedicated validation middleware before any business logic executes. Validation should cover type checking, format validation, bounds checking, and business rule validation. Reject early, reject clearly, and always sanitize inputs to prevent injection attacks. + +### 6. Idempotency + +In distributed systems, network failures mean you cannot guarantee that a request is processed exactly once — it may be processed zero times or multiple times. Idempotency ensures that processing the same request multiple times produces the same result. This is critical for operations like payment processing, order creation, and any other side-effect-producing operation where duplicates would be harmful. Idempotency is typically implemented using idempotency keys: clients generate a unique key for each operation, and the server deduplicates requests with the same key. The server must persist the idempotency key alongside the result so that retries return the original result, not a new computation. + +### 7. API Versioning + +APIs evolve, and existing clients should not break when you ship changes. Versioning strategies include URI-based versioning (`/v1/resource`, `/v2/resource`), header-based versioning (`Accept: application/vnd.api+json;version=2`), and query-parameter versioning (`?version=2`). URI-based versioning is the most common and easiest to implement, but it can lead to code duplication as versions proliferate. Header-based versioning keeps URLs clean but is less visible and harder to test in browsers. The most pragmatic approach is to avoid breaking changes when possible by following RESTful practices — add fields instead of removing them, make new endpoints instead of changing existing ones, and deprecate gradually with clear migration timelines. + +## Implementation Patterns & Technologies + +### Layered Architecture: Controller / Service / Repository + +The layered architecture pattern separates concerns into distinct layers, each with a single responsibility. Controllers handle HTTP concerns (parsing requests, sending responses). Services contain business logic. Repositories abstract data access. This separation makes the code testable, maintainable, and navigable. + +```typescript +// controllers/userController.ts — HTTP layer only +import { Request, Response, NextFunction } from 'express'; +import { userService } from '../services/userService'; + +export async function createUser( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const validatedBody = req.validatedBody; // set by validation middleware + const user = await userService.createUser(validatedBody); + res.status(201).json({ data: user }); + } catch (error) { + next(error); // delegate to error-handling middleware + } +} + +export async function getUserById( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const user = await userService.getUserById(req.params.id); + if (!user) { + res.status(404).json({ error: 'user_not_found', message: 'User not found' }); + return; + } + res.json({ data: user }); + } catch (error) { + next(error); + } +} + +// services/userService.ts — business logic, no HTTP awareness +import { userRepository } from '../repositories/userRepository'; +import { hashPassword } from '../lib/crypto'; + +interface CreateUserInput { + email: string; + password: string; + name: string; +} + +export const userService = { + async createUser(input: CreateUserInput) { + const existing = await userRepository.findByEmail(input.email); + if (existing) { + throw new ConflictError('A user with this email already exists'); + } + const passwordHash = await hashPassword(input.password); + return userRepository.create({ + email: input.email, + passwordHash, + name: input.name, + }); + }, + + async getUserById(id: string) { + return userRepository.findById(id); + }, +}; + +// repositories/userRepository.ts — data access, no business logic +import { db } from '../lib/database'; +import type { User } from '../types'; + +export const userRepository = { + async findByEmail(email: string): Promise { + return db.queryOne('SELECT * FROM users WHERE email = $1', [email]); + }, + + async findById(id: string): Promise { + return db.queryOne('SELECT * FROM users WHERE id = $1', [id]); + }, + + async create(data: Partial): Promise { + const { rows } = await db.query( + `INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING *`, + [data.email, data.passwordHash, data.name] + ); + return rows[0]; + }, +}; +``` + +### Middleware Chain Pattern + +Middleware is the backbone of request processing in most web frameworks. Each middleware function receives the request, performs a specific task (logging, authentication, validation), and either passes control to the next middleware or short-circuits the chain by sending a response. The order of middleware matters: security middleware should run first, then authentication, then authorization, then validation, then business logic. + +```python +# middleware/validation.py — schema-driven request validation +from functools import wraps +from pydantic import BaseModel, ValidationError +from flask import request, jsonify + +def validate_body(schema: type[BaseModel]): + def decorator(route_func): + @wraps(route_func) + def wrapper(*args, **kwargs): + try: + validated = schema(**request.get_json(force=True)) + request.validated_body = validated + return route_func(*args, **kwargs) + except ValidationError as e: + return jsonify({ + "error": "validation_failed", + "message": "Request body failed validation", + "details": e.errors(include_context=False), + }), 422 + return wrapper + return decorator + +# schemas/user.py — Pydantic schemas for declarative validation +from pydantic import BaseModel, EmailStr, Field + +class CreateUserSchema(BaseModel): + email: EmailStr + password: str = Field(min_length=8, max_length=128) + name: str = Field(min_length=1, max_length=100) + +# routes/users.py — applied at the route level +from flask import Blueprint +from middleware.validation import validate_body +from schemas.user import CreateUserSchema + +router = Blueprint('users', __name__) + +@router.route('/users', methods=['POST']) +@validate_body(CreateUserSchema) +def create_user(): + data = request.validated_body + # data is already validated and typed as CreateUserSchema + user = user_service.create_user(data) + return {"data": user}, 201 +``` + +### Error Handling Middleware + +A centralized error handler ensures consistent error responses across every endpoint: + +```typescript +// middleware/errorHandler.ts — Express global error handler +import { Request, Response, NextFunction } from 'express'; + +class AppError extends Error { + constructor( + public statusCode: number, + public code: string, + public message: string, + public details?: unknown + ) { + super(message); + this.name = 'AppError'; + } +} + +class NotFoundError extends AppError { + constructor(resource: string) { + super(404, 'not_found', `${resource} not found`); + } +} + +class ConflictError extends AppError { + constructor(message: string) { + super(409, 'conflict', message); + } +} + +function errorHandler( + err: Error, + _req: Request, + res: Response, + _next: NextFunction +): void { + if (err instanceof AppError) { + res.status(err.statusCode).json({ + error: err.code, + message: err.message, + details: err.details, + }); + return; + } + + // Unexpected errors — log and return generic 500 + console.error('Unhandled error:', err); + res.status(500).json({ + error: 'internal_server_error', + message: 'An unexpected error occurred', + }); +} + +export { AppError, NotFoundError, ConflictError, errorHandler }; +``` + +### API Versioning Strategies + +Versioning is about managing change without breaking clients: + +```typescript +// routes/v1/users.ts +import { Router } from 'express'; +export const router = Router(); + +router.get('/', async (req, res) => { + const users = await userService.listAll(); + res.json({ data: users }); +}); + +// routes/v2/users.ts — v2 adds pagination support +import { Router } from 'express'; +export const router = Router(); + +router.get('/', async (req, res) => { + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const { users, total } = await userService.listPaginated(page, limit); + res.json({ + data: users, + meta: { page, limit, total, totalPages: Math.ceil(total / limit) }, + }); +}); + +// app.ts — mount both versions +import express from 'express'; +import { router as v1Routes } from './routes/v1/users'; +import { router as v2Routes } from './routes/v2/users'; + +const app = express(); +app.use('/api/v1/users', v1Routes); +app.use('/api/v2/users', v2Routes); +``` + +### Idempotency Implementation + +Idempotency keys prevent duplicate side effects in distributed systems: + +```typescript +// middleware/idempotency.ts +import { Request, Response, NextFunction } from 'express'; +import { redis } from '../lib/redis'; + +export async function idempotencyMiddleware( + req: Request, + res: Response, + next: NextFunction +): Promise { + const idempotencyKey = req.headers['idempotency-key'] as string; + + if (!idempotencyKey) { + // Idempotency is optional for GET requests, required for mutating ones + if (req.method !== 'GET' && req.method !== 'OPTIONS') { + res.status(400).json({ + error: 'missing_idempotency_key', + message: 'Idempotency-Key header is required for this request', + }); + return; + } + return next(); + } + + const cacheKey = `idempotency:${idempotencyKey}`; + const existing = await redis.get(cacheKey); + + if (existing) { + const previousResponse = JSON.parse(existing); + res.status(previousResponse.status).json(previousResponse.body); + return; + } + + // Override res.json to cache the response before sending + const originalJson = res.json.bind(res); + res.json = function (body: unknown) { + const responseData = { status: res.statusCode, body }; + redis.setex(cacheKey, 86_400, JSON.stringify(responseData)); // TTL: 24 hours + return originalJson(body); + }; + + next(); +} +``` + +## Common Pitfalls + +### 1. Over-Engineering Before Product-Market Fit + +Building a microservices architecture, complete with service discovery, message queues, and distributed tracing, before you have ten paying customers is premature optimization. The operational overhead of distributed systems — deployment pipelines, inter-service communication, data consistency, observability — slows development velocity at exactly the stage when speed matters most. Start with a well-structured monolith. Extract services when there is evidence that the monolith is constraining team velocity or scalability, not before. + +### 2. Ignoring Idempotency in Distributed Systems + +In any system where requests can fail and be retried — which is every distributed system — idempotency is not optional. Without idempotency, a network timeout that causes a client to retry a payment request can result in double charges. An order creation retry can produce duplicate orders. A user registration retry can create duplicate accounts. The fix is straightforward: require idempotency keys on all mutating endpoints, deduplicate on the server side, and persist the response so retries return the cached result. This pattern should be part of the API framework, not an afterthought in individual endpoints. + +### 3. Throwing Generic 500 Errors Instead of Structured Responses + +A generic `500 Internal Server Error` response tells the client nothing useful. Was the database down? Was there a validation bug? Did a downstream service fail? The client cannot possibly handle this gracefully. Structured error responses — with machine-readable error codes, human-readable messages, and optional details — allow clients to display appropriate UI states, trigger retry logic, or log meaningful diagnostics. Every error should map to a code that the client can reason about, and the error format should be consistent across every endpoint in the API. + +### 4. Mixing Business Logic Into the Controller Layer + +Controllers should handle HTTP concerns only: parse the request, delegate to a service, and send a response. When business logic leaks into controllers — validation rules, authorization checks, data transformations — that logic becomes untestable (you need HTTP calls to exercise it), non-reusable (you cannot call it from other controllers or background jobs), and difficult to maintain (every endpoint implements its own version of the same rule). Enforce the separation with a clear architectural boundary: controllers depend on services, services depend on repositories, and no layer reaches across more than one boundary. + +### 5. Not Validating Input at the API Boundary + +Input validation is the first and most important defense against bugs and security vulnerabilities. Every API endpoint should validate that incoming data matches expected types, formats, and constraints before any business logic executes. Missing validation allows malformed data to propagate into the database, corrupting data integrity. Missing validation also opens the door to injection attacks, mass assignment vulnerabilities, and business logic bypasses. Use a schema-based validation library (Zod, Pydantic, Joi) that runs as early middleware in the request lifecycle, before any handler logic executes. Validate once, reject early, and never trust client input. + +### 6. Ignoring API Versioning Until It's Too Late + +Every API eventually needs to change in ways that are not backward-compatible. Teams that do not plan for versioning from day one face an impossible choice when the need arises: break existing clients or never change the API. Neither option is acceptable at scale. Adopt a versioning strategy — URI-based or header-based — from the first endpoint. The overhead is minimal (a `/v1/` prefix in the URL), and the benefit is the freedom to evolve the API without coordinating with every client. Deprecate old versions with clear timelines and migration guides. + +## How This Layer Connects to the 12 Factors + +- **[Factor 10: Backend-for-Frontend (BFF)](../articles/10-Factor-10.md)** — The BFF pattern is an API-layer concern that creates specialized backend services tailored to specific client needs, reducing over-fetching and under-fetching while encapsulating client-specific logic. +- **[Factor 11: API Communication Patterns](../articles/11-Factor-11.md)** — The choice of REST, GraphQL, gRPC, or WebSockets defines how every layer above and below the API communicates, directly impacting performance, client complexity, and developer experience. +- **[Factor 1: UI Libraries](../articles/01-Factor-1.md)** — Frontend frameworks consume APIs; the API design directly shapes how frontend components fetch, cache, and display data. +- **[Factor 3: Design Systems](../articles/03-Factor-3.md)** — Backend API contracts and error formats should align with frontend design system patterns for consistent error states and loading UX. +- **[Factor 5: State Management](../articles/05-Factor-5.md)** — Server state management libraries like TanStack Query, Apollo Client, and SWR are tightly coupled to the API style — REST clients use query keys and mutations, GraphQL clients use fragments and subscriptions. +- **[Factor 6: Authentication & Authorization](../articles/06-Factor-6.md)** — Auth middleware is the outermost layer of the backend logic, intercepting every request before business logic executes to verify identity and enforce permissions. +- **[Factor 7: Rendering Strategies](../articles/07-Factor-7.md)** — SSR and ISR strategies rely on the API layer to provide data at request time or build time, and the API must be designed to support both synchronous and cached data delivery. +- **[Factor 9: i18n](../articles/09-Factor-9.md)** — Internationalization can be handled at the API layer by detecting client locale and returning localized error messages and content. +- **[Factor 12: A11y/SEO/Perf](../articles/12-Factor-12.md)** — API response times directly affect Core Web Vitals; slow endpoints become slow pages regardless of frontend optimization. +- **[Supplemental Factor 1: Testing](../articles/13-Supplemental-factor-1.md)** — The layered architecture (controller/service/repository) makes the backend testable at every level: integration tests for endpoints, unit tests for services, and contract tests for API boundaries. + +## Case Study + +Tikal helped a logistics company migrate from a monolithic Rails API to a GraphQL + microservices architecture. The company operated a fleet of 10,000+ IoT-equipped delivery vehicles, each streaming GPS coordinates, engine diagnostics, and delivery status in real time. The original Rails monolith had served the company well through its early years, but as the fleet grew from 500 to 10,000 vehicles, the monolith began to buckle under the load. + +**The challenge:** The Rails API was handling everything — REST endpoints for the web dashboard, REST endpoints for the mobile driver app, WebSocket connections for real-time tracking, background job processing for route optimization, and webhook delivery to customer systems. A single database migration could take down the entire API. A CPU spike from route optimization would delay GPS processing, causing the tracking map to lag by 30 seconds or more. The mobile app was consuming 200MB of data per driver per shift because the REST endpoints returned full vehicle objects when the mobile UI only needed a subset of fields. + +### Our approach: + +1. **API layer split with GraphQL as the public surface** — We introduced a GraphQL gateway that replaced the REST endpoints for the web dashboard and mobile apps. The GraphQL schema was designed around the client use cases: the mobile driver app had a `DriverSession` type with only the fields needed for the driver's current trip, while the web dashboard had richer `Vehicle` and `Delivery` types with historical data. This eliminated over-fetching entirely. Mobile data usage dropped from 200MB to 80MB per shift on day one. + +2. **gRPC for inter-service communication** — Behind the GraphQL gateway, we decomposed the monolith into domain services — Vehicle Service, Route Service, Delivery Service, and Customer Service — communicating over gRPC. Each service was independently deployable and owned its own data store. The strongly-typed protobuf contracts prevented the serialization mismatches that had plagued the Rails JSON serializers. gRPC streaming was used for the GPS telemetry pipeline, reducing the overhead of thousands of individual HTTP requests per second. + +3. **GraphQL subscriptions for real-time tracking** — The WebSocket-based real-time tracking was migrated to GraphQL subscriptions, which provided the same real-time capability but with GraphQL's declarative data selection. The mobile app subscribed to `vehiclePosition(vehicleId: ID!)` and received only the coordinates and timestamp, not the full vehicle object. This cut WebSocket message sizes by 90%. + +4. **BFF pattern for mobile clients** — A dedicated Backend-for-Frontend service was created for the mobile apps. This BFF aggregated data from multiple gRPC services and exposed a mobile-optimized GraphQL schema that accounted for the device's network conditions, battery level, and screen size. When the BFF detected a slow connection, it would batch subscription updates into 5-second intervals instead of sending every GPS ping individually. + +5. **Incremental migration with the strangler fig pattern** — We did not rewrite the monolith. Instead, we deployed the GraphQL gateway in front of the existing Rails API and migrated endpoints one by one. Each migration was behind a feature flag and could be rolled back instantly. Over six months, the Rails API went from serving 100% of traffic to serving only legacy customer webhooks, which were the last to be migrated. + +### Results: +- **API response times reduced by 60%** — the GraphQL gateway resolved complex queries with a single round-trip to the gRPC services, compared to the previous N+1 REST calls. +- **Mobile data usage decreased by 60%** — from 200MB to 80MB per driver shift, improving app performance and customer satisfaction. +- **Real-time tracking latency dropped from 30 seconds to under 2 seconds** — the gRPC telemetry pipeline and GraphQL subscriptions eliminated the queuing delays caused by the overloaded monolith. +- **Deploy frequency went from weekly to multiple times per day** — each microservice could be deployed independently without risking the entire system. +- **The legacy Rails monolith was fully decommissioned within eight months** — every domain was migrated incrementally, with zero downtime during cutovers. + +The key lesson: the API layer is where you can make the most impactful changes with the least risk. By introducing a GraphQL gateway and the strangler fig pattern, the team improved the user experience immediately while decoupling the backend architecture incrementally. They did not need a big-bang rewrite — they evolved the architecture one endpoint at a time. + +## Conclusion + +The APIs and backend logic layer is the backbone of every full-stack application. It is where business rules are enforced, data is validated and transformed, and clients communicate with servers. The decisions made in this layer — API style, architectural pattern, error handling strategy, validation approach — determine the developer experience for every team member and the product experience for every user. + +Start with REST and a well-structured monolith. Use the controller/service/repository pattern to keep concerns separated and testable. Validate input at the boundary with schema-based validators. Handle errors with a centralized middleware that produces structured, client-actionable responses. Plan for idempotency from day one. Adopt a versioning strategy before you need it. And when your architecture needs to evolve, reach for incremental patterns — the strangler fig, the gateway, the BFF — not rewrites. + +The best API is the one that clients can depend on, that developers can debug, and that the team can evolve without breaking existing consumers. That outcome is not an accident — it is the result of deliberate architectural choices made with an understanding of the tradeoffs involved. + +--- + +_This article is part of Tikal's Modern Full-Stack Developer's Guide: A 12-Factor Approach series. For the application architecture perspective, see the [main 12 factors](../articles/00-Intro.md)._ diff --git a/layers/03-Layer-3-Database-and-Storage.md b/layers/03-Layer-3-Database-and-Storage.md new file mode 100644 index 0000000..06ec5e2 --- /dev/null +++ b/layers/03-Layer-3-Database-and-Storage.md @@ -0,0 +1,491 @@ +# Layer 3: Database & Storage +![cover](../images/layer3.png) + +## TL;DR + +The database and storage layer handles all persistent data in a full-stack application — relational databases for structured transactions, NoSQL stores for flexible schemas and high-velocity writes, object storage for blobs and media, and CDNs for global content distribution. For fullstack developers, mastering this layer means knowing when to choose SQL over NoSQL, how to model data for both read and write performance, how to migrate schemas without downtime, how to tune queries and connection pools under load, and how to integrate object storage and CDNs into the application architecture. This layer directly determines the system's data integrity, query latency, operational cost, and ability to evolve the data model as the product grows. + +## Why This Layer Matters + +The database and storage layer is the durable foundation every other layer depends on. A slow frontend can be optimized with caching. A slow API can be optimized with pagination and query tuning. But data that is lost, inconsistent, or inaccessible is unrecoverable — it erodes user trust and can have legal and financial consequences. Every architectural decision in this layer — database engine, schema design, indexing strategy, migration approach, storage backend — has direct and measurable effects on application performance, development velocity, and operational reliability. + +The diversity of storage options available today is both a superpower and a source of complexity. A single application may use PostgreSQL for transactional data, Redis for caching and session state, Elasticsearch for full-text search, S3 for file uploads, and CloudFront or Cloudflare for CDN delivery. Each storage technology has different performance characteristics, consistency models, scaling properties, and cost profiles. Choosing the right tool for each job — polyglot persistence — is the hallmark of a mature full-stack architecture. + +But polyglot persistence introduces its own challenges. When data lives in multiple stores, maintaining consistency across them becomes non-trivial. A user's profile might be in PostgreSQL, their session token in Redis, their uploaded avatar in S3, and the CDN edge cache may hold a stale version of that avatar. Understanding how data flows between these stores, how to keep them synchronized, and how to handle failures gracefully is critical. + +The data model itself — the shape of your tables, documents, and indexes — evolves as the product evolves. A schema that served 100 users efficiently may degrade catastrophically at 100,000 users. Migrating from one schema to another, or from one database engine to another, without downtime requires careful planning, well-tested migration scripts, and a rollback strategy. Fullstack developers who understand data modeling, migration patterns, and query optimization can build systems that scale without requiring a rewrite at every growth milestone. + +## Key Considerations for Fullstack Developers + +### 1. SQL vs. NoSQL: A Decision Framework + +The choice between relational and non-relational databases is not about which is "better" — it is about which set of trade-offs aligns with your data's access patterns, consistency requirements, and team expertise. + +**Choose SQL (PostgreSQL, MySQL) when:** +- Data has clear relationships and referential integrity matters (orders → line items → products) +- You need ACID transactions — atomic updates across multiple rows and tables +- Schema enforcement is valuable for data quality and team coordination +- You need complex queries with joins, aggregations, window functions, and CTEs +- Your query patterns are not fully known in advance and need ad-hoc exploration + +**Choose NoSQL (MongoDB, DynamoDB, Cassandra) when:** +- Your data model is schema-flexible or evolves rapidly without migrations +- You need horizontal write scalability with automatic partitioning +- Your access patterns are known and query-by-primary-key dominant +- You need high-velocity writes at massive scale (time-series, IoT, event logs) +- Your data is naturally document-shaped (JSON blobs, denormalized aggregates) + +**Use the pragmatic hybrid** when your application has diverse data needs. A common pattern is PostgreSQL for transactional/relational data with a document store (like MongoDB) or a search engine (like Elasticsearch) for specific workloads that don't fit the relational model well. This avoids the operational complexity of running a dozen databases while still using the right tool for specific jobs. + +### 2. Object Storage and CDN Integration + +Relational databases are not designed to store large binary objects or serve them at global scale. Storing images, videos, PDFs, or archives as BLOBs in PostgreSQL or MySQL leads to table bloat, slow backups, and poor read performance. The correct architecture is: + +- **Upload to object storage** (S3, GCS, Azure Blob, R2) — durable, cheap, infinitely scalable +- **Store only the object key/URL in the database** — reference, not the data itself +- **Serve through a CDN** (CloudFront, Cloudflare, Fastly) — edge-cached, globally distributed +- **Use signed URLs** for private content — time-limited access without exposing credentials + +```typescript +// services/storageService.ts — Upload and serve files through S3 + CDN +import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; + +const s3 = new S3Client({ region: process.env.AWS_REGION }); +const BUCKET = process.env.S3_BUCKET_NAME; +const CDN_DOMAIN = process.env.CDN_DOMAIN; // e.g., d2x3y4z5.cloudfront.net + +interface UploadResult { + objectKey: string; + cdnUrl: string; +} + +export async function uploadFile( + fileBuffer: Buffer, + fileName: string, + mimeType: string, + userId: string +): Promise { + const objectKey = `users/${userId}/${Date.now()}-${fileName}`; + + await s3.send(new PutObjectCommand({ + Bucket: BUCKET, + Key: objectKey, + Body: fileBuffer, + ContentType: mimeType, + })); + + return { + objectKey, + cdnUrl: `https://${CDN_DOMAIN}/${objectKey}`, + }; +} + +export async function getPrivateDownloadUrl( + objectKey: string, + expiresInSeconds = 300 +): Promise { + const command = new GetObjectCommand({ + Bucket: BUCKET, + Key: objectKey, + }); + + return getSignedUrl(s3, command, { expiresIn: expiresInSeconds }); +} + +// repositories/userRepository.ts — store only the reference +import { db } from '../lib/database'; + +export const userRepository = { + async updateAvatar(userId: string, avatarKey: string): Promise { + await db.query( + 'UPDATE users SET avatar_url = $1, updated_at = NOW() WHERE id = $2', + [avatarKey, userId] + ); + }, +}; +``` + +The CDN caches the file at edge locations worldwide. When a user requests `https://d2x3y4z5.cloudfront.net/users/abc/avatar.jpg`, the CDN serves it from the nearest edge if cached, or fetches from the S3 origin and caches it for subsequent requests. This pattern reduces origin load by 90-95% for media-heavy applications and delivers sub-50ms load times globally. + +### 3. Data Modeling: Normalization, Denormalization, and Indexing + +**Normalization** eliminates data redundancy by splitting data into related tables with foreign keys. A normalized schema (3NF) ensures every piece of data lives in exactly one place, preventing update anomalies and maintaining referential integrity. Normalization is the default starting point for transactional systems. + +**Denormalization** intentionally duplicates data to avoid expensive joins at read time. In a denormalized e-commerce schema, the `orders` table might store `product_name` and `product_price` directly on each order line rather than joining through a `products` table. This makes writes more expensive (you must update duplicate data when prices change) but reads dramatically faster. Denormalization is a performance optimization, not a design default. + +**Indexing** is the single highest-impact performance lever in any database. An index is a data structure (typically a B-tree) that allows the database to find rows without scanning the entire table. The key indexing principles are: + +- Index columns used in `WHERE`, `JOIN`, `ORDER BY`, and `GROUP BY` clauses +- Use composite indexes for queries that filter on multiple columns (order matters — put equality filters before range filters) +- Avoid over-indexing — each index slows down writes and consumes storage +- Use partial indexes (`WHERE status = 'active'`) for filtered queries on large tables +- Monitor index usage with `pg_stat_user_indexes` (PostgreSQL) or equivalent tools + +```sql +-- users table: support fast lookups by email (login) and by organization +CREATE INDEX idx_users_email ON users (email); +CREATE INDEX idx_users_org_id ON users (org_id); + +-- orders table: support date-range queries and status filtering +CREATE INDEX idx_orders_created_at ON orders (created_at DESC); +CREATE INDEX idx_orders_user_status ON orders (user_id, status) + WHERE status IN ('pending', 'processing'); + +-- products table: full-text search index +CREATE INDEX idx_products_search ON products + USING GIN (to_tsvector('english', name || ' ' || description)); + +-- avoid: index on low-cardinality column alone +-- CREATE INDEX idx_users_active ON users (is_active); -- only true/false, not useful +``` + +### 4. Migration Strategies + +Schema migrations are how the database evolves alongside the application. The standard approach is incremental, versioned migration files that are applied in order — never ad-hoc schema changes in production consoles. + +**The golden rule of safe migrations:** Every migration must be backward-compatible with the current application code. This means: + +0. **Add columns and tables** — never remove or rename them in a single deployment. Add a new column, deploy the application code that reads/writes it, then remove the old column in a subsequent migration. +0. **Use nullable defaults for new columns** — existing rows should not break because a new column has no value. Provide a default or allow NULL during the transition period. +0. **Migrate data in batches** — for large tables, backfill new columns in batches of 1,000–10,000 rows to avoid long-running locks. +0. **Always have a rollback** — every migration should have a corresponding down migration that reverts the schema and restores any transformed data. + +```typescript +// migrations/003_add_user_timezone.ts — forward migration +import { db } from '../lib/database'; + +export async function up(): Promise { + // Step 1: Add the new column as nullable + await db.query(` + ALTER TABLE users + ADD COLUMN timezone VARCHAR(50) DEFAULT 'UTC'; + `); + + // Step 2: Backfill existing rows in batches (for large tables) + let updated = 0; + const BATCH_SIZE = 1000; + do { + const result = await db.query(` + UPDATE users + SET timezone = 'UTC' + WHERE timezone IS NULL + LIMIT ${BATCH_SIZE} + RETURNING id; + `); + updated = result.rowCount ?? 0; + } while (updated === BATCH_SIZE); + + // Step 3: Make the column NOT NULL now that all rows have values + await db.query(` + ALTER TABLE users + ALTER COLUMN timezone SET NOT NULL; + `); +} + +export async function down(): Promise { + await db.query('ALTER TABLE users DROP COLUMN timezone;'); +} +``` + +### 5. Connection Pooling + +Every database connection consumes server resources — memory for the connection buffer, a file descriptor, and a backend process or thread. Opening a new connection per request does not scale. Connection pooling maintains a fixed set of open connections that are borrowed and returned by application code, eliminating the overhead of connection establishment. + +Key configuration parameters for a connection pool: + +- **Min / Max connections** — set min to handle baseline traffic and max to the database's connection limit minus a safety margin for administrative connections +- **Idle timeout** — close connections that have been idle too long to free server resources +- **Connection timeout** — how long a request waits for a connection before failing (return a 503, don't block indefinitely) + +```typescript +// lib/database.ts — PostgreSQL connection pool with pg-pool +import { Pool, PoolConfig } from 'pg'; + +const poolConfig: PoolConfig = { + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT || '5432'), + database: process.env.DB_NAME, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + min: parseInt(process.env.DB_POOL_MIN || '2'), + max: parseInt(process.env.DB_POOL_MAX || '20'), + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 5_000, + maxUses: 7500, // recycle connections periodically to avoid memory leaks +}; + +export const pool = new Pool(poolConfig); + +pool.on('error', (err) => { + console.error('Unexpected database pool error:', err); +}); + +export const db = { + async query(text: string, params?: any[]): Promise { + const client = await pool.connect(); + try { + const result = await client.query(text, params); + return result.rows as T[]; + } finally { + client.release(); // return to pool, don't destroy + } + }, + + async transaction(fn: (client: any) => Promise): Promise { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const result = await fn(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + }, +}; +``` + +### 6. Query Optimization + +Slow queries are the most common cause of production incidents in database-backed applications. The optimization workflow is: + +1. **Identify slow queries** — use `pg_stat_activity` (PostgreSQL), `SHOW FULL PROCESSLIST` (MySQL), or APM tools (Datadog, New Relic, Scout) +2. **Run `EXPLAIN ANALYZE`** — understand the query plan: sequential scans, index usage, join strategies, sort operations +3. **Fix the most expensive operation** — add an index, rewrite the query, denormalize, or add a cache + +Common query optimization patterns: + +- **N+1 queries** — the ORM fetches a list of entities, then fetches related entities one-by-one in a loop. Fix with eager loading (`INCLUDES` in Rails, `SELECT ... IN (...)` for manual queries) +- **Missing composite indexes** — a query filtering on `(org_id, status, created_at)` cannot use separate single-column indexes efficiently; a composite index on `(org_id, status, created_at DESC)` turns it into a single index seek +- **Index-only scans** — if the index contains all columns the query needs, PostgreSQL never touches the table heap. Add covering indexes with `INCLUDE` columns +- **Avoid `SELECT *` in production queries** — returning unused columns wastes memory, network bandwidth, and prevents index-only scans + +## Implementation Patterns & Technologies + +### SQL vs. NoSQL Decision Matrix + +| Criterion | SQL (PostgreSQL, MySQL) | NoSQL (MongoDB, DynamoDB) | +|-----------|------------------------|---------------------------| +| Consistency model | Strong (ACID) | Eventual or configurable | +| Schema | Fixed, enforced | Flexible, per-document | +| Query capability | Rich (joins, CTEs, windows) | Primary-key-centric | +| Horizontal scaling | Read replicas, sharding complexity | Built-in partitioning | +| Migration tooling | Mature (Prisma, Drizzle, Flyway) | App-level, manual | +| Transaction scope | Multi-row, multi-table | Single-document unless using DynamoDB transactions | + +### Object Storage + CDN Architecture + +``` +User ──> Upload file ──> App Server ──> S3 (origin) + │ + └──> DB: store object key + +User ──> GET /files/abc.jpg + │ + ▼ + CDN edge ──cached?──> Return from edge cache (sub-50ms) + │ + └──miss──> S3 origin ──> Cache at edge ──> Return +``` + +### Migration Patterns + +- **Expand-Migrate-Contract** — expand the schema to support both old and new formats, run a background migration to populate the new format, then contract by removing the old format +- **Online schema changes** — tools like `pgroll` (PostgreSQL) and `gh-ost` (MySQL) apply schema changes without locking tables +- **Blue-green migrations** — run two database schemas side by side, point new application version at the new schema, and cut over when validation passes +- **Feature-flagged migrations** — gate new schema access behind a feature flag so you can test the new code path on a subset of traffic before full rollout + +### Read Replicas for Read Scaling + +Offload read queries from the primary database to read replicas to improve throughput: + +```python +# lib/database_router.py — Route queries to primary or replica +import os +import psycopg2 +from psycopg2 import pool as pg_pool +from contextlib import contextmanager + +class DatabaseRouter: + def __init__(self): + self.primary_pool = pg_pool.ThreadedConnectionPool( + 2, 20, + dsn=os.environ["DATABASE_PRIMARY_URL"], + ) + self.replica_pool = pg_pool.ThreadedConnectionPool( + 2, 40, + dsn=os.environ["DATABASE_REPLICA_URL"], + ) + + @contextmanager + def connection(self, read_only: bool = False): + pool = self.replica_pool if read_only else self.primary_pool + conn = pool.getconn() + try: + yield conn + finally: + pool.putconn(conn) + + def execute_read(self, query: str, params: tuple = ()) -> list: + with self.connection(read_only=True) as conn: + with conn.cursor() as cur: + cur.execute(query, params) + return cur.fetchall() + + def execute_write(self, query: str, params: tuple = ()) -> None: + with self.connection(read_only=False) as conn: + with conn.cursor() as cur: + cur.execute(query, params) + conn.commit() + +router = DatabaseRouter() + +# Usage: reads go to replica, writes go to primary +users = router.execute_read("SELECT * FROM users WHERE org_id = %s", (org_id,)) +router.execute_write( + "INSERT INTO users (name, email) VALUES (%s, %s)", + ("Alice", "alice@example.com"), +) +``` + +## Common Pitfalls + +### 1. Using the Database as a Message Queue + +Polling database tables for work items — `SELECT * FROM jobs WHERE status = 'pending'` — creates contention on the jobs table, wastes IOPS on repeated queries, and introduces unnecessary load on the primary database. Use a dedicated message queue (RabbitMQ, SQS, Redis streams) for asynchronous work distribution. Databases are for storing state, not coordinating work. + +### 2. Storing Binary Data in the Database + +Storing images, PDFs, or large JSON blobs as columns in PostgreSQL or MySQL causes table bloat and slow query performance. BLOBs inflate the table size, make vacuum/optimize operations take longer, and slow down full-table scans. Object storage (S3, GCS, R2) is orders of magnitude cheaper per GB and designed specifically for blob storage. Store only the object key in the database. + +### 3. Missing Indexes on Foreign Keys + +If `orders.user_id` references `users.id` but has no index, every query that joins or filters on `orders.user_id` performs a sequential scan of the entire orders table. This works at 1,000 rows and catastrophically fails at 100,000 rows. Index every foreign key column — most ORMs do not do this automatically. + +### 4. Schema Migrations Without a Rollback Plan + +Running a destructive migration (`DROP COLUMN`, `ALTER COLUMN TYPE` that truncates data) without a rollback plan means the only way to recover from a mistake is a point-in-time database restore, which loses all data written after the last backup. Every migration should have a corresponding down migration that can be applied to revert the schema without data loss. + +### 5. Over-Indexing + +Adding indexes to every column that appears in a query — or creating indexes "in case they're needed" — slows down every write operation (INSERT, UPDATE, DELETE) because each index must be updated. A table with 10 indexes can be 5-10x slower for writes than the same table with 2 well-chosen indexes. Measure query patterns, then create indexes. Remove unused indexes. PostgreSQL's `pg_stat_user_indexes` shows how often each index is used. + +### 6. Ignoring Connection Pool Starvation + +If the connection pool is too small, requests queue up waiting for a connection and eventually time out. If it is too large, the database runs out of memory or CPU handling concurrent connections. Monitor `pool.waitingCount` and `database.activeConnections` in production. Set the pool max to leave headroom for administrative connections — never use all available database connections for the application. + +### 7. Not Planning for Polyglot Consistency + +When you introduce a second storage system — caching in Redis, search in Elasticsearch, files in S3 — the data in these systems can become inconsistent with the primary database. A product image uploaded to S3 may not have its URL saved to the database if the database write fails after the S3 upload succeeds. Implement the outbox pattern or transactional outbox to ensure cross-store consistency: write the intent to an outbox table within the same database transaction, and a separate process reads the outbox and publishes updates to the secondary stores. + +## How This Layer Connects to the 12 Factors + +- **[Factor 6: Authentication & Authorization](../articles/06-Factor-6.md)** — User credentials, session tokens, and permission policies are stored in the database layer. Token blacklisting, refresh token persistence, and RBAC/ABAC policy evaluation all depend on fast, reliable database access. The auth layer is the most security-critical consumer of the database. +- **[Factor 11: API Communication Patterns](../articles/11-Factor-11.md)** — The API layer defines how data is queried and mutated. REST endpoints typically map to database queries, GraphQL resolvers fetch from database tables or joined views, and gRPC services aggregate across multiple storage backends. The API communication pattern determines whether the database is queried efficiently or suffers from N+1 problems and over-fetching. +- **[Factor 7: Rendering Strategies](../articles/07-Factor-7.md)** — SSR and ISR rely on the database layer to provide fresh data at request time or build time. Database query latency directly affects Time to First Byte for server-rendered pages. +- **[Factor 5: State Management](../articles/05-Factor-5.md)** — Server state (TanStack Query, SWR, Apollo Client) is a cache of the database. Cache invalidation strategies, stale-while-revalidate patterns, and optimistic updates all assume a consistent database source of truth. +- **[Supplemental Factor 2: Observability](../articles/14-Supplemental-factor-2.md)** — Database query performance, connection pool metrics, and slow query logs are essential observability signals. Every production incident involving slow page loads or API timeouts should start with database query analysis. + +## Case Study + +Tikal helped an e-commerce platform migrate from a monolithic PostgreSQL database to a polyglot persistence architecture. The platform served 2 million monthly active users across 15 countries, with 50,000+ products, real-time inventory updates, and a global customer base. The original architecture used a single PostgreSQL database for everything — users, orders, inventory, product catalog, session data, and product images stored as base64-encoded strings in JSON columns. + +**The challenge:** The monolithic PostgreSQL database was approaching its limits. Order queries that took 50ms at 10,000 orders per day now took 800ms at 50,000 orders per day because the orders table had grown to 12 million rows with no partitioning. Product search — a full-text query across 50,000 products — could take 3-5 seconds because PostgreSQL's built-in text search lacked faceting, relevance scoring, and typo-tolerance. Session storage — 500K simultaneous sessions written every 5 minutes — caused write contention on the session table. Product images stored as base64 in JSON columns made the products table 15GB, causing backups to take 4+ hours and pg_restore operations to fail due to memory limits. + +**Our approach:** + +1. **PostgreSQL for orders and inventory** — We kept PostgreSQL as the source of truth for orders, inventory, and user accounts — the relational core where ACID transactions and referential integrity are non-negotiable. We partitioned the orders table by month (range partitioning on `created_at`), reducing query times by 80%. We added a connection pooler (PgBouncer) in transaction mode to handle 1,000+ concurrent connections without overwhelming the database. + +2. **DynamoDB for session and cart data** — Session data and shopping carts are high-velocity, write-heavy workloads with simple access patterns (get/put by session ID or user ID). We migrated this to DynamoDB with on-demand capacity mode. Session reads went from 12ms to 3ms p99. Cart merges — combining a guest cart with a user cart after login — became a single DynamoDB transaction instead of a multi-table PostgreSQL transaction. + +3. **Elasticsearch for product search** — We set up an Elasticsearch cluster with an index per locale. The product catalog was synced from PostgreSQL to Elasticsearch via Change Data Capture (Debezium → Kafka → Logstash). Search queries dropped from 3-5 seconds to 50-150ms, with faceted navigation, typo-tolerant autocomplete, and relevance scoring based on purchase history. + +4. **S3 + CloudFront for product images** — The 15GB of base64-encoded images was extracted into an S3 bucket organized by product ID. Image URLs were stored in the PostgreSQL products table as object keys. CloudFront was configured with a 30-day TTL for product images and 7-day TTL for thumbnails. Image load times dropped from 200ms (served from the application server) to under 30ms (served from edge locations). Database size dropped by 14GB, reducing backup time from 4+ hours to under 30 minutes. + +**The consistency challenge:** With data spread across PostgreSQL, DynamoDB, Elasticsearch, and S3, maintaining consistency was the hardest part. When a user added an item to their cart: +- The cart item was written to DynamoDB +- If inventory needed to be reserved, PostgreSQL was updated +- The product availability change needed to propagate to Elasticsearch + +If any of these writes failed after the first succeeded, the system would be inconsistent — a user would see an item in their cart that was no longer available, or inventory would be reserved with no corresponding cart item. + +**Solution: Event sourcing with the outbox pattern.** We implemented a transactional outbox table in PostgreSQL. Any write that affected multiple storage systems wrote an event to the outbox table within the same PostgreSQL transaction. A separate outbox publisher service (running as a sidecar with at-least-once delivery guarantees) read from the outbox and published events to the secondary stores: + +```typescript +// services/orderService.ts — Transactional outbox pattern +import { db } from '../lib/database'; +import { v4 as uuidv4 } from 'uuid'; + +interface AddToCartInput { + userId: string; + productId: string; + quantity: number; + sessionId: string; +} + +export async function addToCart(input: AddToCartInput): Promise { + const eventId = uuidv4(); + + await db.transaction(async (client) => { + // Step 1: Reserve inventory in the primary database + const reservation = await client.query( + `UPDATE inventory + SET reserved_quantity = reserved_quantity + $1 + WHERE product_id = $2 + AND available_quantity - reserved_quantity >= $1 + RETURNING *`, + [input.quantity, input.productId] + ); + + if (reservation.rowCount === 0) { + throw new Error('Insufficient inventory'); + } + + // Step 2: Write the outbox event (same transaction, guaranteed atomic) + await client.query( + `INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload, created_at) + VALUES ($1, 'cart', $2, 'cart.item_added', $3, NOW())`, + [ + eventId, + input.userId || input.sessionId, + JSON.stringify({ + userId: input.userId, + sessionId: input.sessionId, + productId: input.productId, + quantity: input.quantity, + }), + ] + ); + }); + + // Outbox publisher reads and processes events asynchronously + // - Writes cart item to DynamoDB + // - Publishes product availability change to Elasticsearch + // - Both operations are idempotent and retried on failure +} +``` + +**Results after migration:** +- **Query latency dropped 70%** — the average order query went from 800ms to 240ms, with p99 under 500ms +- **Product search became real-time** — Elasticsearch returned results in 50-150ms with typo tolerance and faceted navigation, replacing the 3-5 second PostgreSQL text search +- **Image delivery moved to CDN** — CloudFront delivered product images in under 30ms globally, 6-7x faster than the previous application-server-served approach +- **Database size reduced by 93%** — from 16GB to 1.1GB after extracting images to S3, enabling daily backups in under 30 minutes +- **Zero data loss during migration** — every migration step had a rollback plan and was validated with full traffic replay before cutover + +The key lesson: polyglot persistence is not about using every database available — it is about matching each storage technology to the access patterns and consistency requirements of the data it holds. The transactional outbox pattern provides the consistency guarantee that makes polyglot architectures safe and reliable in production. + +## Conclusion + +The database and storage layer is the durable foundation of every full-stack application. Choosing the right storage technology for each data type — relational for transactional integrity, document stores for flexible schemas, object storage for blobs, CDNs for global delivery — and understanding how to model, migrate, index, query, and connect to those stores is essential for building systems that are both performant and reliable. + +Start with PostgreSQL for your transactional core. It is the most versatile and battle-tested database for full-stack applications. Add NoSQL stores and object storage only when you have a clear performance or capability need that PostgreSQL cannot meet. Index your foreign keys and query-critical columns from day one. Use connection pooling with appropriate min/max settings. Plan every migration with a rollback strategy. And when you need to span data across multiple storage systems, use the outbox pattern to maintain consistency without sacrificing performance. + +The database layer does not need to be rewritten at every growth milestone — but it does need to be evolved deliberately, with the same discipline and testing rigor as the application code it supports. + +--- + +_This article is part of Tikal's Modern Full-Stack Developer's Guide: A 12-Factor Approach series. For the application architecture perspective, see the [main 12 factors](../articles/00-Intro.md)._ diff --git a/layers/04-Layer-4-Auth-and-Permissions.md b/layers/04-Layer-4-Auth-and-Permissions.md new file mode 100644 index 0000000..d163ce1 --- /dev/null +++ b/layers/04-Layer-4-Auth-and-Permissions.md @@ -0,0 +1,521 @@ +# Layer 4: Authentication & Permissions +![cover](../images/layer4.png) + +## TL;DR + +The authentication and permissions layer governs identity verification, session management, and access control across the full-stack application. For fullstack developers, mastering this layer means understanding OAuth2 and OIDC flows, choosing between RBAC and ABAC for authorization, managing JWT lifecycle and token rotation, implementing secure session strategies, and building frontend patterns that gracefully handle token refresh, protected routes, and permission-based UI rendering. This layer is the security boundary of the entire application — every request, every route, every API call passes through it. + +## Why This Layer Matters + +Authentication and authorization are often conflated, but they serve fundamentally different purposes. Authentication answers "who are you?" — it establishes identity. Authorization answers "what can you do?" — it enforces policy. Confusing the two leads to security gaps where identity proves access, or, worse, authorization checks are skipped entirely because "the user is logged in." + +In modern full-stack applications, the auth layer has expanded far beyond username and password. Applications must support social login, single sign-on (SSO) for enterprise customers, multi-factor authentication (MFA), passwordless magic links, device authorization for headless clients, and service-to-service authentication for backend microservices. Each authentication method has different security properties, user experience implications, and implementation complexity. + +At the same time, authorization models have grown more sophisticated. Role-based access control (RBAC) is simple and effective for straightforward hierarchies, but breaks down when permissions depend on context — a user's department, the sensitivity of a document, the time of day, or the geographic location. Attribute-based access control (ABAC) and policy-based access control (PBAC) provide the expressiveness needed for complex multi-tenant SaaS applications, but require careful design to avoid evaluation performance issues. + +The token lifecycle — issuance, signing, storage, refresh, rotation, and revocation — is the operational backbone of the auth layer. A token that leaks to an attacker grants access until it expires. A refresh token stored insecurely undermines the entire authentication system. A missing or slow token revocation mechanism means that terminating a user's session is effectively impossible until the token naturally expires. Every decision in the token design — short-lived access tokens with long-lived refresh tokens, rotation policies, storage choices (HTTP-only cookies vs. localStorage), and signature algorithms — has security and performance consequences. + +For fullstack developers, the auth layer is unique because it demands expertise across the entire stack: cryptographic primitives for token signing, protocol knowledge for OAuth2/OIDC flows, database design for session storage and permission policies, frontend patterns for token refresh and protected routing, and operational practices for key rotation and security monitoring. + +## Key Considerations for Fullstack Developers + +### 1. Authentication Protocols: OAuth2, OIDC, and SAML + +**OAuth2** is an authorization framework, not an authentication protocol. It defines four roles — resource owner (user), client (application), authorization server, and resource server — and several grant types for different client types: +- **Authorization Code Grant** — the standard flow for server-side web applications. The client receives an authorization code after user consent, then exchanges it for tokens in a server-to-server call that includes the client secret. +- **Authorization Code Grant with PKCE** — the recommended flow for native mobile and single-page applications. PKCE (Proof Key for Code Exchange) replaces the client secret with a dynamically generated cryptographic challenge, preventing authorization code interception attacks. +- **Client Credentials Grant** — used for service-to-service communication where no user is involved. The client authenticates directly with its credentials and receives a token. +- **Device Authorization Grant** — for devices with limited input capability (smart TVs, CLI tools). The user completes authentication on a separate device. + +**OpenID Connect (OIDC)** is an identity layer built on top of OAuth2. It adds an ID token (a JWT containing user identity claims) and a `/userinfo` endpoint for fetching additional user attributes. OIDC is the recommended protocol for authentication in modern applications because it standardizes what OAuth2 leaves unspecified — how the client verifies the user's identity, how identity claims are formatted, and how to obtain additional user information. + +**SAML** is an older, XML-based protocol primarily used in enterprise environments for SSO. While SAML is still widely deployed in legacy systems, OIDC has largely replaced it for new implementations due to JSON's simplicity, JWT's compactness, and OIDC's better support for mobile and single-page applications. + +### 2. Authorization Models: RBAC, ABAC, and PBAC + +**RBAC** maps users to roles and roles to permissions. The mapping is static: a user has a role, and that role grants a fixed set of permissions. RBAC is the most common authorization model because it is simple to implement, audit, and reason about. The limitation is that it cannot express context-dependent rules — "managers can approve expenses under $10,000" requires either a separate role per threshold or a different model. + +**ABAC** evaluates access decisions based on attributes of the user, resource, action, and environment. A policy might state: "A user can view a document if the user's department matches the document's department AND the document classification is not 'confidential' AND the request is during business hours." ABAC is expressive and flexible but requires a policy evaluation engine and careful performance tuning. + +**PBAC** centralizes authorization logic into a policy engine — typically using a declarative policy language like Rego (Open Policy Agent) or Cedar (AWS). PBAC moves authorization decisions out of application code into a dedicated service, enabling consistent enforcement across microservices and simplifying audits. The trade-off is additional infrastructure complexity and potential latency for policy evaluation. + +### 3. Token Lifecycle: JWTs, Refresh Tokens, and Rotation + +JSON Web Tokens (JWTs) are the dominant token format in modern authentication. A JWT consists of three base64url-encoded segments — header, payload, and signature — separated by dots. The header specifies the signing algorithm, the payload contains claims (user ID, expiration, issuer, etc.), and the signature verifies integrity. + +Access tokens are short-lived JWTs (typically 15-60 minutes) that the resource server validates on every request. Refresh tokens are long-lived opaque tokens (days to months) used only to obtain new access tokens without requiring the user to re-authenticate. The refresh token grant is an OAuth2 flow where the client presents a refresh token to the authorization server and receives a new access token (and optionally a new refresh token). + +Token rotation is the practice of issuing a new refresh token with each refresh response and invalidating the previous one. Rotation ensures that if a refresh token is stolen, the attacker can use it only until the legitimate client performs its next refresh, at which point the authorization server detects the reuse and revokes all tokens for that session. + +### 4. Session Management: Server-Side vs. Stateless + +**Server-side sessions** store session data in a database (Redis, PostgreSQL, or a dedicated session store) and give the client a session ID cookie. The server looks up the session on every request. This approach provides immediate revocation — delete the session from the store and the user is logged out — at the cost of a database round-trip per request. + +**Stateless sessions** encode all session data in a JWT or similar self-contained token. The server validates the token signature on each request without any database lookup. Stateless sessions scale horizontally without shared session storage but cannot be revoked: invalidating a stateless token requires a blocklist, which reintroduces the server-side state the pattern was meant to avoid. + +Most production systems use a hybrid approach: short-lived stateless access tokens (15-60 minutes) with server-side refresh token storage. The access token can be validated without a database call for low-latency API requests, while the refresh token can be revoked server-side for prompt session termination. + +### 5. Frontend Auth Patterns + +Protected routes are the most basic frontend auth pattern: the router checks authentication status before rendering a page and redirects unauthenticated users to the login page. In React, this is typically implemented as a wrapper component that reads from an auth context and renders `Navigate` from React Router when the user is not authenticated. + +The token refresh interceptor is a more sophisticated pattern where the HTTP client automatically detects a 401 response or an about-to-expire access token, uses the refresh token to obtain a new access token, retries the original request, and returns the result to the caller. This makes token refresh transparent to the rest of the application — components never need to know whether a refresh occurred. + +Permission-based UI rendering conditionally shows or hides elements based on the user's permissions. A "Delete" button checks for the `delete:users` permission before rendering; a dashboard component checks for `view:analytics`. This pattern requires the auth context to expose both authentication state and authorization capabilities. + +## Implementation Patterns & Technologies + +```typescript +// lib/auth/authService.ts — OIDC authentication service with device authorization +import { decodeJwt, jwtVerify, SignJWT } from 'jose'; +import { createRemoteJWKSet } from 'jose/jwks'; + +const JWKS = createRemoteJWKSet( + new URL(`${process.env.OIDC_ISSUER}/.well-known/jwks.json`) +); + +interface TokenSet { + accessToken: string; + refreshToken: string; + idToken?: string; + expiresAt: number; // epoch ms when access token expires +} + +interface User { + id: string; + email: string; + name: string; + roles: string[]; + permissions: string[]; +} + +// Exchange an authorization code for tokens (Authorization Code + PKCE flow) +export async function exchangeCodeForTokens( + code: string, + codeVerifier: string, + redirectUri: string +): Promise { + const response = await fetch(`${process.env.OIDC_ISSUER}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + code_verifier: codeVerifier, + redirect_uri: redirectUri, + client_id: process.env.OIDC_CLIENT_ID!, + client_secret: process.env.OIDC_CLIENT_SECRET!, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + const data = await response.json(); + + // Decode the access token to compute expiration locally + const decoded = decodeJwt(data.access_token); + const expiresAt = (decoded.exp ?? Math.floor(Date.now() / 1000) + 3600) * 1000; + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + idToken: data.id_token, + expiresAt, + }; +} + +// Refresh an access token using a refresh token (with rotation) +export async function refreshTokens( + currentRefreshToken: string +): Promise { + const response = await fetch(`${process.env.OIDC_ISSUER}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: currentRefreshToken, + client_id: process.env.OIDC_CLIENT_ID!, + client_secret: process.env.OIDC_CLIENT_SECRET!, + }), + }); + + if (!response.ok) { + throw new Error('Refresh token expired or revoked'); + } + + const data = await response.json(); + const decoded = decodeJwt(data.access_token); + const expiresAt = (decoded.exp ?? Math.floor(Date.now() / 1000) + 3600) * 1000; + + return { + accessToken: data.access_token, + // The authorization server issues a new refresh token (rotation) + refreshToken: data.refresh_token, + idToken: data.id_token, + expiresAt, + }; +} + +// Verify and decode an access token on every API request (resource server) +export async function verifyAccessToken( + token: string +): Promise<{ sub: string; roles: string[]; permissions: string[] }> { + const { payload } = await jwtVerify(token, JWKS, { + issuer: process.env.OIDC_ISSUER, + audience: process.env.OIDC_AUDIENCE, + }); + + return { + sub: payload.sub!, + roles: (payload.roles as string[]) ?? [], + permissions: (payload.permissions as string[]) ?? [], + }; +} + +// Build the user profile from the ID token claims after login +export function extractUserFromIdToken(idToken: string): User { + const claims = decodeJwt(idToken); + return { + id: claims.sub!, + email: claims.email as string, + name: claims.name as string, + roles: (claims.roles as string[]) ?? [], + permissions: (claims.permissions as string[]) ?? [], + }; +} +``` + +```typescript +// hooks/useAuthInterceptor.ts — Axios interceptor for transparent token refresh +import axios, { + AxiosError, + InternalAxiosRequestConfig, +} from 'axios'; +import { useCallback, useEffect, useRef } from 'react'; + +// Create a dedicated axios instance for API calls +export const apiClient = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL, +}); + +interface UseAuthInterceptorOptions { + getAccessToken: () => string | null; + getRefreshToken: () => string | null; + onRefreshSuccess: (accessToken: string, refreshToken: string) => void; + onRefreshFailure: () => void; + tokenExpiryBufferMs?: number; // how early to refresh before expiration +} + +// Attach interceptor that reads the current tokens from a React context +export function useAuthInterceptor({ + getAccessToken, + getRefreshToken, + onRefreshSuccess, + onRefreshFailure, + tokenExpiryBufferMs = 120_000, // refresh 2 minutes before expiry +}: UseAuthInterceptorOptions): void { + const isRefreshing = useRef(false); + const pendingRequests = useRef< + Array<(token: string) => void> + >([]); + + const refreshTokenCall = useCallback(async () => { + if (isRefreshing.current) return; // prevent concurrent refresh calls + + isRefreshing.current = true; + try { + const response = await axios.post('/api/auth/refresh', { + refreshToken: getRefreshToken(), + }); + + const { accessToken: newAccess, refreshToken: newRefresh } = response.data; + onRefreshSuccess(newAccess, newRefresh); + + // Retry all queued requests with the new access token + pendingRequests.current.forEach((resolve) => resolve(newAccess)); + pendingRequests.current = []; + } catch { + pendingRequests.current = []; + onRefreshFailure(); // logout user + } finally { + isRefreshing.current = false; + } + }, [getRefreshToken, onRefreshSuccess, onRefreshFailure]); + + useEffect(() => { + // Request interceptor: add the Bearer token to every request + const reqInterceptor = apiClient.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + const token = getAccessToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + } + ); + + // Response interceptor: handle 401 and pre-emptive refresh + const resInterceptor = apiClient.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { + _retry?: boolean; + }; + + // Do not retry the refresh endpoint itself + if (originalRequest.url?.includes('/auth/refresh')) { + return Promise.reject(error); + } + + // Case 1: Pre-emptive refresh — access token is about to expire + const accessToken = getAccessToken(); + if (accessToken) { + const payload = JSON.parse(atob(accessToken.split('.')[1])); + const expiresAt = payload.exp * 1000; + const timeUntilExpiry = expiresAt - Date.now(); + + if (timeUntilExpiry < tokenExpiryBufferMs && !originalRequest._retry) { + originalRequest._retry = true; + + // Queue the request until refresh completes + if (isRefreshing.current) { + return new Promise((resolve) => { + pendingRequests.current.push((newToken: string) => { + originalRequest.headers.Authorization = `Bearer ${newToken}`; + resolve(apiClient(originalRequest)); + }); + }); + } + + await refreshTokenCall(); + originalRequest.headers.Authorization = `Bearer ${getAccessToken()}`; + return apiClient(originalRequest); + } + } + + // Case 2: Response is 401 — token expired, refresh and retry + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + if (isRefreshing.current) { + // Another request is already refreshing; queue this one + return new Promise((resolve) => { + pendingRequests.current.push((newToken: string) => { + originalRequest.headers.Authorization = `Bearer ${newToken}`; + resolve(apiClient(originalRequest)); + }); + }); + } + + await refreshTokenCall(); + originalRequest.headers.Authorization = `Bearer ${getAccessToken()}`; + return apiClient(originalRequest); + } + + return Promise.reject(error); + } + ); + + return () => { + apiClient.interceptors.request.eject(reqInterceptor); + apiClient.interceptors.response.eject(resInterceptor); + }; + }, [getAccessToken, getRefreshToken, refreshTokenCall]); +} +``` + +### Protocol Decision Matrix + +| Criterion | OAuth2 | OIDC | SAML | +|-----------|--------|------|------| +| Identity verification | Not built-in (authorization only) | Standardized via ID token | Built-in (Assertion) | +| Token format | Opaque or JWT | JWT (ID token) + opaque access token | XML Assertion | +| Mobile / SPA support | PKCE for public clients | PKCE + ID token for identity | Poor — designed for browser redirects | +| Enterprise SSO adoption | Medium | Growing rapidly | Legacy standard | +| Client types supported | Confidential + Public | Same as OAuth2 | Web apps primarily | +| Token refresh | Yes (opaque or JWT) | Yes | No native refresh | + +## Common Pitfalls + +### 1. Confusing Authentication with Authorization + +The most common auth security mistake: assuming that being authenticated implies authorization. A logged-in user should not automatically have access to every resource. Every API endpoint must independently verify both the caller's identity and their permission to perform the requested action. Do not rely on the frontend to enforce permissions — the backend must be the authoritative enforcement point. + +### 2. Storing Tokens in localStorage + +localStorage is accessible to any JavaScript running on the same origin, making it vulnerable to XSS attacks. If an attacker injects a script into your page, they can read the access token and refresh token from localStorage and impersonate the user indefinitely. Store access tokens in memory (a JavaScript variable) and refresh tokens in HTTP-only, Secure, SameSite=Strict cookies. Memory-only access tokens are lost on page refresh, requiring a refresh token exchange, which is an acceptable UX trade-off for the security benefit. + +### 3. Long-Lived Access Tokens Without Rotation + +Setting access token expiry to days or weeks — instead of minutes — means a leaked token remains valid for an unacceptably long window. Keep access token lifetimes at 15-60 minutes and implement refresh token rotation so that a stolen refresh token can be detected and revoked when the legitimate client performs its next refresh. + +### 4. Ignoring Token Revocation + +Stateless JWTs cannot be revoked without a server-side blocklist. If you cannot revoke tokens, terminating a user's session or responding to a security incident is impossible until the token expires naturally. Use a short token lifetime combined with a server-side blocklist for immediate revocation when needed, or use server-side sessions if revocation is a hard requirement. + +### 5. RBAC as a One-Size-Fits-All Solution + +RBAC is simple and effective for small applications but breaks down as complexity grows. When you need rules like "regional managers can approve orders under $5,000 from their region but only during business hours," RBAC requires an explosion of roles (regional_manager_5k_day, regional_manager_10k_day, etc.). Evaluate ABAC or PBAC early when your authorization rules involve attributes beyond the user's role. + +### 6. Client-Side Permission Enforcement + +Hiding a button on the frontend does not prevent a malicious user from sending the API request directly. Frontend permission checks improve UX by hiding unavailable actions, but the backend must enforce the same checks. Always treat the client as untrusted — validate permissions on every API request regardless of what the UI shows. + +### 7. Refresh Token Reuse Detection + +If a refresh token is stolen and the attacker uses it before the legitimate client's next refresh, the attacker gains ongoing access. Implement refresh token rotation: every refresh request issues a new refresh token and invalidates the old one. When a rotated-out token is presented, immediately revoke all tokens for that user session — this is a strong signal that the refresh token was compromised. + +## How This Layer Connects to the 12 Factors + +- **[Factor 6: Authentication & Authorization](../articles/06-Factor-6.md)** — The foundational factor that defines authentication and authorization strategies. Layer 4 is the architectural implementation of Factor 6: OAuth2/OIDC flows, token lifecycle management, frontend interceptors, and backend middleware that make the factor's principles operational. Every pattern described in Factor 6 — JWT-based auth, RBAC/ABAC, passwordless, token refresh — is built and deployed within this layer. + +- **[Factor 10: Backend-for-Frontend (BFF)](../articles/10-Factor-10.md)** — The BFF pattern transforms how auth is implemented. Rather than each client (web, mobile, IoT) implementing its own OAuth2 flow — potentially with inconsistent security properties — the BFF becomes the sole OAuth2 client. It handles the authorization code exchange, stores refresh tokens server-side, and issues short-lived session cookies to the frontend. This eliminates the need for client secrets on mobile devices and prevents token storage in browser-localStorage. The BFF also enriches the session with user permissions fetched from the authorization service, so frontends never need to decode tokens or parse authorization headers. + +- **[Factor 11: API Communication Patterns](../articles/11-Factor-11.md)** — Every API communication pattern (REST, GraphQL, gRPC, WebSocket) must integrate with the auth layer. REST endpoints use the Authorization header with Bearer tokens; GraphQL resolvers extract the user context from the request's auth middleware; gRPC interceptors validate tokens and propagate identity metadata across service calls; WebSocket connections authenticate during the upgrade handshake and maintain the user context for the connection's lifetime. The choice of communication pattern determines how auth metadata flows through the system. + +- **[Factor 1: UI Component Libraries & Frameworks](../articles/01-Factor-1.md)** — The frontend framework determines how protected routes, auth contexts, and permission-based rendering are implemented. React uses context + wrappers; Vue uses navigation guards; Angular uses route guards and HTTP interceptors. + +- **[Factor 2: State Management](../articles/02-Factor-2.md)** — Auth state (current user, tokens, permissions) is global application state. The auth context is a form of server state that must be synchronized with the backend. Token refresh is a background process that updates server state without user interaction. + +- **[Factor 5: Server State Management](../articles/05-Factor-5.md)** — Auth tokens and user profiles are the outermost server-state boundary. Every server state library (TanStack Query, SWR, Apollo) depends on the auth interceptor to attach credentials and handle 401 responses, making auth the foundational layer on which all server state fetching depends. + +## Case Study + +Tikal helped a B2B SaaS company — a workforce analytics platform serving mid-market and enterprise customers — replace a custom-built authentication system with Auth0 + OIDC. The company had grown organically from a single-tenant prototype to a 500-customer multi-tenant platform and the authentication system could no longer keep up. + +**The challenge:** The custom auth system used bcrypt-hashed passwords stored in PostgreSQL with server-side sessions tracked in Redis. It worked well for the company's 400 mid-market customers (direct sign-ups with email/password) but was failing for their 100 enterprise customers. Every enterprise deal required the company to build yet another custom SSO integration — SAML for Active Directory shops, OIDC for Google Workspace, bespoke LDAP for on-premise deployments. Each integration took 4-6 weeks, was maintained as a separate code path, and broke every time the identity provider's API changed. Enterprise prospects consistently cited SSO support as a gating requirement: "We don't buy software that doesn't support our identity provider." + +Additionally, the user experience was degrading. A user authenticating through an enterprise IdP went through 5 redirects — app → IdP → app → IdP → app — because the custom system lacked proper session state management during the OAuth2 flow. The login flow had 4 different code paths (direct, Google, Azure AD, Okta), each implemented differently with varying degrees of security. + +**Our approach:** We replaced the entire authentication system with Auth0 as the identity broker, using OIDC as the common protocol for all authentication flows. The architecture had three layers: + +1. **Auth0 as the identity broker** — Auth0 became the single OIDC provider for all authentication. For direct sign-ups, Auth0 handled email/password authentication with built-in MFA and breach detection. For enterprise customers, Auth0 acted as a federation proxy: it accepted SAML assertions from enterprise IdPs (ADFS, Okta, Azure AD) and OIDC tokens from Google Workspace, then issued a unified OIDC token to the application. This eliminated the need to maintain separate SSO integration code paths — adding a new enterprise IdP became an Auth0 configuration change, not a code change. + +2. **BFF as the OAuth2 client** — Rather than implementing the OAuth2 Authorization Code + PKCE flow directly in the SPA (which requires exposing client credentials and storing refresh tokens in the browser), we introduced a BFF layer. The BFF handled the token exchange with Auth0, stored refresh tokens server-side in an encrypted Redis store, and issued short-lived session cookies to the frontend. This eliminated token storage on the client entirely — the frontend never saw a refresh token or client secret. + +3. **Frontend auth interceptor** — The React SPA used an Axios interceptor (similar to the one shown earlier in this article) that attached the session cookie to every request. When the session expired, the interceptor triggered a BFF endpoint that refreshed the auth tokens transparently. The user never saw a login prompt unless the refresh token itself had expired. + +**Technical implementation details:** + +- Auth0 connection: one OIDC application with multiple enterprise connections (SAML for ADFS/Okta, OIDC for Google Workspace) +- BFF: Next.js API routes with iron-session for encrypted session cookies +- Token storage: Auth0 refresh tokens stored in Redis with a TTL matching the refresh token lifetime; encrypted at rest using AES-256-GCM +- Session cookies: HTTP-only, Secure, SameSite=Lax, 7-day max age (matching the Auth0 refresh token lifetime) +- Token rotation: Auth0 issued a new refresh token with every refresh; if a rotated-out refresh token was presented, the BFF revoked all sessions for that user + +```typescript +// BFF: src/pages/api/auth/refresh.ts — server-side token refresh with rotation +import { getIronSession, IronSession } from 'iron-session'; +import { NextApiRequest, NextApiResponse } from 'next'; + +const SESSION_OPTIONS = { + password: process.env.SESSION_COOKIE_SECRET!, + cookieName: 'session', + cookieOptions: { + secure: process.env.NODE_ENV === 'production', + httpOnly: true, + sameSite: 'lax' as const, + maxAge: 7 * 24 * 60 * 60, // 7 days + }, +}; + +interface SessionData { + accessToken?: string; + refreshToken?: string; + user?: { id: string; email: string; name: string; roles: string[] }; + expiresAt?: number; +} + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const session = await getIronSession(req, res, SESSION_OPTIONS); + + if (req.method === 'POST') { + // Refresh the access token using Auth0 + const refreshToken = session.refreshToken; + if (!refreshToken) { + return res.status(401).json({ error: 'No refresh token' }); + } + + try { + const response = await fetch( + `https://${process.env.AUTH0_DOMAIN}/oauth/token`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: process.env.AUTH0_CLIENT_ID!, + client_secret: process.env.AUTH0_CLIENT_SECRET!, + refresh_token: refreshToken, + }), + } + ); + + if (!response.ok) { + // Refresh failed — token was rotated and reused (compromised), + // or the session simply expired + session.destroy(); + return res.status(401).json({ error: 'Session expired' }); + } + + const tokens = await response.json(); + const decoded = JSON.parse( + Buffer.from(tokens.access_token.split('.')[1], 'base64').toString() + ); + + // Update session with new tokens (Auth0 rotates refresh tokens) + session.accessToken = tokens.access_token; + session.refreshToken = tokens.refresh_token; + session.expiresAt = decoded.exp * 1000; + session.user = { + id: decoded.sub, + email: decoded.email, + name: decoded.name, + roles: decoded[`${process.env.AUTH0_AUDIENCE}/roles`] ?? [], + }; + await session.save(); + + return res.status(200).json({ + accessToken: tokens.access_token, + expiresAt: decoded.exp * 1000, + user: session.user, + }); + } catch (error) { + session.destroy(); + return res.status(500).json({ error: 'Token refresh failed' }); + } + } + + return res.status(405).json({ error: 'Method not allowed' }); +} +``` + +**Results:** + +- **Enterprise deals unblocked** — SSO was the #1 gating requirement for enterprise prospects. Within 6 months of deploying Auth0 + OIDC, the company closed 12 enterprise deals worth a combined $2.4M ARR that had been stalled on SSO requirements. +- **Login flow simplified from 5 redirects to 2** — app → Auth0 → app (the same flow for every authentication method). The user experience became consistent regardless of whether the user signed up directly or came through an enterprise IdP. +- **SSO integration time dropped from 4-6 weeks to 1 day** — Adding a new enterprise IdP became a configuration change in the Auth0 dashboard rather than a code change. The company added 8 enterprise IdP connections in the first 3 months. +- **Security posture improved** — Refresh tokens moved from localStorage (the previous implementation stored them in the SPA) to an encrypted BFF session cookie. Token rotation defeated refresh token theft. Auth0's breach detection flagged and blocked 12 credential stuffing attacks in the first month. +- **Development velocity increased** — The auth team shrank from 3 engineers to 1 part-time, reassigned to core product work. The BFF auth layer was maintained by the platform team as part of the shared infrastructure. + +**Key lessons:** Auth0 as an identity broker eliminated the complexity of maintaining multiple SSO integrations. The BFF pattern eliminated client-side token storage, which was the largest security risk in the previous architecture. OIDC as the common protocol meant every authentication flow — direct, SAML, OIDC — produced the same token format for the application, simplifying the backend middleware and frontend interceptors. The most important architectural decision was making Auth0 the sole identity provider for the application, not just a proxy — this unified the token format, the authentication flows, and the session management regardless of the upstream identity provider. + +## Conclusion + +The authentication and permissions layer is the security boundary of every full-stack application. Authentication establishes identity through protocols like OAuth2 and OIDC, authorization enforces what that identity can do through models like RBAC, ABAC, and PBAC, and the token lifecycle — short-lived access tokens with rotated refresh tokens — provides the operational mechanism that bridges the two. + +Start with OIDC as your authentication protocol — it provides standardized identity verification on top of OAuth2's authorization framework. Use a BFF pattern to handle the OAuth2 token exchange server-side, keeping refresh tokens out of browser storage. Use short access tokens (15-60 minutes) for stateless validation on API requests, and server-side refresh token storage with rotation for prompt revocation and theft detection. On the frontend, implement a token refresh interceptor that makes token management transparent to the rest of the application, and enforce authorization on both the frontend (for UX) and the backend (for security). + +Auth0, Okta, Keycloak, and other identity providers should be evaluated early — the build-vs-buy decision for authentication has higher stakes than most. A custom auth implementation is a security-critical, compliance-heavy, ongoing maintenance burden that distracts from your core product. Use an identity provider as the security foundation and focus your team's energy on the authorization logic and frontend patterns that differentiate your application. + +The auth layer does not need to be complex — but it must be correct. Every edge case in token refresh, every permission check, every session revocation path must be deliberate and tested. In security, correctness is the only feature that matters. + +--- + +_This article is part of Tikal's Modern Full-Stack Developer's Guide: A 12-Factor Approach series. For the application architecture perspective, see the [main 12 factors](../articles/00-Intro.md)._ diff --git a/layers/05-Layer-5-Hosting-and-Deployment.md b/layers/05-Layer-5-Hosting-and-Deployment.md new file mode 100644 index 0000000..3dcaf87 --- /dev/null +++ b/layers/05-Layer-5-Hosting-and-Deployment.md @@ -0,0 +1,522 @@ +# Layer 5: Hosting & Deployment +![cover](../images/layer5.png) + +## TL;DR + +The hosting and deployment layer is where application code becomes a running service. This layer encompasses deployment platforms (Vercel, Netlify, Railway, Fly.io, self-hosted), environment management (development, staging, production, preview deployments), rollout strategies (blue-green, canary, feature flags), and infrastructure as code (Terraform, Pulumi, CloudFormation). For fullstack developers, mastering this layer means understanding how to choose the right platform for each workload, manage multiple environments with parity, deploy with zero downtime and progressive exposure, and codify infrastructure so it is reviewable, versioned, and reproducible. This layer is the bridge between development velocity and production reliability — the difference between a deployment that causes an incident and one that users never notice. + +## Why This Layer Matters + +Hosting and deployment is the operational reality of every application. No matter how clean the code, how comprehensive the tests, or how elegant the architecture, a poorly designed deployment pipeline undermines everything. A deployment that takes 45 minutes and requires manual coordination is a deployment that happens infrequently, which means bug fixes and features languish in staging while users wait. A deployment that lacks rollback capability is a deployment that turns every release into a high-stakes gamble. A deployment that treats production, staging, and development as fundamentally different environments is a deployment where "works on my machine" becomes an organizational excuse. + +The modern hosting landscape offers an overwhelming range of options. Platform-as-a-Service providers like Vercel, Netlify, and Railway abstract away server management entirely, letting developers push code and have it running in seconds. Container-focused platforms like Fly.io, Render, and Railway provide the flexibility of containers with the convenience of managed infrastructure. Cloud providers like AWS, GCP, and Azure offer raw compute, networking, and storage with maximum control. And self-hosted solutions using Kubernetes, Nomad, or bare metal provide ultimate sovereignty for regulated industries or high-scale workloads. Choosing the right platform — or combination of platforms — requires understanding the trade-offs between developer experience, operational control, scaling characteristics, and cost. + +Environment management is another dimension where deployment decisions have outsize impact. Every environment — development, staging, production, preview — serves a different purpose. Development must prioritize iteration speed. Staging must mirror production as closely as possible to catch environment-specific bugs. Preview deployments enable teams to review changes in an isolated environment before merging. Production must balance stability, performance, and availability. Achieving parity across environments without duplicating infrastructure or introducing configuration drift requires deliberate tooling and discipline. + +Rollout strategies determine how new code reaches users. A naive "deploy and hope" approach maximizes velocity but minimizes safety. Blue-green deployments maintain two identical environments and switch traffic atomically, making rollback instantaneous. Canary deployments route a small percentage of traffic to the new version, monitor for errors, and gradually increase the percentage. Feature flags decouple deployment from release, letting teams deploy code to production that is disabled by default and enabled progressively. Each strategy trades complexity for safety, and the right choice depends on the application's risk tolerance, observability, and traffic patterns. + +Infrastructure as Code (IaC) transforms infrastructure management from a manual, error-prone process into a software engineering practice. When infrastructure is defined in code — Terraform configurations, Pulumi programs, CloudFormation templates — it can be version-controlled, code-reviewed, tested, and automatically applied. IaC eliminates configuration drift, enables disaster recovery by recreating entire environments from code, and makes infrastructure changes as auditable and reversible as application code changes. + +For fullstack developers, the hosting and deployment layer is where the rubber meets the road. It is the layer that determines whether users experience new features or errors, whether rollbacks take seconds or hours, and whether deploying on a Friday afternoon is a reasonable activity or a career-limiting decision. + +## Key Considerations for Fullstack Developers + +### 1. Deployment Platforms: Matching Workload to Infrastructure + +Different application workloads have different hosting requirements. A static marketing site, a server-rendered Next.js application, a real-time WebSocket server, and a background job processor each benefit from different platforms: + +- **Vercel** — Optimized for frontend frameworks (Next.js, SvelteKit, Remix). Provides edge functions, ISR, automatic static optimization, and preview deployments. Best for frontend-heavy applications where server-side rendering and edge delivery matter. Not suitable for long-running processes or stateful services. + +- **Netlify** — Similar to Vercel with strengths in static sites, serverless functions, and form handling. Netlify's Edge Functions and deploy previews make it a strong choice for Jamstack architectures. Less suited for complex backend logic or database-connected services. + +- **Railway** — A container-based platform that emphasizes developer experience. Connect a GitHub repo, choose a build pack or Dockerfile, and get a running service with a database. Excellent for full-stack applications where the same platform hosts frontend, API, and database. Less mature for high-scale enterprise workloads. + +- **Fly.io** — Runs containers on firecracker micro-VMs close to users. Supports any language or framework via Docker. Strong for globally distributed applications and real-time workloads. More operational control than Vercel/Netlify, less than raw AWS. + +- **Render** — Offers static sites, web services, background workers, PostgreSQL, and Redis in one platform. Good for full-stack applications that want managed infrastructure without cloud-provider complexity. Blueprint IaC provides infrastructure-as-code via YAML. + +- **Self-hosted / Cloud providers (AWS, GCP, Azure)** — Maximum control and flexibility. Suitable for regulated industries, high-scale workloads, or organizations with dedicated infrastructure teams. Requires significantly more operational expertise to manage networking, security, scaling, and cost. + +### 2. Environment Management: Parity Without Duplication + +The principle of environment parity — making development, staging, and production as similar as possible — is one of the most violated principles in practice. The gap between development (SQLite on a laptop, hot reload, mock services) and production (PostgreSQL in a cluster, CDN, real third-party APIs) is a primary source of deployment failures. + +**Effective environment management strategies:** + +- **Ephemeral environments** — Spin up a complete environment (infrastructure + data seed) for each pull request. Destroy it when the PR merges. Tools like Railway, Heroku Review Apps, and Kubernetes namespaces make this practical. The cost is infrastructure duplication; the benefit is catching environment-specific bugs before merge. + +- **Preview deployments** — Deploy the frontend and any frontend-adjacent services (BFF, static assets) to a unique URL for each PR. The backend and database remain shared (or use a staging backend). Vercel, Netlify, and Cloudflare Pages provide preview deployments out of the box. This is lighter weight than full ephemeral environments and sufficient for most frontend changes. + +- **Environment configuration as code** — Store environment-specific configuration in the repository, not in a wiki or a developer's local `.env` file. Use a consistent schema across environments and validate it at deploy time. Tools like Doppler, Vault, or AWS Secrets Manager manage secrets across environments without exposing them in code. + +- **Database parity** — Use the same database engine (PostgreSQL, not SQLite) in all environments. Seed staging with anonymized production data to catch query performance and data-shape issues before they reach production. Apply migrations to staging first, then production. + +### 3. Rollout Strategies: Deploy vs. Release + +Deploying is making code available on servers. Releasing is making that code serve user traffic. Decoupling the two is the foundational insight behind safe rollout strategies. + +- **Blue-green deployment** — Two identical environments (blue and green) run simultaneously. At any time, one environment serves production traffic. The new version is deployed to the idle environment, tested, and then traffic is switched atomically (at the load balancer or DNS level). Rollback is instantaneous — switch traffic back to the previous environment. The cost is doubled infrastructure during deployment. + +- **Canary deployment** — Route a small percentage of traffic (2%, 5%, 10%) to the new version. Monitor error rates, latency, and business metrics. If metrics remain healthy, gradually increase the percentage to 25%, 50%, 100%. If metrics degrade, roll back the canary and investigate. Canary deployments require sophisticated traffic routing infrastructure (service mesh, feature flag platform, or load balancer rules) and robust observability. + +- **Feature flags** — Wrap new functionality in conditional checks controlled by a remote configuration service. Deploy the code with the flag disabled. Enable the flag for internal users first, then a small percentage of users, then gradually roll out. Feature flags decouple deployment from release entirely — code can be in production for weeks before it is enabled. The trade-off is technical debt from flag cleanup and the risk of flag-induced complexity in the codebase. + +- **Rolling update** — Replace instances one at a time (or in batches) while maintaining the overall service. Kubernetes deployments use rolling updates by default. This is the simplest zero-downtime strategy but provides the least safety — if the new version has a bug, some users experience it before the deployment is rolled back. + +### 4. Infrastructure as Code: Codifying Environments + +Infrastructure as Code is the practice of defining infrastructure resources — servers, databases, networks, load balancers, DNS records, CDN distributions — in declarative configuration files. IaC provides: + +- **Version control** — Infrastructure changes are tracked in git alongside application code. Every change has an author, a review, and a history. +- **Reproducibility** — Recreate any environment (production, staging, a specific PR) from the same code at the same commit. +- **Reviewability** — Pull requests for infrastructure changes are reviewed like code changes, catching misconfigurations before they reach production. +- **Automation** — Infrastructure changes are applied automatically by CI/CD pipelines, not executed manually through cloud consoles. + +The three dominant IaC tools are: + +- **Terraform** — Declarative HCL (HashiCorp Configuration Language). Provider model supports AWS, GCP, Azure, and 2000+ other providers. State management requires a backend (S3, Terraform Cloud, or similar). The mature ecosystem and broadest provider support make it the default choice for most teams. + +- **Pulumi** — IaC with general-purpose programming languages (TypeScript, Python, Go, C#, Java). Infrastructure is defined as real programs with loops, conditionals, functions, and classes. This enables more expressive infrastructure patterns than Terraform's HCL, at the cost of a smaller provider ecosystem. + +- **AWS CloudFormation** — AWS-native IaC using JSON or YAML templates. Deep integration with AWS services and built-in drift detection. Limited to AWS and not portable to multi-cloud setups. + +## Implementation Patterns & Technologies + +```typescript +// infrastructure/stack.ts — Pulumi program defining full-stack infrastructure +import * as aws from '@pulumi/aws'; +import * as awsx from '@pulumi/awsx'; +import * as docker from '@pulumi/docker'; + +// Environment name passed at deploy time: 'dev' | 'staging' | 'prod' +const env = process.env.DEPLOY_ENV || 'dev'; +const isProduction = env === 'prod'; +const stackName = `myapp-${env}`; + +// --- Container Registry --- +const ecrRepo = new aws.ecr.Repository(`${stackName}-repo`, { + forceDelete: true, + imageScanningConfiguration: { scanOnPush: true }, +}); + +// --- ECS Cluster with Fargate (serverless containers) --- +const cluster = new aws.ecs.Cluster(`${stackName}-cluster`); + +// Application load balancer — routes traffic to the correct target group +const alb = new awsx.lb.ApplicationLoadBalancer(`${stackName}-alb`, { + internal: false, + securityGroups: [], + subnetIds: aws.ec2.getSubnetIdsOutput({ vpcId: aws.ec2.getVpcOutput({ default: true }).id }).ids, +}); + +// --- ECS Service with Blue-Green deployment via CodeDeploy --- +const appService = new awsx.ecs.FargateService(`${stackName}-api`, { + cluster: cluster.arn, + taskDefinitionArgs: { + container: { + image: process.env.APP_IMAGE || 'nginx:alpine', + cpu: isProduction ? 512 : 256, + memory: isProduction ? 1024 : 512, + portMappings: [{ containerPort: 3000, targetGroup: alb.defaultTargetGroup }], + environment: [ + { name: 'NODE_ENV', value: env }, + { name: 'DATABASE_URL', value: process.env.DATABASE_URL! }, + { name: 'REDIS_URL', value: process.env.REDIS_URL! }, + ], + }, + }, + desiredCount: isProduction ? 4 : 1, + // Blue-green deployment: CodeDeploy shifts traffic from the old task set to the new one + deploymentController: { type: 'CODE_DEPLOY' }, +}); + +// --- RDS PostgreSQL (Aurora Serverless for staging, provisioned for prod) --- +const db = new aws.rds.Cluster(`${stackName}-db`, { + engine: 'aurora-postgresql', + engineVersion: '16.3', + databaseName: 'myapp', + masterUsername: 'admin', + masterPassword: process.env.DB_PASSWORD!, + serverlessv2ScalingConfiguration: isProduction + ? { minCapacity: 1, maxCapacity: 16 } + : { minCapacity: 0.5, maxCapacity: 2 }, + instances: [ + { identifier: `${stackName}-db-0`, instanceClass: isProduction ? 'db.serverless' : 'db.serverless' }, + ], + skipFinalSnapshot: !isProduction, + backupRetentionPeriod: isProduction ? 30 : 7, + deletionProtection: isProduction, +}); + +// --- CloudFront CDN for static assets --- +const cdn = new aws.cloudfront.Distribution(`${stackName}-cdn`, { + enabled: true, + origins: [{ + domainName: alb.loadBalancer.dnsName, + originId: 'alb-origin', + customOriginConfig: { + httpPort: 80, + httpsPort: 443, + originProtocolPolicy: 'https-only', + originSslProtocols: ['TLSv1.2'], + }, + }], + defaultCacheBehavior: { + targetOriginId: 'alb-origin', + viewerProtocolPolicy: 'redirect-to-https', + allowedMethods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'PATCH', 'DELETE'], + cachedMethods: ['GET', 'HEAD', 'OPTIONS'], + forwardedValues: { + queryString: true, + cookies: { forward: 'all' }, + headers: ['Authorization', 'CloudFront-Viewer-Country', 'Origin'], + }, + minTtl: 0, + defaultTtl: 0, + maxTtl: 0, // dynamic content, not cached at edge + compress: true, + }, + orderedCacheBehaviors: [ + // Static assets (_next/static, images) — cache aggressively + { + pathPattern: '/_next/static/*', + targetOriginId: 'alb-origin', + viewerProtocolPolicy: 'redirect-to-https', + allowedMethods: ['GET', 'HEAD', 'OPTIONS'], + cachedMethods: ['GET', 'HEAD', 'OPTIONS'], + forwardedValues: { queryString: false, cookies: { forward: 'none' } }, + minTtl: 0, + defaultTtl: 86400, // 1 day + maxTtl: 31536000, // 1 year + compress: true, + }, + ], + priceClass: 'PriceClass_100', // US, Canada, Europe only + customErrorResponses: [ + { errorCode: 404, responseCode: 200, responsePagePath: '/404.html' }, + ], + aliases: isProduction ? ['myapp.com', 'www.myapp.com'] : [`${env}.myapp.com`], + viewerCertificate: { + acmCertificateArn: process.env.CERT_ARN!, + sslSupportMethod: 'sni-only', + minimumProtocolVersion: 'TLSv1.2_2021', + }, +}); + +// --- Output values consumed by the CI/CD pipeline --- +export const cdnDomain = cdn.domainName; +export const albDns = alb.loadBalancer.dnsName; +export const dbEndpoint = db.endpoint; +export const ecrRepoUrl = ecrRepo.repositoryUrl; +``` + +This Pulumi program defines a production-grade full-stack infrastructure stack in roughly 100 lines of TypeScript. Each resource is explicitly configured for the environment (`dev`, `staging`, `prod`) with appropriate scaling, backup, and security settings. The CloudFront distribution is configured with two cache behaviors: the default behavior passes through with no caching (for dynamic API responses), while the `/_next/static/*` pattern caches aggressively at the edge (for immutable build artifacts). + +```yaml +# .github/workflows/deploy.yml — GitHub Actions workflow with canary analysis +name: Deploy to Production +on: + push: + branches: [main] + +env: + AWS_REGION: us-east-1 + ECR_REPOSITORY: myapp/api + ECS_CLUSTER: myapp-prod-cluster + ECS_SERVICE: myapp-prod-api + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + permissions: + id-token: write # OIDC authentication with AWS + contents: read + + steps: + - uses: actions/checkout@v4 + + # --- Step 1: Build, tag, and push the Docker image --- + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build and tag Docker image + id: build-image + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: ${{ github.sha }} + run: | + docker build \ + --cache-from $ECR_REGISTRY/$ECR_REPOSITORY:latest \ + -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \ + -t $ECR_REGISTRY/$ECR_REPOSITORY:latest \ + . + docker push --all-tags $ECR_REGISTRY/$ECR_REPOSITORY + echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT + + # --- Step 2: Deploy with CodeDeploy (blue-green) --- + - name: Deploy to ECS with CodeDeploy + id: deploy + run: | + # Create the appspec.yml for CodeDeploy (blue-green deployment) + cat > appspec.yml << 'EOF' + version: 1 + Resources: + - TargetService: + Type: AWS::ECS::Service + Properties: + TaskDefinition: "" + LoadBalancerInfo: + ContainerName: "api" + ContainerPort: 3000 + EOF + + # Register a new task definition with the updated image + aws ecs register-task-definition \ + --family myapp-api \ + --cli-input-json "$(aws ecs describe-task-definition \ + --task-definition myapp-api \ + --query 'taskDefinition' \ + | jq '.containerDefinitions[0].image = "${{ steps.build-image.outputs.image }}"' \ + | jq 'del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities)')" \ + --query 'taskDefinition.taskDefinitionArn' \ + --output text > task-def-arn.txt + + # Create CodeDeploy deployment (10% canary, 5-minute interval) + aws deploy create-deployment \ + --application-name myapp-ecs-app \ + --deployment-group-name myapp-ecs-group \ + --revision "revisionType=AppSpecContent,appSpecContent={content=file://appspec.yml}" \ + --description "Canary deploy $(git rev-parse --short HEAD)" \ + --output json + + # --- Step 3: Monitor canary health via CloudWatch --- + - name: Monitor canary metrics + run: | + echo "Monitoring canary deployment for 5 minutes..." + # Wait for the canary to stabilize (10% traffic for 5 minutes) + sleep 300 + + # Check for increased error rate in the canary target group + ERROR_RATE=$(aws cloudwatch get-metric-statistics \ + --namespace AWS/ApplicationELB \ + --metric-name HTTPCode_Target_5XX_Count \ + --dimensions Name=LoadBalancer,Value=app/myapp-prod-alb/abc123 \ + --start-time "$(date -u -d '5 minutes ago' +%Y-%m-%dT%H:%M:%SZ)" \ + --end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --period 60 \ + --statistics Sum \ + --query 'Datapoints[0].Sum' \ + --output text) + + if [ "$ERROR_RATE" = "None" ] || [ "$ERROR_RATE" -lt 5 ]; then + echo "✅ Canary healthy — proceeding to full rollout" + # Trigger CodeDeploy to shift remaining 90% traffic + aws deploy continue-deployment \ + --deployment-id "$(aws deploy list-deployments \ + --application-name myapp-ecs-app \ + --deployment-group-name myapp-ecs-group \ + --query 'deployments[0]' \ + --output text)" \ + --deployment-wait-type READY + else + echo "❌ Canary error rate too high ($ERROR_RATE 5XX in 5min) — rolling back" + aws deploy stop-deployment \ + --deployment-id "$(aws deploy list-deployments \ + --application-name myapp-ecs-app \ + --deployment-group-name myapp-ecs-group \ + --query 'deployments[0]' \ + --output text)" \ + --auto-rollback-enabled + exit 1 + fi + + # --- Step 4: Invalidate CloudFront cache for updated assets --- + - name: Invalidate CloudFront cache + run: | + aws cloudfront create-invalidation \ + --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \ + --paths "/*" +``` + +This GitHub Actions workflow implements a canary blue-green deployment on ECS with automated rollback. The pipeline builds a Docker image, registers a new task definition, deploys via AWS CodeDeploy starting at 10% traffic, monitors error rates for five minutes via CloudWatch, and either promotes the canary to full rollout or automatically rolls back. The final step invalidates the CloudFront CDN cache so users see the latest assets. + +### Platform Decision Matrix + +| Criterion | Vercel | Railway | Fly.io | AWS (self-managed) | +|-----------|--------|---------|--------|--------------------| +| Setup time | Minutes | Minutes | Minutes | Days to weeks | +| Frontend optimization | Excellent | Good | Good | Manual | +| Stateful services | No | Yes | Yes | Yes | +| Global edge | Built-in | Regional | Per-user proximity | CloudFront + regions | +| Infrastructure control | Minimal | Moderate | Moderate | Complete | +| Operational overhead | Near zero | Low | Low | High | +| Cost at scale | High (per-request) | Moderate | Moderate | Lowest | +| Preview deployments | Built-in | Manual | Manual | Manual | + +## Common Pitfalls + +### 1. Environment Drift Between Development and Production + +The most common deployment failure is an environment difference that was never tested. A PostgreSQL 15 feature used in production but not available in the local PostgreSQL 14. A Linux-only npm dependency that works on macOS during development. A timeout that is too tight in production because the staging environment is on the same network as the database while production is cross-region. Use Docker for local development to match the production runtime. Use the same database engine and version everywhere. Run integration tests in a CI environment that mirrors production, not on developer laptops. + +### 2. Deploy and Pray + +Deploying without automated health checks, rollback capability, or gradual traffic shifting is gambling with production. A deployment that immediately serves 100% of traffic to a broken version affects every user. Always have a rollback plan — and test it. Use blue-green or canary deployments so that a bad release impacts at most a small percentage of users. Monitor error rates and latency during and after deployment, not just uptime. + +### 3. Infrastructure as Manual Configuration + +Clicking buttons in the AWS console to set up infrastructure is fast and dangerous. Manual infrastructure creates snowflake environments that cannot be reproduced. When something breaks at 3 AM, no one remembers which buttons were clicked six months ago. Codify everything — databases, load balancers, DNS, CDN, IAM roles, VPCs — in Terraform, Pulumi, or CloudFormation. Apply infrastructure changes through CI/CD, not through the console. + +### 4. Ignoring Preview Deployments + +Deploying every pull request to a shared staging environment creates contention — two PRs in testing at the same time step on each other. A team of five developers sharing one staging environment spends more time coordinating than testing. Use preview deployments (Vercel, Netlify, Railway) or ephemeral Kubernetes namespaces so every PR gets its own isolated environment. The infrastructure cost is modest; the productivity gain is significant. + +### 5. Secrets in Environment Variables at Build Time + +Injecting secrets (API keys, database passwords) as build-time environment variables embeds them in the Docker image or build artifact. Anyone with access to the image can extract the secrets. Use runtime secrets — injected at container start time via secrets manager (AWS Secrets Manager, Vault, Doppler) or environment variables set at the orchestrator level. Never bake secrets into images. + +### 6. Not Testing the Rollback + +Most teams have a rollback strategy. Few teams have tested it. A rollback that has never been exercised will fail when it is needed — the database migration cannot be reverted, the old Docker image was pruned from the registry, the rollback script has a syntax error. Test rollbacks in staging as part of every release. Automate rollback verification in the CI pipeline. + +### 7. Treating All Environments Identically + +Parity does not mean identity. Production needs different scaling, backup, alerting, and security configurations than staging. Staging needs different data seeding and monitoring than development. Configuring every environment with production-grade redundancy and backup multiplies cost without benefit. Use environment-specific IaC variables to right-size each environment while keeping the infrastructure definition consistent. + +## How This Layer Connects to the 12 Factors + +- **[Factor 2: Repository Strategy](../articles/02-Factor-2.md)** — The repository structure directly determines deployment strategy. A monorepo with multiple applications (frontend, API, workers) requires a build pipeline that can selectively deploy based on changed files, while a multirepo setup deploys each repository independently. Monorepos enable atomic deployments across services but require sophisticated tooling (Nx, Turborepo) to avoid rebuilding everything on every change. The deployment layer implements the repository strategy's decisions about build isolation, artifact versioning, and cross-service deployment coordination. + +- **[Factor 7: Rendering Strategies](../articles/07-Factor-7.md)** — The rendering strategy determines hosting requirements. SSR applications need servers that can execute JavaScript at request time — Vercel's edge functions, Fly.io's micro-VMs, or ECS Fargate tasks. SSG applications need a build step and a CDN — and can be hosted on simpler, cheaper infrastructure (S3 + CloudFront, Netlify). ISR requires platforms that support on-demand revalidation with cache purging. The deployment pipeline must match the chosen rendering strategy: SSR deployments must handle connection pooling, session affinity, and cold starts; SSG deployments must handle incremental builds and cache invalidation. + +- **[Factor 1: UI Component Libraries & Frameworks](../articles/01-Factor-1.md)** — The frontend framework determines which hosting platforms are compatible. Next.js integrates natively with Vercel; SvelteKit works well with Netlify or Vercel; Remix is optimized for Fly.io. The deployment platform's support for the framework's rendering model (SSR, SSG, ISR, edge functions) is a factor in platform selection. + +- **[Factor 10: Backend-for-Frontend (BFF)](../articles/10-Factor-10.md)** — The BFF pattern impacts deployment by introducing an additional service that must be deployed alongside the frontend. BFF deployment must be coordinated with frontend deployment to ensure API compatibility. Preview deployments for the BFF enable frontend developers to test API changes in isolation. + +- **[Factor 11: API Communication Patterns](../articles/11-Factor-11.md)** — API patterns influence deployment topology. REST APIs are straightforward to deploy behind any load balancer. GraphQL APIs may benefit from dedicated caching infrastructure (CDN for queries, not mutations). WebSocket servers require platforms that support long-lived connections with sticky sessions, which rules out serverless platforms like Vercel and limits options to Fly.io, Railway, or container-based hosting. + +- **[Supplemental Factor 3: Micro-Frontends](../articles/15-Supplemental-factor-3.md)** — Micro-frontend architectures require coordinated deployment of multiple independent frontend applications. Each micro-frontend may be deployed by a different team on a different cadence, yet they must compose seamlessly at runtime. The hosting layer must support independent deployments, shared infrastructure (CDN, routing layer), and integration testing across micro-frontends before they reach users. + +## Case Study + +Tikal helped a major media company — one of the largest news publishers in the country — transform their deployment process from a 45-minute manual FTP workflow to a fully automated CI/CD pipeline with preview environments and zero-downtime blue-green deployments. + +**The challenge:** The company operated a high-traffic news site that served 100,000+ concurrent readers during breaking news events. Their deployment process was entirely manual: a developer would build the application locally, compress it into a ZIP file, upload it via FTP to a shared hosting server, and manually restart the web server. A single deployment took 45 minutes of focused effort and had to be scheduled during low-traffic windows (typically 2-3 AM). Rollbacks were worse — they required locating the previous build archive, re-uploading it, and restarting the server again. During breaking news, when the newsroom was publishing stories as quickly as possible, the deployment bottleneck meant stories could be 30-60 minutes late reaching the CDN. Deployments failed approximately 15% of the time due to file permission issues, missing dependencies, or configuration differences between the developer's machine and the production server. Each failed deployment delayed the next attempt by at least an hour. + +**Our approach:** We designed and implemented a complete CI/CD transformation with four key components: + +1. **GitHub Actions CI/CD pipeline** — Every push to the `main` branch triggered a pipeline that: ran the test suite (unit, integration, and visual regression tests), built the Next.js application with production optimizations, built a Docker image with the Node.js runtime and application code, pushed the image to Amazon ECR, and deployed the image to an ECS Fargate cluster using blue-green deployment. + +2. **Preview environments** — Every pull request received its own preview environment. A lightweight ECS Fargate service (256 CPU, 512 MB RAM, 1 task) was provisioned automatically by a GitHub Actions workflow, deployed with the PR's Docker image, and assigned a unique URL (`pr-123.preview.news-site.com`). The preview environment connected to a shared staging database seeded with anonymized production data. When the PR was merged or closed, the preview environment was torn down automatically. + +3. **Blue-green deployment with canary analysis** — Production deployment used AWS CodeDeploy with a blue-green strategy. The new version was deployed to the idle target group (green). Once healthy, 10% of traffic was shifted to the green group for a five-minute observation period. CloudWatch alarms monitored 5xx error rates, p95 latency, and custom business metrics (article views per minute). If metrics remained healthy, the remaining 90% of traffic was shifted. If metrics breached thresholds, CodeDeploy automatically rolled back to the blue group. + +4. **CloudFront cache invalidation** — The final deployment step invalidated the CloudFront CDN cache for all paths. To avoid the cost and latency of a full invalidation (`/*`), we configured the pipeline to invalidate only changed paths by comparing the current build manifest against the previous one. For breaking news, where immediacy was critical, we added an API endpoint that the newsroom CMS could call to trigger a targeted cache invalidation for specific article paths — this reduced the "story published to reader sees it" time from minutes to under 10 seconds. + +```typescript +// scripts/invalidateCache.ts — Targeted CloudFront invalidation for changed assets +import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront'; +import { readFileSync, existsSync } from 'fs'; +import { execSync } from 'child_process'; + +const cf = new CloudFrontClient({ region: process.env.AWS_REGION }); +const DISTRIBUTION_ID = process.env.CLOUDFRONT_DISTRIBUTION_ID!; + +interface BuildManifest { + // Maps build output files to their content hashes + assets: Record; + pages: Record; +} + +export async function invalidateChangedAssets(): Promise { + const currentManifest: BuildManifest = JSON.parse( + readFileSync('.next/build-manifest.json', 'utf-8') + ); + + let previousManifest: BuildManifest = { assets: {}, pages: {} }; + const prevPath = '.next/previous-build-manifest.json'; + + if (existsSync(prevPath)) { + previousManifest = JSON.parse(readFileSync(prevPath, 'utf-8')); + } + + // Find only the assets whose content hash changed + const changedPaths: string[] = []; + + for (const [asset, hash] of Object.entries(currentManifest.assets)) { + if (previousManifest.assets[asset] !== hash) { + changedPaths.push(`/${asset}`); + } + } + + for (const [page, { initial }] of Object.entries(currentManifest.pages)) { + const prev = previousManifest.pages[page]?.initial ?? []; + const curr = initial ?? []; + if (JSON.stringify(prev) !== JSON.stringify(curr)) { + changedPaths.push(page === '/' ? '/index.html' : `${page}.html`); + } + } + + if (changedPaths.length === 0) { + console.log('No changed assets to invalidate'); + return; + } + + // Batch paths into groups of 15 (CloudFront invalidation limit per path) + const BATCH_SIZE = 15; + for (let i = 0; i < changedPaths.length; i += BATCH_SIZE) { + const batch = changedPaths.slice(i, i + BATCH_SIZE); + + await cf.send(new CreateInvalidationCommand({ + DistributionId: DISTRIBUTION_ID, + InvalidationBatch: { + CallerReference: `deploy-${Date.now()}-${i}`, + Paths: { + Quantity: batch.length, + Items: batch, + }, + }, + })); + + console.log(`Invalidated ${batch.length} paths:`, batch); + } + + // Save the current manifest for the next deploy + execSync('cp .next/build-manifest.json .next/previous-build-manifest.json'); +} + +// Usage: called at the end of the deploy workflow +// npx tsx scripts/invalidateCache.ts +``` + +This script reads the Next.js build manifest, compares the current build's asset hashes against the previous deploy, and issues targeted CloudFront invalidations for only the changed assets. On a typical deploy — where only a few pages or components changed — this invalidates 5-20 paths instead of thousands, reducing invalidation cost by 90% and completion time from minutes to seconds. + +**Results:** + +- **Deployment time dropped from 45 minutes to 4 minutes** — The fully automated CI/CD pipeline, from commit to production, took under four minutes. The bottleneck shifted from the deployment itself to the test suite (which we also optimized from 12 minutes to 3 minutes through parallelization and test splitting). +- **Zero-downtime deployments** — Every deployment used blue-green with canary analysis. Readers never saw errors during deployments. The newsroom could deploy during peak traffic hours without concern. +- **Deployment failure rate dropped from 15% to 0.3%** — Automated testing caught the issues that previously caused FTP deployment failures. The only remaining failures were infrastructure issues (ECR registry unavailable, CloudFront API throttling), each with automated retry logic. +- **Story publication latency dropped from 30-60 minutes to under 10 seconds** — The targeted cache invalidation API combined with the automated CI/CD pipeline meant that breaking news stories published by the CMS reached readers through the CDN in under 10 seconds, compared to the previous window of 30-60 minutes between CMS publish and manual FTP deploy. +- **Preview environments transformed the review workflow** — The editorial team used preview URLs to review story layouts before publication. Designers verified responsive breakpoints. Ad operations validated ad placements. The product manager reviewed feature changes before merge. Preview environments eliminated the "it worked on my machine" gap between development and production. +- **Developer productivity increased** — Deploying on Friday afternoon was no longer a risk. The automated rollback meant that any bad deploy was detected and reverted within five minutes with zero user impact. Developers deployed 8x more frequently — from once every two days to four times per day on average. + +**Key lessons:** The most impactful change was not any single technology — it was the shift in mindset from deployment as a risky manual operation to deployment as a safe automated process. The preview environments eliminated the "it works on my machine" problem by providing an isolated environment for every change. The blue-green canary deployment eliminated the fear of breaking production. The targeted cache invalidation eliminated the latency between content publication and CDN delivery. Each component of the transformation reduced a specific source of risk or delay, and together they made deployment fast, safe, and frequent. + +## Conclusion + +The hosting and deployment layer is the operational foundation of every full-stack application. Choosing the right platform for each workload — Vercel for frontend-heavy apps, Fly.io for globally distributed services, Railway for full-stack simplicity, or AWS for maximum control — is the first architectural decision. Managing environments with parity through Docker, ephemeral environments, and code-defined configuration eliminates the "works on my machine" class of deployment failures. Implementing rollout strategies that decouple deployment from release — blue-green for atomic cutovers, canary for progressive exposure, feature flags for per-user targeting — makes deploying safe enough to do multiple times a day. And adopting infrastructure as Code transforms infrastructure from a fragile snowflake into a versioned, reviewable, reproducible asset. + +Start by automating your deployment pipeline before you worry about rollout strategies. A manual deployment with blue-green is still a manual deployment. Use preview environments for every pull request — the infrastructure cost is negligible compared to the debugging time they save. Codify your infrastructure in Terraform or Pulumi so that a complete environment can be recreated from scratch. Deploy in small, frequent increments — the more often you deploy, the smaller each change is, and the easier it is to identify the cause of any issue. And always test your rollback: a deployment strategy is only as good as its ability to undo a bad change without user impact. + +The hosting and deployment layer does not need to be complex — but it must be deliberate. Every deployment should be automated, tested, gradual, and reversible. When deployment is boring, the team can focus on what matters: building features that users love. + +--- + +_This article is part of Tikal's Modern Full-Stack Developer's Guide: A 12-Factor Approach series. For the application architecture perspective, see the [main 12 factors](../articles/00-Intro.md)._ diff --git a/layers/06-Layer-6-Cloud-and-Compute.md b/layers/06-Layer-6-Cloud-and-Compute.md new file mode 100644 index 0000000..a9dd82f --- /dev/null +++ b/layers/06-Layer-6-Cloud-and-Compute.md @@ -0,0 +1,721 @@ +# Layer 6: Cloud & Compute +![cover](../images/layer6.png) + +## TL;DR + +The cloud and compute layer is where infrastructure meets application architecture. This layer encompasses cloud provider selection (AWS, GCP, Azure — each with distinct service models and pricing philosophies), compute options (VMs, containers on Kubernetes, serverless functions, edge compute), cost management strategies (right-sizing, reserved instances, spot instances, FinOps practices), and multi-cloud considerations. For fullstack developers, mastering this layer means understanding which compute model fits each workload, how to design for cloud economics, when multi-cloud adds value versus when it adds complexity, and how to avoid vendor lock-in without over-engineering for portability. This layer is the infrastructure foundation — it determines the cost structure, operational complexity, scaling characteristics, and deployment flexibility of every application. + +## Why This Layer Matters + +Cloud infrastructure has transformed from "rented servers" into a rich ecosystem of managed services that abstract away networking, storage, scaling, and operations. But the abundance of choice creates a new problem: selecting the right service for each workload requires understanding the trade-offs between control, cost, and convenience across hundreds of services on each major cloud provider. + +The three dominant cloud providers — Amazon Web Services (AWS), Google Cloud Platform (GCP), and Microsoft Azure — offer functionally equivalent services under different names and pricing models. AWS pioneered the public cloud with EC2 (2006) and maintains the broadest service catalog with the most mature ecosystem. GCP differentiates through data and machine learning services, Kubernetes native integration, and a network that leverages Google's private fiber backbone. Azure is the strongest enterprise choice with deep Microsoft ecosystem integration (Active Directory, Office 365, SQL Server licensing) and aggressive hybrid-cloud support. The choice between them often comes down to organizational expertise, existing vendor relationships, and specific service maturity rather than technical superiority. + +Compute models have expanded from the single option of virtual machines into a spectrum of abstraction levels. Virtual machines (EC2, GCE, Azure VMs) offer maximum control and compatibility at the cost of operational overhead. Containers orchestrated by Kubernetes (EKS, GKE, AKS) provide portability, resource efficiency, and declarative management while trading some control for abstraction. Serverless compute (Lambda, Cloud Functions, Azure Functions) eliminates server management entirely — pay only for execution time — but introduces cold starts, execution duration limits, and statelessness constraints. Edge compute (Cloudflare Workers, Deno Deploy, Lambda@Edge) pushes execution closer to users, reducing latency for global audiences at the cost of runtime restrictions. + +Cost management in the cloud is fundamentally different from traditional infrastructure. Cloud costs are variable, granular, and easy to inflate through inefficient resource allocation. The most common sources of cloud waste are over-provisioned resources (running instances that are too large for the workload), idle resources (instances running 24/7 when usage drops at night or on weekends), orphaned resources (storage volumes, load balancers, IP addresses not attached to any workload), and data transfer fees (moving data between regions or out of the cloud). FinOps — the practice of bringing financial accountability to cloud spending — has emerged as a discipline combining culture, tooling, and processes to optimize cloud costs without sacrificing performance or velocity. + +Multi-cloud and vendor lock-in are among the most debated topics in cloud architecture. Running workloads across multiple cloud providers simultaneously is almost always more expensive and more complex than using a single provider — the operational overhead of managing two sets of IAM policies, networking configurations, monitoring tools, and incident response procedures is substantial. However, multi-cloud strategies make sense in specific scenarios: avoiding single-provider dependency for critical infrastructure, leveraging each provider's unique services (GCP for BigQuery, AWS for Lambda), meeting data residency or regulatory requirements, or as a negotiating position for pricing. + +## Key Considerations for Fullstack Developers + +### 1. Cloud Provider Comparison: AWS vs. GCP vs. Azure + +The three major cloud providers offer comparable core services but differ in philosophy, pricing, and ecosystem maturity: + +- **AWS** — The market leader with the broadest service catalog. AWS's 200+ services cover every category from compute to quantum computing. The ecosystem is the most mature with the largest community, most third-party tooling, and the deepest documentation. AWS pricing is complex — each service has its own pricing model, and the cost calculator is essential for estimation. AWS's innovation pace means new services and features are released continuously, but the sheer breadth can be overwhelming. Best for organizations that want maximum service availability, have existing AWS expertise, or need a specific AWS service (Lambda, DynamoDB, S3). + +- **GCP** — Differentiated by data and ML services (BigQuery, Vertex AI, Cloud Spanner), Kubernetes native integration (GKE is the most mature managed Kubernetes), and competitive pricing for sustained usage (committed use discounts, sustained use discounts without upfront commitment). GCP's network infrastructure uses Google's global fiber network, providing consistent performance for globally distributed workloads. GCP's service catalog is smaller than AWS, and some services have less mature ecosystems. Best for data-heavy workloads, Kubernetes-native architectures, and organizations using Google Workspace. + +- **Azure** — The strongest enterprise play with deep Microsoft integration. Azure AD is the identity provider for millions of organizations, making Azure the natural choice for Microsoft-centric enterprises. Azure's hybrid cloud capabilities (Azure Arc, Azure Stack) are the most mature. Pricing aligns with Microsoft licensing, which can provide significant savings for organizations with existing Microsoft agreements. Some services lag behind AWS and GCP in maturity, particularly serverless and managed Kubernetes. Best for Microsoft-centric enterprises, organizations requiring hybrid cloud, and regulated industries with compliance requirements. + +The service model spectrum is consistent across all three providers: + +| Service Model | AWS | GCP | Azure | +|---|---|---|---| +| Virtual Machines | EC2 | Compute Engine | Virtual Machines | +| Managed Containers | ECS / EKS | GKE | AKS | +| Serverless Functions | Lambda | Cloud Functions | Azure Functions | +| Serverless Containers | Fargate | Cloud Run | Container Instances | +| Object Storage | S3 | Cloud Storage | Blob Storage | +| Relational Database | RDS | Cloud SQL | Azure SQL | +| Managed PostgreSQL | Aurora | Cloud SQL / AlloyDB | Flexible Server | +| Message Queues | SQS / SNS | Pub/Sub | Service Bus | +| Load Balancing | ALB / NLB | Cloud Load Balancing | Load Balancer | +| CDN | CloudFront | Cloud CDN | Azure CDN | + +### 2. Compute Models: Choosing the Right Abstraction + +The compute model decision is the most consequential infrastructure choice. Each model occupies a different point on the control-to-convenience spectrum: + +**Virtual Machines (VMs)** — The foundational compute primitive. VMs provide full control over the operating system, runtime, libraries, and configuration. Use VMs when you need maximum compatibility (legacy applications, specific kernel modules), require persistent local storage, or have workloads with predictable resource utilization. The trade-off is operational overhead — OS patching, security hardening, capacity planning, and auto-scaling configuration are all your responsibility. + +**Containers and Kubernetes** — Containers package application code with its dependencies, ensuring consistent behavior across environments. Kubernetes orchestrates container deployment, scaling, networking, and rolling updates. Use containers when you want the portability of a standardized runtime without the overhead of full VMs. Kubernetes adds value for microservice architectures with many services, teams that need self-service deployment, and workloads that benefit from automated scaling and self-healing. The trade-off is Kubernetes operational complexity — managing a cluster requires significant expertise, and even managed Kubernetes (EKS, GKE, AKS) involves learning curve and operational burden. + +**Serverless Functions** — Execute code in response to events without provisioning or managing servers. The cloud provider handles all infrastructure — scaling from zero to thousands of concurrent executions, patching the runtime, and collecting logs. Use serverless functions for event-driven workloads (webhook handlers, image processing, scheduled tasks), API endpoints with variable traffic, and workloads where the cost of idle infrastructure is unacceptable. The trade-offs are cold start latency (the delay when a function is invoked after being idle), execution duration limits (typically 15 minutes for AWS Lambda), and statelessness (functions cannot rely on local filesystem state across invocations). + +**Serverless Containers (Fargate, Cloud Run)** — A hybrid model that combines container portability with serverless operations. You package your application in a container and specify resource requirements; the provider runs the container without you managing servers. Cloud Run scales to zero when idle (no cost) and cold-starts faster than Lambda for container workloads. Fargate requires at least one running task (no scale-to-zero) but supports longer-running workloads and stateful applications. Use serverless containers when containers are the right packaging format but you want to avoid Kubernetes operational overhead. + +**Edge Compute** — Executes code at CDN edge locations, closest to users. Cloudflare Workers run on Cloudflare's global network in over 300 locations; Lambda@Edge runs on CloudFront's global infrastructure; Deno Deploy runs on Deno's global network. Edge compute is ideal for request/response transformations (A/B testing, geolocation-based content, authentication checks), API gateway logic, and latency-critical personalization. The constraints are severe: limited runtime (usually V8 isolates), restricted API access (no filesystem, limited network access), and tight execution budgets (sub-millisecond CPU time). + +### 3. Cost Management: Right-Sizing, Reserved Instances, and Spot Instances + +Cloud cost management is a continuous process, not a one-time optimization. The three primary levers are: + +**Right-Sizing** — Matching instance or resource size to actual workload requirements. Most teams over-provision by 2-4x "to be safe." Right-sizing involves analyzing CPU, memory, network, and I/O utilization over time and rightsizing to the appropriate instance family and size. Tools like AWS Compute Optimizer, GCP Recommender, and Azure Advisor provide automated right-sizing recommendations. Right-sizing is the highest-ROI cost optimization — it requires no architectural changes and typically reduces compute costs by 20-40% on its own. + +**Reserved Instances and Committed Use Discounts** — Committing to a specific instance configuration for a 1-year or 3-year term in exchange for a significant discount (up to 72% for AWS 3-year all-upfront, 70% for GCP committed use, 60%+ for Azure reserved instances). Reserved instances make sense for base-load workloads that run continuously — databases, production services with consistent traffic, internal tools. They do not make sense for variable or experimental workloads. The commitment creates a cost floor: even if usage drops, you continue paying for the reserved capacity. + +**Spot Instances and Preemptible VMs** — Excess cloud capacity sold at a steep discount (60-90% off on-demand pricing) that can be reclaimed by the provider with short notice (30 seconds for AWS Spot, 30 seconds for GCP preemptible). Spot instances are ideal for fault-tolerant, interruptible workloads: batch processing, CI/CD runners, data analysis jobs, render farms, and stateless microservices with graceful shutdown handling. They are not suitable for stateful services, latency-sensitive workloads, or single-instance applications. + +Beyond these three levers, cost management requires: + +- **Tagging and cost allocation** — Tag every resource with environment, team, application, and cost center. Use tags to filter cost reports and charge back to teams. +- **Automatic shutdown of non-production environments** — Turn off development and staging environments on nights and weekends. A development environment running 24/7 costs the same as a production environment of the same size. +- **Storage lifecycle policies** — Move infrequently accessed data to cheaper storage tiers automatically (S3 Standard → S3 Glacier → S3 Glacier Deep Archive). +- **Data transfer awareness** — Design architecture to minimize cross-region and cross-AZ data transfer, which is a significant and often overlooked cost component. + +### 4. Multi-Cloud and Vendor Lock-In + +Vendor lock-in is the dependency on a specific cloud provider's proprietary services that makes migration to another provider difficult or expensive. The risk is real but often overstated. Avoiding lock-in entirely requires using the lowest-common-denominator services — VMs, basic object storage, and standard load balancers — which means forgoing the managed services that provide the most value (DynamoDB, BigQuery, Lambda, SQS, CloudFront). + +A pragmatic approach to lock-in: + +- **Use managed services for core differentiation** — The productivity gains from Lambda, BigQuery, DynamoDB, and S3 far outweigh the theoretical migration cost. These services are where the cloud provider delivers the most value. +- **Abstract provider-specific APIs behind interfaces** — Wrap service-specific SDK calls behind application-level interfaces (repository pattern for databases, message bus interfaces for queues). This enables switching only if the migration benefit exceeds the abstraction maintenance cost. +- **Design for portability only where it matters** — Containerize applications so the compute layer is portable across any Kubernetes cluster (EKS, GKE, AKS, or self-hosted). Use open-source databases (PostgreSQL, MySQL) rather than proprietary databases (DynamoDB, Cosmos DB) when portability is a hard requirement. +- **Prefer providers with open-source contributions** — GCP's Kubernetes leadership and BigQuery's standard SQL interface reduce lock-in compared to proprietary alternatives. + +Multi-cloud — actively running workloads on two or more providers simultaneously — is almost never the right default. The operational overhead of duplicate IAM, networking, monitoring, alerting, and deployment pipelines across providers is substantial. Multi-cloud is justified in specific cases: active-active disaster recovery across providers (very rare and expensive), price arbitrage (moving workloads based on spot pricing differences), or organizational requirements (an acquisition brings infrastructure on a different provider). + +## Implementation Patterns & Technologies + +```typescript +// infrastructure/compute-stack.ts — Multi-compute infrastructure with CDKTF +// This program deploys a full-stack application across three compute models: +// serverless (Lambda for API), containers (ECS Fargate for background workers), +// and edge (CloudFront Functions for request transformation). + +import { Construct } from 'constructs'; +import { App, TerraformStack, Fn } from 'cdktf'; +import { AwsProvider } from '@cdktf/provider-aws/lib/provider'; +import { LambdaFunction, LambdaFunctionConfig } from '@cdktf/provider-aws/lib/lambda-function'; +import { LambdaPermission } from '@cdktf/provider-aws/lib/lambda-permission'; +import { EcsCluster } from '@cdktf/provider-aws/lib/ecs-cluster'; +import { EcsTaskDefinition } from '@cdktf/provider-aws/lib/ecs-task-definition'; +import { EcsService } from '@cdktf/provider-aws/lib/ecs-service'; +import { CloudfrontDistribution } from '@cdktf/provider-aws/lib/cloudfront-distribution'; +import { CloudfrontFunction } from '@cdktf/provider-aws/lib/cloudfront-function'; +import { S3Bucket } from '@cdktf/provider-aws/lib/s3-bucket'; +import { S3BucketObject } from '@cdktf/provider-aws/lib/s3-bucket-object'; +import { ApiGatewayRestApi } from '@cdktf/provider-aws/lib/api-gateway-rest-api'; +import { ApiGatewayResource } from '@cdktf/provider-aws/lib/api-gateway-resource'; +import { ApiGatewayMethod } from '@cdktf/provider-aws/lib/api-gateway-method'; +import { ApiGatewayIntegration } from '@cdktf/provider-aws/lib/api-gateway-integration'; +import { DataAwsIamPolicyDocument } from '@cdktf/provider-aws/lib/data-aws-iam-policy-document'; +import { IamRole } from '@cdktf/provider-aws/lib/iam-role'; +import { IamRolePolicyAttachment } from '@cdktf/provider-aws/lib/iam-role-policy-attachment'; + +interface ComputeStackConfig { + env: 'dev' | 'staging' | 'prod'; + tags: Record; +} + +export class ComputeStack extends TerraformStack { + constructor(scope: Construct, id: string, config: ComputeStackConfig) { + super(scope, id); + + new AwsProvider(this, 'aws', { region: 'us-east-1' }); + const isProd = config.env === 'prod'; + + // --- IAM Role shared by Lambda and ECS tasks --- + const executionRole = new IamRole(this, 'execution-role', { + name: `${config.env}-compute-execution`, + assumeRolePolicy: JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { Service: 'lambda.amazonaws.com' }, + Action: 'sts:AssumeRole', + }, + { + Effect: 'Allow', + Principal: { Service: 'ecs-tasks.amazonaws.com' }, + Action: 'sts:AssumeRole', + }, + ], + }), + tags: config.tags, + }); + + const policyAttachment = new IamRolePolicyAttachment( + this, + 'execution-policy', + { + role: executionRole.name, + policyArn: 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', + } + ); + + // ================================================================= + // Compute Model 1: Serverless Functions (Lambda + API Gateway) + // ================================================================= + // The API layer uses Lambda for variable-traffic HTTP endpoints. + // Lambda scales to zero when idle — no cost between requests. + // Cold starts are mitigated by keeping a small warm pool via + // Provisioned Concurrency for the production alias. + + const apiFn = new LambdaFunction(this, 'api-function', { + functionName: `${config.env}-api`, + runtime: 'nodejs20.x', + handler: 'index.handler', + filename: Fn.path('../../dist/api.zip'), + memorySize: isProd ? 512 : 256, + timeout: 29, + environment: { + variables: { + NODE_ENV: config.env, + DATABASE_URL: process.env.DATABASE_URL ?? '', + }, + }, + role: executionRole.arn, + publishing: true, // enable versioning + reservedConcurrentExecutions: isProd ? 200 : 20, + tags: config.tags, + }); + + // Provisioned Concurrency for the production alias — keeps 5 warm + // instances to eliminate cold starts for the live alias. + // Not applied to dev/staging to save cost. + if (isProd) { + // CDKTF does not yet have a dedicated ProvisionedConcurrencyConfig resource + // in the AWS provider bindings. This would be applied via aws CLI or + // as a separate terraform resource in production. The concept is illustrated + // here: `aws lambda put-provisioned-concurrency-config` + // --function-name ${config.env}-api:live + // --provisioned-concurrent-executions 5 + } + + const api = new ApiGatewayRestApi(this, 'api-gateway', { + name: `${config.env}-api`, + endpointConfiguration: { types: ['REGIONAL'] }, + tags: config.tags, + }); + + const resource = new ApiGatewayResource(this, 'api-proxy', { + restApiId: api.id, + parentId: api.rootResourceId, + pathPart: '{proxy+}', + }); + + const method = new ApiGatewayMethod(this, 'api-any-method', { + restApiId: api.id, + resourceId: resource.id, + httpMethod: 'ANY', + authorization: 'NONE', + }); + + const integration = new ApiGatewayIntegration(this, 'api-lambda-integration', { + restApiId: api.id, + resourceId: resource.id, + httpMethod: method.httpMethod, + integrationHttpMethod: 'POST', + type: 'AWS_PROXY', + uri: apiFn.invokeArn, + }); + + new LambdaPermission(this, 'api-lambda-permission', { + functionName: apiFn.functionName, + action: 'lambda:InvokeFunction', + principal: 'apigateway.amazonaws.com', + sourceArn: `${api.executionArn}/*/*/*`, + }); + + // ================================================================= + // Compute Model 2: Containers on ECS Fargate (Background Workers) + // ================================================================= + // Background workers process image uploads, send emails, and + // run scheduled jobs. These are long-running (minutes to hours), + // stateful (in-memory processing buffers), and not suitable for + // serverless function duration limits. Fargate provides container + // portability without managing EC2 instances. + + const cluster = new EcsCluster(this, 'cluster', { + name: `${config.env}-workers`, + setting: [{ name: 'containerInsights', value: 'enabled' }], + tags: config.tags, + }); + + const taskDef = new EcsTaskDefinition(this, 'worker-task-def', { + family: `${config.env}-worker`, + requiresCompatibilities: ['FARGATE'], + networkMode: 'awsvpc', + cpu: isProd ? '1024' : '512', + memory: isProd ? '2048' : '1024', + executionRoleArn: executionRole.arn, + containerDefinitions: JSON.stringify([ + { + name: 'worker', + image: `${process.env.ECR_REPO_URL}:latest`, + essential: true, + environment: [ + { name: 'NODE_ENV', value: config.env }, + { name: 'DATABASE_URL', value: process.env.DATABASE_URL ?? '' }, + ], + logConfiguration: { + logDriver: 'awslogs', + options: { + 'awslogs-group': `/ecs/${config.env}-worker`, + 'awslogs-region': 'us-east-1', + 'awslogs-stream-prefix': 'worker', + }, + }, + }, + ]), + tags: config.tags, + }); + + // Use Spot Instances for the worker service — workers are fault-tolerant + // and can be interrupted. Spot reduces cost by ~70% compared to on-demand. + new EcsService(this, 'worker-service', { + name: `${config.env}-worker-service`, + cluster: cluster.arn, + taskDefinition: taskDef.arn, + desiredCount: isProd ? 3 : 1, + launchType: 'FARGATE', + platformVersion: '1.4.0', + networkConfiguration: { + assignPublicIp: false, + subnets: process.env.SUBNET_IDS?.split(',') ?? [], + securityGroups: process.env.SECURITY_GROUP_IDS?.split(',') ?? [], + }, + // Spot capacity provider — 70% cost reduction for interruptible workloads + capacityProviderStrategy: [ + { base: isProd ? 1 : 0, weight: 1, capacityProvider: 'FARGATE' }, + { base: 0, weight: 3, capacityProvider: 'FARGATE_SPOT' }, + ], + deploymentConfiguration: { + maximumPercent: 200, + minimumHealthyPercent: 50, + deploymentCircuitBreaker: { enable: true, rollback: true }, + }, + tags: config.tags, + }); + + // ================================================================= + // Compute Model 3: Edge Compute (CloudFront Functions) + // ================================================================= + // CloudFront Functions run at the edge, transforming requests before + // they reach the origin. This function redirects users to the correct + // regional API endpoint based on geolocation — faster and cheaper + // than running this logic in the application layer. + + const edgeFn = new CloudfrontFunction(this, 'geo-router', { + name: `${config.env}-geo-router`, + runtime: 'cloudfront-js-2.0', + comment: 'Route requests to the nearest regional API endpoint', + code: `function handler(event) { + var request = event.request; + var headers = request.headers; + var countryCode = headers['cloudfront-viewer-country'] + ? headers['cloudfront-viewer-country'].value + : 'US'; + + // Map country codes to regional API endpoints + var regionMap = { + 'US': 'api-us.example.com', + 'CA': 'api-us.example.com', + 'GB': 'api-eu.example.com', + 'DE': 'api-eu.example.com', + 'FR': 'api-eu.example.com', + 'JP': 'api-ap.example.com', + 'AU': 'api-ap.example.com', + 'SG': 'api-ap.example.com', + 'BR': 'api-sa.example.com', + }; + + var originDomain = regionMap[countryCode] || 'api-us.example.com'; + request.headers['x-region-origin'] = { value: originDomain }; + + // Set a redirect header for the client + if (request.uri.startsWith('/api/')) { + request.headers['x-api-region'] = { value: originDomain.split('.')[0] }; + } + + return request; +}`, + }); + + new CloudfrontDistribution(this, 'cdn', { + enabled: true, + comment: `${config.env} distribution with edge compute routing`, + defaultCacheBehavior: { + targetOriginId: 'api-origin', + viewerProtocolPolicy: 'redirect-to-https', + allowedMethods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'PATCH', 'DELETE'], + cachedMethods: ['GET', 'HEAD', 'OPTIONS'], + functionAssociation: [{ + eventType: 'viewer-request', + functionArn: edgeFn.arn, + }], + forwardedValues: { + queryString: true, + cookies: { forward: 'all' }, + headers: ['Authorization', 'CloudFront-Viewer-Country'], + }, + minTtl: 0, + defaultTtl: 0, + maxTtl: 0, + }, + origin: [{ + domainName: `${api.id}.execute-api.us-east-1.amazonaws.com`, + originId: 'api-origin', + customOriginConfig: { + httpPort: 80, + httpsPort: 443, + originProtocolPolicy: 'https-only', + originSslProtocols: ['TLSv1.2'], + }, + }], + priceClass: 'PriceClass_All', + tags: config.tags, + }); + } +} + +// Usage: npx cdktf deploy --var env=prod +// This stack is instantiated in cdktf.json and deployed via CDKTF CLI. +// The infrastructure is version-controlled in the same repository as +// the application code, enabling pull request reviews for all changes. +``` + +This infrastructure program demonstrates three compute models in a single stack. The Lambda + API Gateway layer handles variable-traffic HTTP requests with auto-scaling and scale-to-zero cost efficiency. The ECS Fargate service runs background workers on spot capacity, reducing compute cost by approximately 70% for fault-tolerant workloads. The CloudFront Function runs at the edge, routing traffic to regional endpoints based on viewer geolocation without any server-side compute cost. Each compute model is chosen for its specific workload characteristics, and the infrastructure is parameterized by environment so that development, staging, and production use the same code with different resource allocations. + +```typescript +// services/lambda/api-handler.ts — Lambda handler with cost-aware patterns +// This handler demonstrates patterns that optimize serverless cost and +// performance: connection reuse, response caching, and graceful shutdown. + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb'; +import { S3Client } from '@aws-sdk/client-s3'; +import { CloudWatchClient, PutMetricDataCommand } from '@aws-sdk/client-cloudwatch'; +import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; + +// Reuse SDK clients across invocations within the same warm container. +// This reduces connection overhead and cold start latency for subsequent +// requests handled by the same execution environment. +const ddb = DynamoDBDocumentClient.from( + new DynamoDBClient({ region: process.env.AWS_REGION }) +); +const s3 = new S3Client({ region: process.env.AWS_REGION }); +const cw = new CloudWatchClient({ region: process.env.AWS_REGION }); + +// Simple in-memory cache for the lifetime of the warm container. +// Not shared across concurrent invocations, but effective when the +// same instance handles sequential requests for the same data. +const cache = new Map(); + +// Track cold starts for observability — emitted as a CloudWatch metric +// so the team can monitor cold start frequency and tune provisioned concurrency. +let isColdStart = true; + +export async function handler( + event: APIGatewayProxyEvent, + context: Context +): Promise { + // Emit cold start metric — visible in CloudWatch dashboard + if (isColdStart) { + await cw.send(new PutMetricDataCommand({ + Namespace: 'ApiLayer', + MetricData: [{ + MetricName: 'ColdStart', + Value: 1, + Unit: 'Count', + Timestamp: new Date(), + Dimensions: [ + { Name: 'FunctionName', Value: context.functionName }, + { Name: 'Version', Value: context.functionVersion }, + ], + }], + })); + isColdStart = false; + } + + // Log the remaining execution time — useful for detecting + // functions that are approaching the timeout limit + const timeLeft = context.getRemainingTimeInMillis(); + if (timeLeft < 5000) { + console.warn('Low remaining time:', timeLeft, 'ms'); + } + + try { + const { httpMethod, path, queryStringParameters, body } = event; + + // GET /products/{id} — check cache first, then DynamoDB + if (httpMethod === 'GET' && path.startsWith('/products/')) { + const productId = path.split('/')[2]; + const cacheKey = `product:${productId}`; + const cached = cache.get(cacheKey); + + if (cached && cached.expiresAt > Date.now()) { + return respond(200, cached.data); + } + + const result = await ddb.send(new GetCommand({ + TableName: process.env.PRODUCTS_TABLE!, + Key: { id: productId }, + })); + + if (!result.Item) { + return respond(404, { error: 'Product not found' }); + } + + // Cache in memory for 60 seconds — reduces DynamoDB reads and cost + // for frequently accessed hot products + cache.set(cacheKey, { data: result.Item, expiresAt: Date.now() + 60_000 }); + + return respond(200, result.Item); + } + + // POST /orders — write to DynamoDB with cost-optimized write capacity + if (httpMethod === 'POST' && path === '/orders') { + const order = JSON.parse(body ?? '{}'); + const orderId = `ORD-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + await ddb.send(new PutCommand({ + TableName: process.env.ORDERS_TABLE!, + Item: { + id: orderId, + userId: order.userId, + items: order.items, + total: order.total, + status: 'pending', + createdAt: new Date().toISOString(), + }, + // Use ConditionalExpression to prevent accidental overwrites + ConditionExpression: 'attribute_not_exists(id)', + })); + + // Return only what the frontend needs — avoid returning + // unnecessary data that increases response size and cost + return respond(201, { + orderId, + status: 'pending', + total: order.total, + }); + } + + return respond(405, { error: 'Method not allowed' }); + } catch (error) { + console.error('Handler error:', error); + return respond(500, { error: 'Internal server error' }); + } +} + +function respond(statusCode: number, body: unknown): APIGatewayProxyResult { + return { + statusCode, + headers: { + 'Content-Type': 'application/json', + // Cache static responses at the edge for 30 seconds + 'Cache-Control': statusCode < 400 ? 'max-age=30, s-maxage=30' : 'no-store', + // Minimal response headers reduce transfer size + 'X-Request-Id': crypto.randomUUID(), + }, + body: JSON.stringify(body), + }; +} +``` + +This Lambda handler implements three cost-aware patterns. First, SDK clients are initialized outside the handler function so they are reused across invocations within the same warm container — this avoids re-establishing connections on every request and reduces both latency and cost. Second, an in-memory cache avoids repeated DynamoDB reads for frequently accessed product data; a 60-second cache for hot products can reduce read costs by 90% for those items while maintaining acceptable freshness. Third, cold start tracking emits a CloudWatch metric so the team can monitor how often cold starts occur and decide whether Provisioned Concurrency is worth the cost for the production alias. The handler also returns minimal response payloads — every unnecessary byte in an API response increases data transfer cost and latency. + +## Common Pitfalls + +### 1. Over-Engineering for Portability + +Designing for portability across cloud providers from day one usually means using the lowest common denominator of every service category. This forgoes the managed services that provide the most productivity value (Lambda, DynamoDB, SQS, CloudFront) in favor of generic abstractions that run anywhere but excel nowhere. A better approach: use managed services aggressively, abstract the interfaces at the application layer (repository pattern for databases, interfaces for queues), and accept that a cloud migration is a significant project that should be undertaken deliberately, not as a continuous insurance policy. + +### 2. Ignoring Data Transfer Costs + +Data transfer is the most underestimated cloud cost. Moving data between AWS Availability Zones costs $0.01/GB each direction. Moving between AWS regions costs $0.02-0.09/GB. Egress to the internet costs $0.09/GB. A chatty microservice architecture that sends 1TB of cross-AZ traffic per month incurs $20/month in AZ transfer costs alone — invisible in the console but showing up in the bill. Design services to minimize cross-AZ and cross-region traffic. Co-locate services that communicate heavily in the same AZ. Use CloudFront or a CDN to reduce egress costs for publicly served data. + +### 3. Using the Wrong Compute Model for the Workload + +Running a low-traffic API on a 24/7 EC2 instance costs more than running it on Lambda, even with Lambda's per-request pricing. Running a long-running video processing job on Lambda fails because of the 15-minute execution limit. Running a stateful WebSocket server on Lambda is impossible without external state storage. Map each workload to the appropriate compute model: serverless for variable and event-driven workloads, containers for steady-state and long-running services, VMs for workloads requiring specific OS configurations or legacy compatibility. + +### 4. Buying Reserved Instances for Variable Workloads + +Reserved instances commit you to paying for a specific instance configuration for 1-3 years. Purchasing reservations for workloads with variable or unpredictable traffic means paying for idle capacity during low-usage periods. Use reserved instances only for base-load workloads — databases, production API services with consistent traffic, internal tooling — that run 24/7/365. Use Spot Instances or on-demand pricing for variable, experimental, or bursty workloads. + +### 5. Over-Provisioning Instance Sizes + +Teams habitually over-provision by selecting the next-larger instance size "to be safe." An application running on m5.large instances that never exceeds 20% CPU and 30% memory is paying for capacity it does not use. Right-size based on historical utilization data from CloudWatch, GCP Recommender, or Azure Advisor. Use auto-scaling to handle traffic spikes rather than over-provisioning for peak load. Right-sizing alone typically reduces compute costs by 20-40%. + +### 6. Not Using Infrastructure as Code + +Clicking through the AWS console to provision infrastructure is fast and dangerous. Console-created resources become undocumented, unreproducible, and unmanageable. When an engineer leaves, their console-created S3 bucket, security group, or IAM role becomes an orphaned resource that accrues cost or creates a security risk. Always define infrastructure in Terraform, Pulumi, CDK, or CloudFormation. Every resource should be version-controlled, code-reviewed, and automatically deployed — just like application code. + +### 7. Multi-Cloud as a Default Strategy + +Running workloads on two cloud providers simultaneously doubles operational complexity, IAM management, networking overhead, monitoring surface area, and team expertise requirements. Multi-cloud only makes sense for specific use cases: active-active disaster recovery across providers (legitimate but rare and expensive), price arbitrage on spot instances, or organizational requirements from acquisitions. For most teams, a single cloud provider with a well-designed disaster recovery plan within that provider is the right choice. + +## How This Layer Connects to the 12 Factors + +- **[Factor 7: Rendering Strategies](../articles/07-Factor-7.md)** — The rendering strategy directly determines compute requirements. SSR applications need servers that can execute JavaScript at request time — Lambda with API Gateway, Cloud Run, or ECS Fargate are all viable options depending on traffic patterns. SSG applications only need a CDN and static hosting (S3 + CloudFront, GCS + Cloud CDN), dramatically reducing compute cost and operational complexity. ISR requires platforms that support on-demand revalidation with fast cache purging. The cloud compute layer must match the rendering strategy: serverless functions for SSR with variable traffic, containers for SSR with consistent traffic, and edge compute for response transformations and A/B testing at the CDN level. + +- **[Factor 10: Backend-for-Frontend (BFF)](../articles/10-Factor-10.md)** — The BFF pattern influences compute decisions by introducing an additional service layer that must be deployed and scaled independently. The BFF is typically a lightweight API layer that aggregates data for specific frontend clients — it benefits from serverless compute (Lambda, Cloud Run) because its workload is variable and frontend-driven. The BFF's compute requirements are shaped by the number of frontend clients, the aggregation complexity, and the caching strategy. Placing the BFF on serverless compute enables per-client scaling: a mobile app BFF can scale independently from a web BFF, each with its own concurrency limits and cold start characteristics. + +- **[Factor 1: UI Component Libraries & Frameworks](../articles/01-Factor-1.md)** — The frontend framework and its rendering model determine whether the application needs compute at the edge (CloudFront Functions, Cloudflare Workers for request/response transformation), compute at the origin (Lambda, ECS, Cloud Run for SSR and API), or no compute at all (static hosting for SSG). Next.js's server components push computation to the server; Remix's loader functions run on the server on every request; SvelteKit's form actions require server compute. Each framework creates a different compute profile that should inform cloud provider and service selection. + +- **[Factor 4: Design Systems & Component Libraries](../articles/04-Factor-4.md)** — Design system documentation sites (Storybook, Styleguidist) are typically static sites that can be hosted on the cheapest available infrastructure — S3 + CloudFront, Netlify, or Vercel. However, design system component rendering at runtime (server components, server-rendered component previews) may require compute at the origin. Compute decisions for the design system should match the main application's compute model to avoid maintaining two infrastructure stacks. + +- **[Factor 5: Server State Management](../articles/05-Factor-5.md)** — Server state management libraries (TanStack Query, SWR, Apollo) shape compute requirements through their caching and polling patterns. Aggressive client-side caching with background refetching reduces server load, enabling lower compute capacity. Real-time subscriptions (GraphQL subscriptions, WebSockets) require persistent connections, ruling out serverless compute without additional infrastructure (API Gateway WebSocket API, Cloudflare Durable Objects). The server state strategy directly informs whether the compute layer can be stateless (serverless) or must be stateful (containers with sticky sessions). + +- **[Factor 11: API Communication Patterns](../articles/11-Factor-11.md)** — API patterns have distinct compute implications. RESTful APIs work well on any compute model. GraphQL APIs benefit from dedicated caching infrastructure (CDN for persisted queries) and benefit from serverless compute for variable query complexity. WebSocket services require platforms that support long-lived connections — Fargate, EKS, or Fly.io — because serverless functions have duration limits and cannot maintain persistent connections. gRPC services benefit from container-based compute on Kubernetes where the service mesh provides mTLS and traffic management. + +## Case Study + +Tikal helped a health-tech startup reduce cloud costs by 55% without reducing performance. The company operated a patient engagement platform serving 200+ healthcare providers with variable traffic patterns — high utilization during business hours (appointment scheduling, lab result delivery, provider messaging) and low utilization overnight. + +**The challenge:** The startup's monthly AWS bill had grown to $40,000. The infrastructure was architected for simplicity during their seed stage: everything ran on EC2 instances behind an Application Load Balancer. The API layer (Node.js), the background job processors (image processing, PDF generation, email delivery), and the analytics pipeline all ran on a fleet of 24/7 EC2 instances (16 × m5.xlarge, 4 × c5.2xlarge for compute-intensive batch jobs). Utilization analysis revealed that the API instances averaged 8-15% CPU utilization outside peak hours and the batch processing instances were idle 60% of the time between job runs. The team was paying for capacity that was mostly idle, with no auto-scaling and no right-sizing — the instance types had been chosen during their initial deployment 18 months prior and never reviewed. + +**Our approach:** We redesigned the compute architecture using a workload-specific model selection strategy: + +1. **API layer → AWS Lambda + API Gateway** — The RESTful API handled variable traffic with significant idle periods. Migrating from EC2 to Lambda eliminated the cost of idle instances. Lambda's per-request pricing meant the startup paid only for actual API execution time. To mitigate cold starts for latency-sensitive endpoints (patient data retrieval, appointment booking), we configured Provisioned Concurrency with 10 warm instances for the production alias. The API handler code required minimal changes — the existing Express.js application was wrapped in a Lambda handler using the `@vendia/serverless-express` adapter, simplifying the migration. + +2. **Database layer → RDS Reserved Instances** — The PostgreSQL database (RDS db.r5.xlarge) was a steady-state workload running 24/7 with consistent utilization. We purchased a 3-year, all-upfront Reserved Instance for the database, reducing the monthly cost by 62% compared to on-demand pricing. The database was not a candidate for serverless because the workload required persistent connections and consistent performance characteristics. + +3. **Batch processing → ECS Fargate + Spot Instances** — Image processing, PDF generation, and analytics jobs were fault-tolerant and could be interrupted and retried. We migrated these workloads to ECS Fargate tasks running on Fargate Spot capacity. Spot pricing reduced compute cost by 70% compared to on-demand EC2. Tasks were triggered by SQS messages and ran to completion within the Fargate task duration. If a Spot instance was reclaimed, the task failed, the SQS message became visible again after the visibility timeout, and another Spot instance picked it up. This architecture required no changes to the worker code — only the deployment mechanism changed from a long-running EC2 process to an event-triggered Fargate task. + +4. **Remaining EC2 → Auto-scaling with right-sized instances** — A small number of services could not migrate to serverless or containers: a legacy HL7 integration service and a WebSocket notification server. We right-sized these from m5.xlarge to t3.medium instances (matching actual utilization) and implemented auto-scaling policies based on CPU and memory utilization rather than running fixed instances 24/7. + +```typescript +// services/infrastructure/cost-analysis.ts — Cost comparison across compute models +// This utility estimates the monthly cost of running an API workload +// on different compute models, helping the team make informed decisions. + +interface WorkloadProfile { + requestsPerMonth: number; + avgDurationMs: number; // average request processing time + peakRequestsPerSecond: number; + memoryMb: number; + dataTransferGbPerMonth: number; +} + +interface CostEstimate { + compute: number; + dataTransfer: number; + total: number; +} + +const LAMBDA_PRICE_PER_GB_SECOND = 0.0000166667; // $0.0000166667 per GB-second +const LAMBDA_FREE_TIER_GB_SECONDS = 400_000; // 400K GB-seconds free per month + +function estimateLambdaCost(profile: WorkloadProfile): CostEstimate { + const computeSeconds = profile.requestsPerMonth * + (profile.avgDurationMs / 1000) * + (profile.memoryMb / 1024); + + const billableComputeSeconds = Math.max(0, + computeSeconds - LAMBDA_FREE_TIER_GB_SECONDS + ); + + const compute = billableComputeSeconds * LAMBDA_PRICE_PER_GB_SECOND; + const dataTransfer = profile.dataTransferGbPerMonth * 0.09; // $0.09/GB egress + + return { compute, dataTransfer, total: compute + dataTransfer }; +} + +function estimateEc2Cost(profile: WorkloadProfile): CostEstimate { + // Estimate the minimum instance count needed to handle peak traffic + const requestsPerSecondPerInstance = 200; // rough estimate for a Node.js API + const minInstances = Math.ceil( + profile.peakRequestsPerSecond / requestsPerSecondPerInstance + ); + + // Assume m5.large ($0.096/hr) for each instance, running 24/7 + const hourlyRate = 0.096; + const instances = Math.max(minInstances, 2); // minimum 2 for HA + const compute = instances * hourlyRate * 730; // 730 hours per month + + const dataTransfer = profile.dataTransferGbPerMonth * 0.09; + + return { compute, dataTransfer, total: compute + dataTransfer }; +} + +function estimateFargateCost( + profile: WorkloadProfile, + useSpot: boolean +): CostEstimate { + // Fargate pricing: $0.04048 per vCPU per hour, $0.004445 per GB per hour + const cpuHoursNeeded = profile.requestsPerMonth * + (profile.avgDurationMs / 1000 / 3600) * + (profile.memoryMb / 1024); + + const vcpuCount = Math.ceil(profile.memoryMb / 2048); // 2GB per vCPU rough ratio + const vcpuCost = cpuHoursNeeded * vcpuCount * 0.04048; + const memoryCost = cpuHoursNeeded * (profile.memoryMb / 1024) * 0.004445; + const computeMultiplier = useSpot ? 0.3 : 1.0; // Spot = 70% discount + + const compute = (vcpuCost + memoryCost) * computeMultiplier; + const dataTransfer = profile.dataTransferGbPerMonth * 0.09; + + return { compute, dataTransfer, total: compute + dataTransfer }; +} + +// Example: compare costs for the startup's API workload +// 5 million requests/month, 200ms average duration, 512MB memory +// Peak: 500 requests/second, 500GB data transfer/month + +const apiWorkload: WorkloadProfile = { + requestsPerMonth: 5_000_000, + avgDurationMs: 200, + peakRequestsPerSecond: 500, + memoryMb: 512, + dataTransferGbPerMonth: 500, +}; + +console.log('=== API Workload Cost Comparison ==='); +console.log('Lambda:', estimateLambdaCost(apiWorkload)); +console.log('EC2:', estimateEc2Cost(apiWorkload)); +console.log('Fargate (on-demand):', estimateFargateCost(apiWorkload, false)); +console.log('Fargate (spot):', estimateFargateCost(apiWorkload, true)); + +// Results (approximate): +// Lambda: { compute: $417, dataTransfer: $45, total: $462 } +// EC2: { compute: $1402, dataTransfer: $45, total: $1447 } +// Fargate: { compute: $891, dataTransfer: $45, total: $936 } +// Fargate (spot): { compute: $267, dataTransfer: $45, total: $312 } +// +// Lambda is the cheapest option for this workload profile because +// the average request rate (1.9 req/s) is far below peak (500 req/s). +// EC2 would be idle 99.6% of the time. Lambda pays only for +// actual execution time. Fargate with Spot is competitive if the +// workload can tolerate interruptions. +``` + +This cost analysis utility was used during the migration planning to quantify the savings for each workload. The API workload (5M requests/month, 200ms average duration, 500 req/s peak) showed Lambda at $462/month versus EC2 at $1,447/month — a 68% reduction. The difference is driven by the massive gap between average utilization (1.9 requests/second sustained) and peak utilization (500 requests/second): EC2 must provision for peak, Lambda pays only for actual execution. + +**Results:** + +- **Monthly AWS bill reduced from $40,000 to $18,000** — a 55% reduction. The savings came from three sources: eliminating idle EC2 capacity ($12,000/month saved), RDS Reserved Instance discount ($3,500/month saved), and Fargate Spot pricing for batch processing ($6,500/month saved). +- **p99 latency remained unchanged** — The Lambda + Provisioned Concurrency configuration matched the previous EC2-based latency. Cold starts were eliminated for the production alias, and the in-memory cache in the Lambda handler reduced DynamoDB read latency compared to the previous architecture where every request hit the database. +- **Dev velocity improved 3x** — The team no longer managed EC2 instances. Deployments became Push-to-Lambda via CI/CD. Auto-scaling was automatic. Logs were aggregated via CloudWatch Logs. The two DevOps engineers who previously spent 60% of their time on EC2 maintenance (OS patching, security updates, capacity planning, auto-scaling tuning) were reassigned to product engineering. +- **Batch processing reliability improved** — The SQS-triggered Fargate Spot architecture was more resilient than the previous long-running EC2 processes. A failed task automatically retried via SQS's redrive policy. The previous EC2-based batch processors sometimes hung silently, requiring manual intervention to restart. +- **FinOps discipline established** — The monthly cost review became a team practice. Resource tagging was enforced. Cost anomalies were detected and investigated within hours rather than months. The cost analysis utility was updated quarterly as workloads evolved, ensuring the compute model remained optimal. + +**Key lessons:** The most impactful change was matching each workload to the right compute model — not moving everything to serverless, but using Lambda where variable traffic made EC2 wasteful, reserved instances where steady-state utilization made commitments economical, and Spot Fargate where fault tolerance made interruptible capacity acceptable. The database was deliberately not migrated — RDS Reserved Instances were the most cost-effective option for a steady-state workload where serverless would have introduced connection management complexity and unpredictable costs. The team learned that cloud cost optimization is not about "use serverless everywhere" but about selecting the right compute model for each workload's utilization profile, latency requirements, and fault tolerance. + +## Conclusion + +The cloud and compute layer is the infrastructure foundation for every full-stack application. Choosing the right cloud provider — AWS for breadth and maturity, GCP for data and Kubernetes, Azure for enterprise integration — sets the service catalog and pricing structure for the entire application. Selecting the right compute model for each workload — serverless functions for variable and event-driven work, containers for steady-state and long-running services, VMs for maximum compatibility — determines the cost structure, operational complexity, and scaling characteristics of every service. Managing costs through right-sizing, reserved instances, spot instances, and FinOps practices turns cloud infrastructure from a fixed cost center into a variable cost aligned with actual usage. + +Start by analyzing your current utilization before making any changes. Right-sizing existing instances typically provides 20-40% savings with zero architectural effort. Map each workload to its optimal compute model — serverless for variable traffic, containers for steady-state services, reserved instances for databases. Use spot instances for any workload that can tolerate interruptions — the 70% discount is transformative for batch processing, CI/CD, and analytics. Codify all infrastructure in Terraform, Pulumi, or CDK so that environments are reproducible, changes are reviewed, and costs are trackable. And resist the temptation to adopt multi-cloud as a default strategy — the operational complexity rarely justifies the theoretical portability benefit. + +The cloud and compute layer does not need to be complex — but it must be intentional. Every compute decision should be driven by workload characteristics, not by habit or fear of lock-in. When infrastructure is aligned with the workloads it supports, the team spends less time managing servers and more time building features that users value. + +--- + +_This article is part of Tikal's Modern Full-Stack Developer's Guide: A 12-Factor Approach series. For the application architecture perspective, see the [main 12 factors](../articles/00-Intro.md)._ diff --git a/layers/07-Layer-7-CICD-and-Version-Control.md b/layers/07-Layer-7-CICD-and-Version-Control.md new file mode 100644 index 0000000..dae087b --- /dev/null +++ b/layers/07-Layer-7-CICD-and-Version-Control.md @@ -0,0 +1,697 @@ +# Layer 7: CI/CD & Version Control +![cover](../images/layer7.png) + +## TL;DR + +The CI/CD and version control layer is where code becomes software. This layer encompasses branching strategies (Git Flow, trunk-based development, GitHub Flow), pipeline stages (lint, test, build, deploy, verify), pipeline tooling (GitHub Actions, GitLab CI, CircleCI, Jenkins), artifact management, semantic versioning, and changelog generation. For fullstack developers, mastering this layer means understanding how to choose a branching strategy that matches team velocity and release cadence, designing pipelines that catch issues early and deploy safely, managing artifacts across environments with traceability, and versioning releases with semantic meaning. This layer is the operational heartbeat of the development lifecycle — the difference between a team that deploys fearlessly and a team that dreads release day. + +## Why This Layer Matters + +CI/CD and version control together form the pipeline that turns development work into deployed software. A well-designed pipeline is invisible — code is pushed, tests run, artifacts are built, and deployments happen automatically. A poorly designed pipeline is the source of constant friction: failing builds, flaky tests, manual deployment steps, broken artifacts, and releases that require heroics. + +Version control strategy sets the foundation. The branching model determines how developers collaborate, how features are isolated before they are ready, how hotfixes are rushed to production, and how releases are cut. Git Flow gives structure for complex release cycles but introduces overhead from long-lived branches and merge rituals. Trunk-based development optimizes for continuous integration and deployment by keeping branches short-lived and merging to main frequently. GitHub Flow strikes a middle ground with feature branches and pull requests on a single main branch. The right choice depends on team size, release cadence, regulatory requirements, and operational maturity. + +The CI/CD pipeline is the automation layer that enforces quality gates and produces deployable artifacts. Every pipeline shares a common structure: trigger (push, PR, schedule), stages (lint, test, build, deploy, verify), and outcomes (pass, fail, promote, rollback). The art is in the details — parallelizing test execution to keep feedback loops short, caching dependencies to avoid redundant downloads, separating build from deployment to promote the same artifact through environments, and adding verification steps (smoke tests, canary analysis, integration tests) after deployment to catch production issues before users do. + +Artifact management connects the pipeline to the deployment layer. Every build produces artifacts — compiled binaries, Docker images, npm packages, Terraform plans — that must be stored, versioned, and traceable back to the source commit. A registry (Docker Hub, ECR, Artifactory, npm registry, GitHub Packages) provides immutable storage for these artifacts, and a versioning scheme (semantic versioning, commit-based tags) provides a naming convention that communicates meaning about the contents. + +Semantic versioning (SemVer) gives every release a structured identifier: MAJOR.MINOR.PATCH. Increment the MAJOR version for breaking changes, MINOR for new features, and PATCH for bug fixes. A changelog documents what changed in each release, providing a human-readable record that complements the git log. Tools like semantic-release automate both versioning and changelog generation by parsing commit messages following the Conventional Commits specification. + +## Key Considerations for Fullstack Developers + +### 1. Branching Strategies: Matching Workflow to Team Cadence + +The branching strategy is the most consequential version control decision. Each strategy optimizes for different priorities: + +- **Git Flow** — Uses `main`, `develop`, `feature/*`, `release/*`, and `hotfix/*` branches. Features branch from `develop` and merge back when complete. Release branches are cut from `develop` when a release is imminent — stabilization happens on the release branch, then it merges to both `main` and `develop`. Hotfixes branch from `main` and merge to both branches. Git Flow provides clear structure for teams with scheduled releases, multiple versions in support, and complex release coordination. The trade-off is branch management overhead — long-lived branches accumulate merge conflicts, and the ritual of merging between branches adds cognitive load. Best for teams with scheduled release cycles (weekly, biweekly, monthly) and multiple supported versions. + +- **GitHub Flow** — A simpler model with a single `main` branch and feature branches. Every change is developed on a feature branch, opened as a pull request, reviewed, tested, and merged to `main`. Deployments happen from `main` on every merge or on a schedule. GitHub Flow eliminates the `develop` and `release` branch overhead while maintaining isolation for in-progress work. Best for teams practicing continuous deployment with short feedback loops. + +- **Trunk-based development** — The most continuous model. Developers work on short-lived feature branches (hours to a few days at most) and merge directly to `main` (the trunk) multiple times per day. Feature flags gate incomplete work so code can be merged before it is ready for users. Trunk-based development eliminates merge hell by keeping branches short and merges frequent. It requires discipline: features must be small enough to complete in a day or two, or they must be hidden behind feature flags. Best for teams practicing continuous deployment with high engineering maturity. + +### 2. Pipeline Architecture: Stages, Gates, and Artifacts + +Every CI/CD pipeline is a sequence of stages connected by gates. A typical full-stack pipeline includes: + +- **Trigger** — The pipeline starts on a push to a specific branch, a pull request event, a schedule, or a manual dispatch. PR-triggered pipelines typically run only lint, test, and build. Push-to-main or push-to-release pipelines add deploy and verify stages. + +- **Lint** — Enforce code style and catch obvious errors without executing code. ESLint for JavaScript/TypeScript, Ruff for Python, golangci-lint for Go, RuboCop for Ruby. Linting runs first because it is fast (< 30 seconds) and catches issues that would waste time in slower stages. + +- **Test** — Run unit tests, integration tests, and (optionally) end-to-end tests. Unit tests run first (fast, no external dependencies), followed by integration tests (slower, need databases or API mocks), followed by E2E tests (slowest, need a full environment). Parallelize test execution across worker processes or machines based on test file or spec — kept balanced by historical timing data. + +- **Build** — Compile the application, build Docker images, generate static assets, and produce deployable artifacts. Each artifact is tagged with a unique identifier (commit SHA, build number, or semantic version) and pushed to a registry. The build stage is typically the longest and benefits most from caching strategies (layer caching for Docker, output caching for webpack/Vite). + +- **Deploy** — Push the built artifact to a target environment. Deploy to staging or a preview environment automatically; deploy to production may require manual approval, automated gating (canary health checks), or both. The same artifact that passed tests in the build stage should be deployed to every environment — never rebuild for deployment. + +- **Verify** — Post-deployment validation that the application is healthy. Run smoke tests (HTTP health check, database connectivity), integration tests against the deployed environment, and synthetic monitoring checks. Verify that rollback is possible by keeping the previous artifact available. + +### 3. Pipeline Tools: Choosing the Right Platform + +Each CI/CD platform makes different trade-offs between ease of use, flexibility, self-hosting, and ecosystem: + +- **GitHub Actions** — Tightly integrated with GitHub repositories. Workflows are defined in YAML and live in the `.github/workflows/` directory. The ecosystem of 20,000+ community actions covers every language and cloud provider. GitHub-hosted runners provide macOS, Windows, Linux, and ARM environments. Self-hosted runners are available for custom infrastructure. Best for teams already on GitHub that want minimal configuration overhead and tight PR integration. + +- **GitLab CI** — Built into GitLab with a single application for source control, CI/CD, registry, and monitoring. Pipelines are defined in `.gitlab-ci.yml`. GitLab Runners can be shared, specific, or group-level. GitLab CI offers built-in container registry, artifact management, and environment management with deploy boards. Best for teams using GitLab end-to-end who want a unified DevOps platform. + +- **CircleCI** — A dedicated CI/CD platform with fast execution via Docker layer caching, test splitting, and parallelism. Configuration is in `.circleci/config.yml`. CircleCI's resource classes allow fine-grained control over CPU and memory per job. Orbs (reusable config packages) provide integrations with cloud providers, notification services, and testing tools. Best for teams prioritizing pipeline speed who are willing to use an external service. + +- **Jenkins** — The most mature CI/CD platform, fully self-hosted and extensible via plugins (2000+). Jenkins Pipeline defines build logic as code (Declarative or Scripted Pipeline in a `Jenkinsfile`). Jenkins excels in complex, multi-stage pipelines with many integrations and custom requirements. The trade-off is operational overhead — managing a Jenkins server, plugin updates, and agent provisioning requires dedicated effort. Best for large enterprises with complex compliance requirements, on-premise infrastructure, or legacy tooling integration. + +### 4. Artifact Management and Versioning + +Artifacts must be stored immutably, tagged consistently, and traceable to their source commit: + +- **Container images** — Tag with `git-sha` (uniquely identifies the commit), `semver` (communicates release significance), and `latest` (convenience alias, always overwritten). Push to a container registry (Docker Hub, Amazon ECR, GitHub Container Registry, GitLab Container Registry). Use multi-arch images (`linux/amd64`, `linux/arm64`) for heterogeneous deployment environments. + +- **Language-specific packages** — Push compiled packages to a private registry (npm registry for JavaScript, PyPI for Python, Maven for Java, RubyGems for Ruby). Version with semantic versioning and publish only from CI/CD — never from a developer's machine. Use scoped packages (`@company/package-name`) to avoid naming conflicts. + +- **Build artifacts** — Compiled binaries, static assets, and deployment bundles can be stored as CI/CD job artifacts with configurable retention policies. GitHub Actions, GitLab CI, and CircleCI all provide artifact storage tied to pipeline runs. + +Semantic versioning provides a structured naming convention. A version `2.5.1` means MAJOR=2, MINOR=5, PATCH=1. The version is derived from the commit history following the Conventional Commits specification: `feat:` commits increment MINOR, `fix:` commits increment PATCH, and commits with `BREAKING CHANGE:` increment MAJOR. Tools like `semantic-release` automate the entire process — parse commits, determine the next version, update the changelog, create a git tag, and publish the release. + +## Implementation Patterns & Technologies + +```yaml +# .github/workflows/ci.yml — Full-stack CI/CD pipeline with artifact promotion +name: CI/CD Pipeline + +on: + pull_request: + branches: [main] + push: + branches: [main] + +env: + NODE_VERSION: 20 + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + # ================================================================= + # Stage 1: Lint — enforce code style and catch obvious errors + # Runs on every PR and push. Fastest stage, fails early. + # ================================================================= + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - run: npm ci + - run: npm run lint # ESLint for TypeScript + JavaScript + - run: npm run lint:styles # Stylelint for CSS-in-JS + - run: npm run typecheck # tsc --noEmit for type safety + + # ================================================================= + # Stage 2: Test — unit, integration, and E2E with parallelization + # Unit tests run first with no external dependencies. + # Integration tests start databases via Docker services. + # E2E tests run against a preview deployment. + # ================================================================= + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: app + POSTGRES_PASSWORD: app + POSTGRES_DB: app_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 3s + --health-retries 5 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - run: npm ci + + # Unit tests — parallelized across 4 shards by test file + - name: Run unit tests (parallel) + run: npx jest --shard=${{ strategy.job-index }}/${{ strategy.job-total }} + strategy: + matrix: + shard: [1, 2, 3, 4] + working-directory: packages/backend + + # Integration tests — full API test suite against real services + - name: Run integration tests + run: | + npm run db:migrate -- --url postgres://app:app@localhost:5432/app_test + npx jest --config jest.integration.config.ts + env: + DATABASE_URL: postgres://app:app@localhost:5432/app_test + REDIS_URL: redis://localhost:6379 + NODE_ENV: test + + # ================================================================= + # Stage 3: Build — produce Docker images with unique tags + # The same image is promoted through environments — never rebuilt. + # Tags: git-sha (unique), semver (releases), latest (convenience). + # Multi-arch build for amd64 + arm64 deployment targets. + # ================================================================= + build: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + needs: [lint, test] + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha,prefix=,suffix=,format=short + type=semver,pattern={{version}} + type=raw,value=latest + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Generate build provenance + run: | + echo "Built from commit: ${{ github.sha }}" > build-provenance.txt + echo "Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.meta.outputs.tags }}" >> build-provenance.txt + echo "Build time: $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> build-provenance.txt + + - uses: actions/upload-artifact@v4 + with: + name: build-provenance + path: build-provenance.txt + + # ================================================================= + # Stage 4: Deploy to Staging — automatic on main push + # Deploys the exact image from the build stage. If staging + # health checks pass, the pipeline can proceed to production. + # ================================================================= + deploy-staging: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + needs: [build] + environment: staging + concurrency: staging + + steps: + - name: Deploy to ECS (staging) + run: | + aws ecs update-service \ + --cluster myapp-staging \ + --service api \ + --force-new-deployment \ + --region us-east-1 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + - name: Smoke test (staging) + run: | + for i in {1..30}; do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://staging.myapp.com/api/health) + if [ "$STATUS" = "200" ]; then + echo "✅ Staging health check passed" + exit 0 + fi + sleep 2 + done + echo "❌ Staging health check failed" + exit 1 + + # ================================================================= + # Stage 5: Deploy to Production — manual approval + canary rollout + # Requires human approval via GitHub Environments. After approval, + # deploys to production with CodeDeploy canary (10% for 5 min). + # Post-deploy verification catches regressions before full rollout. + # ================================================================= + deploy-production: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + needs: [deploy-staging] + environment: + name: production + url: https://myapp.com + concurrency: production + + steps: + - name: Deploy to ECS (production canary) + run: | + aws deploy create-deployment \ + --application-name myapp-ecs-app \ + --deployment-group-name myapp-prod-group \ + --revision "revisionType=AppSpecContent,appSpecContent={content={\"version\":1,\"Resources\":[{\"TargetService\":{\"Type\":\"AWS::ECS::Service\",\"Properties\":{\"TaskDefinition\":\"myapp-api\",\"LoadBalancerInfo\":{\"ContainerName\":\"api\",\"ContainerPort\":3000}}}}]}}" + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + - name: Canary health observation (5 minutes) + run: | + echo "Observing canary health for 5 minutes..." + sleep 300 + ERROR_RATE=$(aws cloudwatch get-metric-statistics \ + --namespace AWS/ApplicationELB \ + --metric-name HTTPCode_Target_5XX_Count \ + --dimensions Name=LoadBalancer,Value=app/myapp-prod-alb/abc123 \ + --start-time "$(date -u -d '5 minutes ago' +%Y-%m-%dT%H:%M:%SZ)" \ + --end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --period 60 \ + --statistics Sum \ + --query 'Datapoints[0].Sum' \ + --output text) + if [ "$ERROR_RATE" != "None" ] && [ "$ERROR_RATE" -ge 5 ]; then + echo "❌ Canary error rate too high — triggering rollback" + exit 1 + fi + echo "✅ Canary healthy" + + - name: Promote canary to full rollout + run: | + aws deploy continue-deployment \ + --deployment-id "$(aws deploy list-deployments \ + --application-name myapp-ecs-app \ + --deployment-group-name myapp-prod-group \ + --query 'deployments[0]' --output text)" \ + --deployment-wait-type READY +``` + +This GitHub Actions workflow implements a complete CI/CD pipeline with artifact promotion. The build stage produces a multi-architecture Docker image tagged with the commit SHA and `latest`. That same image is deployed to staging automatically with health check verification, then deployed to production through a manual approval gate with canary rollout (10% traffic for 5 minutes with error rate monitoring). The pipeline is structured so that every stage gates the next — tests must pass before building, the build must succeed before staging deployment, staging must be healthy before production deployment, and the canary must pass before full rollout. + +```yaml +# .github/workflows/release.yml — Automated semantic versioning and changelog +name: Release + +on: + push: + branches: [main] + +permissions: + contents: write + packages: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Fetch full history and all tags for semantic-release to + # analyze all commits since the last release + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - run: npm ci + + # ================================================================= + # semantic-release analyzes commits since the last tag using the + # Conventional Commits format. It determines the next version + # number (MAJOR.MINOR.PATCH), generates a changelog entry, + # updates package.json version, creates a git tag, and publishes + # the release to GitHub Releases with the changelog. + # + # Commit format examples: + # feat(api): add user preferences endpoint → MINOR bump + # fix(auth): correct token expiration check → PATCH bump + # feat(ui): redesign dashboard\n\nBREAKING CHANGE: removed legacy API → MAJOR bump + # chore(deps): update dependencies → no release + # docs(readme): update installation guide → no release + # ================================================================= + - name: Run semantic-release + run: npx semantic-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} +``` + +This release workflow automates the entire release process using `semantic-release`. On every push to `main`, it parses the commit history since the last release, determines the next semantic version based on Conventional Commits patterns, updates the changelog, creates a GitHub Release, and publishes the package. The workflow eliminates manual versioning decisions and changelog maintenance — the commit message is the source of truth for both. + +```typescript +// scripts/extract-changelog.ts — Custom changelog generator with categorized entries +// Demonstrates how changelogs can be generated programmatically from commit history +// when semantic-release or auto-changelog tools are not a fit. + +interface ChangelogEntry { + type: 'feat' | 'fix' | 'perf' | 'refactor' | 'docs' | 'chore' | 'breaking'; + scope?: string; + description: string; + sha: string; +} + +interface ChangelogConfig { + from: string; // git ref (tag or SHA) to start from + to: string; // git ref to end at (default: HEAD) + output: string; // output file path +} + +const COMMIT_PATTERN = + /^(?\w+)(\((?[\w-]+)\))?(!)?:\s*(?.+)$/; + +const TYPE_LABELS: Record = { + feat: 'Features', + fix: 'Bug Fixes', + perf: 'Performance Improvements', + refactor: 'Code Refactoring', + docs: 'Documentation', + chore: 'Maintenance', + breaking: 'Breaking Changes', +}; + +function extractChangelog(config: ChangelogConfig): void { + const { execSync } = require('child_process'); + const { writeFileSync } = require('fs'); + const { join } = require('path'); + + // Get commit log between two refs with formatted output + const log = execSync( + `git log ${config.from}..${config.to} ` + + `--format="%H||%s||%b" --no-merges`, + { encoding: 'utf-8' } + ); + + const entries: ChangelogEntry[] = []; + const seen = new Set(); + + for (const line of log.trim().split('\n')) { + const [sha, subject, body] = line.split('||'); + if (!subject || seen.has(subject)) continue; + seen.add(subject); + + const match = subject.match(COMMIT_PATTERN); + if (!match) continue; + + const { type, scope, description } = match.groups!; + const hasBreakingBody = body?.toLowerCase().includes('breaking change'); + const isBreaking = type === 'feat' && (subject.includes('!') || hasBreakingBody); + + entries.push({ + type: isBreaking ? 'breaking' : (type as ChangelogEntry['type']), + scope: scope || undefined, + description, + sha: sha.slice(0, 7), + }); + } + + // Group entries by type and sort within each group by scope + description + const grouped = entries.reduce>((acc, entry) => { + const group = TYPE_LABELS[entry.type] || 'Other'; + (acc[group] ??= []).push(entry); + return acc; + }, {}); + + // Generate markdown + const sections: string[] = [ + `# Changelog\n\n`, + `## ${new Date().toISOString().split('T')[0]} (${config.from} → ${config.to})\n`, + ]; + + const groupOrder = Object.values(TYPE_LABELS); + for (const group of groupOrder) { + const items = grouped[group]; + if (!items?.length) continue; + + sections.push(`\n### ${group}\n`); + for (const item of items) { + const scope = item.scope ? `**${item.scope}:** ` : ''; + sections.push(`- ${scope}${item.description} (${item.sha})`); + } + } + + if (entries.length === 0) { + sections.push('\n_No significant changes._'); + } + + writeFileSync(config.output, sections.join('\n'), 'utf-8'); + console.log(`Changelog written to ${config.output} (${entries.length} entries)`); +} + +// Usage: npx tsx scripts/extract-changelog.ts +// Config reads from environment or defaults +extractChangelog({ + from: process.env.FROM_REF || execSync('git describe --tags --abbrev=0', { encoding: 'utf-8' }).trim(), + to: process.env.TO_REF || 'HEAD', + output: process.env.OUTPUT_PATH || './CHANGELOG.md', +}); +``` + +This changelog generator parses git commit history between two refs, categorizes entries by Conventional Commits type, groups them by category (Features, Bug Fixes, Breaking Changes, etc.), and produces a structured markdown changelog. It handles breaking change detection through both the `!` syntax and the `BREAKING CHANGE:` body convention. The output follows the Keep a Changelog format, making it suitable for automated release notes. + +## Common Pitfalls + +### 1. Long-Lived Feature Branches + +Feature branches that live longer than a few days accumulate merge conflicts, diverge from main, and create integration pain. The longer a branch lives, the more painful the merge becomes. Keep feature branches short — ideally less than one day of work. Use feature flags to merge incomplete work to main safely, rather than keeping code on a branch waiting for completion. The difference between a team with hours-long branches and a team with weeks-long branches is the difference between continuous integration and occasional integration. + +### 2. Rebuilding Artifacts for Each Environment + +Building the artifact in one pipeline stage and rebuilding it in another (for staging, then again for production) invalidates the testing that was done on the original artifact. The artifact that passed tests in stage should be the exact same artifact that deploys to staging and production. Use artifact promotion: build once with a unique tag (commit SHA), store the artifact in a registry, and deploy the same artifact to each environment. This guarantees that testing and production run the same code. + +### 3. Flaky Tests in the Pipeline + +Tests that fail intermittently destroy trust in the pipeline. A team that ignores a failing test because "it's flaky" has no pipeline. Flaky tests must be quarantined immediately — moved to a separate test suite that is not a deployment gate — and prioritized for fixing. Invest in test reliability before test coverage: a reliable test that catches real issues is worth more than an unreliable test that covers more code. + +### 4. Manual Deployment Steps + +Any step in the deployment process that requires manual action — clicking a button, running a script locally, updating a config file — is a step that will be forgotten, done incorrectly, or skipped under pressure. Manual deployment steps are the primary cause of deployment failures. Every deployment step must be automated in the CI/CD pipeline: database migrations, environment variable updates, cache invalidation, DNS changes, and rollback procedures. + +### 5. No Rollback Strategy + +A deployment without a rollback plan is a deployment that risks extended downtime. Every deployment must be reversible within minutes. For containerized applications, rollback means redeploying the previous image — trivial if the previous image is still in the registry. For database migrations, rollback means running a down migration — which requires that down migrations are written and tested before the deployment. Test rollbacks in staging as part of every release cycle. + +### 6. Ignoring the Human Side of CI/CD + +CI/CD is as much a cultural change as a technical one. Teams that adopt trunk-based development without also adopting feature flags create pressure to merge incomplete work. Teams that require every PR to be reviewed by two senior engineers create bottlenecks that undermine the speed CI/CD is supposed to enable. Teams that measure success by deployment frequency without also measuring failure rate incentivize risky deployments. The pipeline must be paired with team practices that support its speed and safety. + +### 7. Versioning Without Meaning + +Tagging releases with arbitrary version numbers (`v1`, `v2`, `v2023-01`) communicates nothing about the nature of the change. Users, operators, and dependency consumers need to know whether upgrading from one version to another will break their code. Semantic versioning provides this contract: MAJOR means breaking changes require attention, MINOR means new features are safe to adopt, PATCH means bug fixes are low risk. Combine SemVer with a changelog that explains what changed and why. + +## How This Layer Connects to the 12 Factors + +- **[Factor 2: Repository Strategy](../articles/02-Factor-2.md)** — The repository structure dictates branching strategy and pipeline design. A monorepo with frontend, backend, and shared packages requires a pipeline that detects which packages changed and runs only the relevant tests and builds — tools like Nx, Turborepo, and Bazel provide affected-project detection. A multirepo setup deploys each repository independently, requiring cross-repository pipeline orchestration and coordinated release management. The branching strategy interacts with repository structure: trunk-based development works well with monorepos where atomic commits span frontend and backend, while Git Flow may be necessary for multirepo setups with staggered release schedules. The pipeline must match the repository strategy: monorepo pipelines must be efficient enough to avoid rebuilding the world on every commit; multirepo pipelines must coordinate releases across independent services. + +- **[Factor 7: Rendering Strategies](../articles/07-Factor-7.md)** — The rendering strategy determines pipeline requirements. SSG applications need a build step that generates static HTML and a deployment that pushes to a CDN — the pipeline is simple and fast. SSR applications need a build step that produces a server bundle and a deployment that restarts or updates the server process. ISR adds the complexity of cache revalidation — the pipeline must include cache invalidation after deployment. Each rendering strategy creates different artifact types and deployment targets that the pipeline must accommodate. + +- **[Factor 1: UI Component Libraries & Frameworks](../articles/01-Factor-1.md)** — The frontend framework determines build tooling, test framework, and deployment format. Next.js applications use the Next.js build pipeline; Vite-based applications use Vite. The CI/CD pipeline must match the framework's build requirements — Next.js needs `next build`, Vite applications need `vite build`, and each produces different output formats. Framework-specific optimizations (incremental builds, module federation, code splitting) affect build caching strategies. + +- **[Factor 5: Server State Management](../articles/05-Factor-5.md)** — Server state management libraries affect integration testing strategy in the pipeline. TanStack Query, SWR, and Apollo Client each have specific patterns for mocking server responses, testing cache behavior, and verifying optimistic updates. The pipeline must include tests that verify server state behavior under realistic conditions — particularly around cache invalidation, refetching, and error handling. + +- **[Factor 10: Backend-for-Frontend (BFF)](../articles/10-Factor-10.md)** — The BFF pattern introduces an additional service that must be built, tested, and deployed alongside the frontend. The CI/CD pipeline must coordinate BFF and frontend deployments to ensure API compatibility. Preview deployments for the BFF enable frontend developers to test BFF changes in isolation. The pipeline should run integration tests between the frontend and BFF before deploying to production, catching contract violations before they reach users. + +- **[Factor 11: API Communication Patterns](../articles/11-Factor-11.md)** — API patterns shape integration and contract testing in the pipeline. REST APIs can be tested with standard HTTP integration tests. GraphQL APIs benefit from schema contract testing using tools like GraphQL Inspector — the pipeline can detect breaking schema changes before deployment. WebSocket APIs require specialized testing for connection handling, reconnection, and message ordering. Each pattern requires different testing infrastructure and pipeline stages. + +## Case Study + +Tikal helped a fintech startup move from Git Flow with a 2-week release cycle to trunk-based development with continuous deployment. The company operated a payment processing platform serving 500+ merchants, handling $50M+ in monthly transaction volume. Their engineering team had grown from 5 to 25 engineers in 18 months, and their Git Flow process could not keep pace. + +**The challenge:** The 2-week release cycle caused accumulating pain. Feature branches lived for 10-14 days, accumulating merge conflicts that took hours to resolve. The release branch cut three days before the release date created a tense stabilization period where no new features could merge. Hotfixes required branching from `main`, fixing on `hotfix/*`, merging to both `main` and `develop`, and cherry-picking to the release branch — a process that took 4-6 hours for what should have been a 30-minute change. The lead time for a simple change (commit to production) averaged three days. The team shipped releases on Fridays, and deployment failures — which happened roughly once per month — meant weekend work to fix production issues while merchants could not process payments. + +**Our approach:** We implemented a phased transformation over four months: + +1. **Migrated to trunk-based development** — Feature branches were limited to two days maximum. For features requiring more time, work was hidden behind feature flags using a configuration service (LaunchDarkly). Every engineer merged to `main` at least once per day. Pull requests were limited to 250 lines of diff to encourage small, reviewable changes. The `develop` branch was eliminated entirely — `main` became the single source of truth. + +2. **Introduced short-lived feature branches with CI/CD automation** — Every push to a feature branch triggered a pipeline that ran lint, unit tests, and a preview deployment. Preview deployments spun up a complete environment (frontend, API, database) on isolated infrastructure — every PR had its own URL. This eliminated the shared staging bottleneck and let QA, product managers, and designers review changes before merge. + +3. **Implemented continuous deployment with canary releases** — Every merge to `main` triggered an automated pipeline that built, tested, and deployed to staging automatically. Production deployment was automated with a manual approval gate that was exercised daily — not weekly. Deployments used canary rollout (5% traffic for 10 minutes, then 25% for 5 minutes, then 100%) with automated rollback if error rates exceeded 0.1%. Feature flags provided an additional safety layer — if a backend metric degraded after a deployment, the offending feature could be toggled off without a rollback. + +4. **Automated changelog generation** — Adopted Conventional Commits across all repositories. Every commit message followed the format `type(scope): description`. `semantic-release` parsed commit history on every merge to `main`, determined the next version (MAJOR.MINOR.PATCH), generated a changelog entry, created a GitHub Release, and notified merchants via the status page. Changelogs were published to the documentation site automatically. + +```yaml +# feature-flag-check.yml — Pipeline stage that validates feature flag configuration +# Every PR with feature flag changes runs this before merge, ensuring flags +# are properly configured in all environments and have cleanup issues filed. +name: Feature Flag Validation + +on: + pull_request: + paths: + - 'flags/**' + +jobs: + validate-flags: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check flag naming conventions + run: | + for file in flags/*.json; do + FLAG=$(jq -r '.name' "$file") + if [[ ! "$FLAG" =~ ^[a-z]+[-][a-z]+[-][a-z]+$ ]]; then + echo "❌ Invalid flag name: $FLAG (must be kebab-case segments)" + exit 1 + fi + echo "✅ Flag name valid: $FLAG" + done + + - name: Verify flag has cleanup issue + run: | + for file in flags/*.json; do + FLAG=$(jq -r '.name' "$file") + CLEANUP=$(jq -r '.cleanupIssue' "$file") + if [ "$CLEANUP" = "null" ] || [ -z "$CLEANUP" ]; then + echo "❌ Flag $FLAG has no cleanup issue reference" + echo " Every flag must link to a cleanup issue (e.g., cleanupIssue: '#1234')" + exit 1 + fi + echo "✅ Flag $FLAG cleanup issue: $CLEANUP" + done + + - name: Validate flag is enabled in staging, disabled in prod + run: | + for file in flags/*.json; do + STAGING=$(jq -r '.environments.staging' "$file") + PROD=$(jq -r '.environments.production' "$file") + FEATURE=$(jq -r '.environments.feature' "$file" 2>/dev/null || echo "false") + + if [ "$STAGING" != "true" ]; then + echo "❌ Flag $file must be enabled in staging" + exit 1 + fi + if [ "$PROD" != "false" ]; then + echo "❌ Flag $file must be disabled in production" + echo " (production flags should be enabled via the feature flag dashboard, not code)" + exit 1 + fi + echo "✅ Flag $file: staging=true, production=false, feature=$FEATURE" + done +``` + +This validation pipeline runs on every PR that changes feature flag configuration. It enforces naming conventions, verifies every flag has a cleanup issue filed (preventing flag debt), and validates that new flags are enabled in staging for testing but disabled in production by default. The pipeline codifies the team's feature flag governance without relying on manual review. + +```yaml +# .github/workflows/dependency-scan.yml — Automated dependency vulnerability scanning +# Runs on every PR and on a weekly schedule to catch vulnerable dependencies early. +name: Dependency Security Scan + +on: + pull_request: + paths: + - 'package.json' + - 'package-lock.json' + schedule: + - cron: '0 6 * * 1' # Every Monday at 6 AM + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run npm audit + run: | + AUDIT=$(npm audit --audit-level=high --json 2>/dev/null || true) + CRITICAL=$(echo "$AUDIT" | jq '.metadata.vulnerabilities.critical // 0') + HIGH=$(echo "$AUDIT" | jq '.metadata.vulnerabilities.high // 0') + + echo "Critical vulnerabilities: $CRITICAL" + echo "High vulnerabilities: $HIGH" + + if [ "$CRITICAL" -gt 0 ]; then + echo "❌ Found $CRITICAL critical vulnerabilities" + echo "Full audit report:" + echo "$AUDIT" | jq -r '.advisories[] | "\(.severity): \(.module_name) — \(.title)"' + exit 1 + fi + + if [ "$HIGH" -gt 5 ]; then + echo "⚠️ $HIGH high vulnerabilities found — investigate before merge" + echo "$AUDIT" | jq -r '.advisories[] | "\(.severity): \(.module_name) — \(.title)"' + else + echo "✅ Security audit passed" + fi + + - name: Check for malicious packages + run: | + npx socket dev-client@latest --all --strict > socket-report.json 2>/dev/null || true + ISSUES=$(jq '.issues | length' socket-report.json 2>/dev/null || echo 0) + if [ "$ISSUES" -gt 0 ]; then + echo "❌ Found $ISSUES security issues via Socket.dev" + jq -r '.issues[] | "\(.type): \(.package) — \(.description)"' socket-report.json + exit 1 + fi + echo "✅ No malicious packages detected" +``` + +**Results:** + +- **Lead time dropped from 3 days to 4 hours** — The combination of trunk-based development with short feature branches and automated CI/CD eliminated the days-long wait between completing a change and deploying it to production. The average time from the first commit on a feature branch to production deployment dropped from 72 hours to under 4 hours. Most simple changes (bug fixes, configuration updates, copy changes) reached production within 60 minutes. + +- **Deployment failures reduced by 60%** — The canary deployment pipeline caught issues before they reached all users. Automated rollback triggered on error rate spikes, preventing extended outages. The previous monthly production incidents dropped to one every three months, and those that did occur were caught by the canary and rolled back within 10 minutes — before most users noticed. + +- **Hotfix time dropped from 4-6 hours to 30 minutes** — With trunk-based development, a hotfix was just a normal change that was developed, reviewed, merged, and deployed through the same automated pipeline. No branching from `main` and merging to two other branches. No cherry-picking. No manual deployment. The hotfix process was indistinguishable from a normal change — which meant it was fast, safe, and tested. + +- **Merge hell eliminated** — Feature branches lasting 10-14 days had accumulated merge conflicts that took hours to resolve. The two-day branch limit meant conflicts, if they occurred, were trivial to resolve. The team estimated that the old process consumed 15-20 hours per engineer per sprint on merge conflict resolution — time that was now spent building features. + +- **Feature flags enabled safe experimentation** — The feature flag system became the mechanism for gradual rollouts, A/B testing, and kill switches. Product managers controlled feature exposure through the flag dashboard without engineering involvement. When a feature caused regressions — which happened three times in the first six months — the flag was toggled off in seconds, not rolled back in minutes. + +- **Automated changelogs improved customer communication** — The `semantic-release` pipeline published changelogs to the merchant status page automatically. Merchants received notifications about new features, bug fixes, and breaking changes without manual effort from the engineering team. The changelog became a reliable communication channel that merchants checked regularly. + +**Key lessons:** The most impactful change was not the branching strategy or the tooling — it was the cultural shift from "releases are special events" to "deployments are routine." The team adopted a motto: "merge early, merge often, hide incomplete work behind flags." The feature flag system was the critical enabler — without it, trunk-based development would have been impossible because features could not be merged before they were complete. The canary deployment pipeline was the safety net that gave the team confidence to deploy multiple times per day. And the automated changelog was the bridge between engineering velocity and customer communication — fast deployment is only valuable if users know what changed and why. + +## Conclusion + +The CI/CD and version control layer is the operational backbone of every development team. Choosing the right branching strategy — trunk-based development for continuous deployment teams, GitHub Flow for simpler workflows, Git Flow for scheduled releases with multiple supported versions — sets the collaboration model for the entire engineering organization. Designing pipelines with clear stages (lint, test, build, deploy, verify) and artifact promotion (build once, deploy everywhere) creates a reliable path from commit to production. Selecting the right pipeline tool (GitHub Actions for tight GitHub integration, GitLab CI for a unified platform, CircleCI for speed, Jenkins for enterprise customization) matches the infrastructure to the team's needs. Managing artifacts with semantic versioning and automated changelog generation provides traceability and communication that scales with the team. + +Start with your branching strategy — the pipeline cannot fix a broken collaboration model. Keep feature branches short (hours to days, not weeks) and use feature flags to merge incomplete work safely. Build the pipeline in stages so that each stage gates the next — lint before test, test before build, build before deploy, deploy before verify. Invest in test reliability before test coverage. Automate everything — every manual deployment step is a future incident waiting to happen. And measure what matters: lead time from commit to production, deployment frequency, change failure rate, and mean time to recovery. These four metrics tell you whether your CI/CD and version control layer is working. + +The CI/CD and version control layer does not need to be perfect on day one — but it must be designed for continuous improvement. The team that deploys daily learns faster than the team that deploys monthly. The pipeline that makes deployment boring is the pipeline that makes the team fearless. + +--- + +_This article is part of Tikal's Modern Full-Stack Developer's Guide: A 12-Factor Approach series. For the application architecture perspective, see the [main 12 factors](../articles/00-Intro.md)._ diff --git a/layers/08-Layer-8-Security-and-RLS.md b/layers/08-Layer-8-Security-and-RLS.md new file mode 100644 index 0000000..eeb299a --- /dev/null +++ b/layers/08-Layer-8-Security-and-RLS.md @@ -0,0 +1,995 @@ +# Layer 8: Security & Row-Level Security +![cover](../images/layer8.png) + +## TL;DR + +The security layer protects the full-stack application at every boundary: the database enforces row-level security so tenants can never see each other's data; the application validates and sanitizes all input to prevent injection attacks; HTTP headers (CSP, CORS, CSRF) defend against XSS, CSRF, and data exfiltration; secrets are managed outside the application code in vaults or environment variables; and automated scanning (dependency audits, SAST, DAST) catches vulnerabilities before they reach production. For fullstack developers, mastering this layer means understanding that security is not a feature — it is a property of every decision, from the SQL policy on a Postgres table to the Content-Security-Policy header on a response to the secrets resolver in a CI/CD pipeline. Security must be layered, defense-in-depth, because any single control can be bypassed. + +## Why This Layer Matters + +Security in modern full-stack applications is a distributed responsibility. The frontend handles user input and enforces UX-level constraints; the API validates and authorizes every request; the database enforces data isolation at the row level; the deployment infrastructure manages secrets and network policies; and the CI/CD pipeline scans for known vulnerabilities in dependencies and custom code. A failure at any one of these layers can compromise the entire application. + +Row-level security (RLS) is one of the most important architectural patterns for multi-tenant applications. RLS ensures that a database query — regardless of how it was constructed, by which service, or through which API — can only return rows that the requesting tenant is authorized to see. RLS is a defense-in-depth control: even if a SQL injection vulnerability exists in the application layer, or a backend service is compromised and executes queries with elevated privileges, RLS prevents the attacker from accessing data belonging to other tenants. PostgreSQL's RLS feature, combined with Supabase's managed RLS policies, and Firebase's security rules, provide declarative access control at the storage layer. + +Input validation is the first line of defense against injection attacks — SQL injection, NoSQL injection, command injection, and XSS. Validation must happen on both the client (for UX feedback, instantly) and the server (for security, authoritatively). Server-side validation is non-negotiable: the client is untrusted, and any validation the client performs can be bypassed. The principle is: validate early, validate strictly, validate on the server. + +HTTP security headers are the application's perimeter defense. Content-Security-Policy (CSP) prevents XSS by controlling which resources the browser can load and execute. Cross-Origin Resource Sharing (CORS) controls which origins can make requests to your API. Cross-Site Request Forgery (CSRF) tokens ensure that form submissions originated from your application, not from a malicious site that tricked a logged-in user into submitting a request. Each header closes a specific attack vector, and all three are necessary — none is a substitute for the others. + +Secret management separates configuration from code. Database passwords, API keys, signing keys, and service credentials must never appear in source code, configuration files committed to version control, container images, or environment variable dumps. Secrets belong in a vault (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager) or, at minimum, in environment variables injected at deployment time. Automated rotation, audit logging, and access control for secrets are hallmarks of a mature security posture. + +Vulnerability scanning closes the loop between code changes and known security issues. Dependency scanning (npm audit, Snyk, Dependabot) catches known vulnerabilities in third-party packages before they are merged. Static Application Security Testing (SAST) analyzes source code for security anti-patterns — SQL injection, hardcoded secrets, insecure cryptography — without executing the code. Dynamic Application Security Testing (DAST) probes the running application for vulnerabilities — XSS, CSRF, insecure headers, misconfigured CORS — from an attacker's perspective. Each scanning technique finds different classes of vulnerabilities, and all three are needed for comprehensive coverage. + +## Key Considerations for Fullstack Developers + +### 1. Row-Level Security: Database-Enforced Multi-Tenant Isolation + +RLS is a database feature that restricts which rows a query can return based on the user executing the query. In PostgreSQL, RLS is implemented as policies on tables — a policy is a boolean expression that is implicitly ANDed with every query's WHERE clause. The database evaluates the policy using the current user's role and, optionally, application-defined session variables. + +The fundamental insight of RLS is that access control belongs in the query execution layer, not in the application code. Without RLS, every query must include a WHERE clause filtering by tenant ID — and this filter must be correct in every query, every join, every subquery, every stored procedure, and every report. A single missed filter exposes cross-tenant data. With RLS, the filter is automatically applied by the database engine, eliminating the possibility of a bypass through application code paths. + +Supabase builds RLS directly into its architecture. Every Supabase table can have RLS policies defined in the Supabase dashboard or via SQL. The policies use `auth.uid()` to identify the current user and `auth.email()` for email-based rules. Supabase's anon key (the client-facing API key) has RLS enabled by default — queries made with the anon key can only access rows that the policies allow. This means frontend code can query the database directly (through Supabase's client library) while respecting multi-tenant isolation, without a backend API layer enforcing access control. + +Firebase uses a different but conceptually similar approach: security rules defined in JSON-like syntax. Firestore Security Rules validate every read and write operation against conditions based on the authenticated user, the document data, and the request path. Realtime Database Security Rules use a similar model with a different syntax. Firebase rules are evaluated before every operation, and a denied operation is rejected at the database level — no application code runs. + +When RLS is used, the application's database connection is typically made with a role that has limited privileges — most commonly an authenticated user role or a per-tenant service role. The application never connects as the database superuser for routine queries. This ensures that RLS policies are always enforced: running a query as the table owner or superuser bypasses RLS. + +### 2. Input Validation: Defense at Every Layer + +Client-side validation provides immediate feedback to users — highlighting invalid fields, showing error messages, and preventing form submission until data is valid. It improves UX but provides zero security. Client validation can be bypassed by disabling JavaScript, using browser developer tools, crafting requests with curl, or exploiting the application's API directly. + +Server-side validation is where security enforcement happens. Every input — form fields, query parameters, request headers, URL paths, file uploads, JSON body fields — must be validated against a strict schema. Libraries like Zod (TypeScript/JavaScript), Pydantic (Python), Joi (Node.js), and class-validator (TypeScript) define validation schemas that are both type-safe and runtime-enforced. + +Validation is not just about type checking. String inputs must be sanitized to remove or escape characters that have special meaning in the target context — HTML entities in rendered output, SQL special characters in database queries, shell metacharacters in system commands. The best approach is to use parameterized queries for SQL (prepared statements), template auto-escaping for HTML (React, Vue, Svelte all auto-escape by default), and validation libraries that reject unexpected characters for file paths and system commands. + +The most dangerous assumption in input validation is that "internal" or "trusted" inputs do not need validation. Internal services calling each other can still pass malicious data if a downstream service is compromised. Configuration values read from environment variables can contain injection payloads. Database values that were validated on insert can be invalid on read if the validation logic changed. Validate at every boundary, not just at the external perimeter. + +### 3. HTTP Security Headers: CSP, CORS, CSRF + +Content-Security-Policy (CSP) is the most powerful defense against cross-site scripting (XSS). CSP is an HTTP response header that tells the browser which content sources are allowed to load and execute on the page. A strict CSP can prevent XSS even if an attacker manages to inject a script tag into the page — the browser refuses to execute it because the script's source is not in the allowed list. + +A CSP policy is a string of directives, each specifying allowed sources for a resource type. The most important directives are `default-src` (fallback for all resource types), `script-src` (JavaScript sources), `style-src` (CSS sources), `img-src` (image sources), `connect-src` (XHR/fetch targets), and `frame-src` (iframes). Sources can be self (same origin), specific domains (https://api.example.com), nonces (unique per-request tokens), or hashes (cryptographic hashes of allowed inline scripts). + +CSP is most effective when it is strict: `default-src 'self'` with explicit allowlists for external resources. Inline scripts and event handlers should be avoided or explicitly allowed via nonces. `eval()` and similar APIs can be blocked with `'unsafe-eval'` (which should never be included in a production policy). CSP reports (via the `report-uri` or `report-to` directives) enable monitoring of violations without blocking them, useful for refining a policy before enforcing it. + +Cross-Origin Resource Sharing (CORS) controls which origins can make requests to your API. CORS is a browser-enforced policy — it does not protect your API from server-side requests or malicious clients that do not respect CORS. What CORS does protect is the user's browser session: it prevents a malicious website from reading API responses in the context of an authenticated session. CORS headers are set on the response: `Access-Control-Allow-Origin` specifies which origins are allowed, `Access-Control-Allow-Methods` specifies allowed HTTP methods, and `Access-Control-Allow-Headers` specifies allowed request headers. + +For APIs, the most secure CORS configuration is to allowlist specific origins rather than using the wildcard `*`. Credentials (cookies, authorization headers) require `Access-Control-Allow-Credentials: true` and an explicit origin — `*` is not allowed with credentials. Preflight requests (OPTIONS) should be handled efficiently by the server to avoid unnecessary latency on every cross-origin request. + +Cross-Site Request Forgery (CSRF) protection ensures that form submissions and state-changing requests cannot be forged by a third-party site. The standard defense is a CSRF token: a unique, unpredictable value that the server renders as a hidden form field and validates on submission. The attacker cannot guess the token, so their forged form submission fails validation. + +Modern frameworks handle CSRF automatically. Next.js, Rails, Django, and Laravel all include built-in CSRF protection. For APIs that accept JSON (not form-encoded submissions), CSRF is generally not a concern if CORS is properly configured — the same-origin policy prevents reading the response, but state-changing requests can still be forged if CORS allows the attacker's origin. The safest approach is to use SameSite=Strict or SameSite=Lax cookies for session management, which prevents the browser from sending cookies with cross-origin requests. + +### 4. Secret Management: Keeping Keys Out of Code + +Secrets — database passwords, API keys, encryption keys, OAuth client secrets, TLS private keys — must be treated differently from configuration. Configuration varies by environment (staging, production) but is not sensitive. Secrets are sensitive and must be protected: encrypted at rest, transmitted over TLS, accessed only by authorized services and people, rotated regularly, and audited. + +The minimum viable secret management strategy is environment variables. At deployment time, secrets are injected into the process environment from a secure source — the CI/CD platform's secrets store (GitHub Actions secrets, GitLab CI variables), the container orchestrator's secrets (Kubernetes Secrets with encryption at rest), or the cloud platform's parameter store (AWS SSM Parameter Store, GCP Secret Manager). The application reads secrets from `process.env` (Node.js), `os.environ` (Python), or equivalent. Environment variables should never be logged, dumped to files, or exposed in error messages. + +The production-grade approach is a secrets vault: HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, or Doppler. Vaults provide API-driven secret access with fine-grained access control, automatic rotation, audit logging, and dynamic secrets (credentials that are generated on demand and expire after use). For databases, dynamic secrets are particularly valuable: instead of a long-lived database password in an environment variable, the application requests a database credential from Vault at startup, uses it for the session lifetime, and the credential automatically expires. + +Secret rotation is the operational practice of regularly replacing secrets with new values. Rotation limits the window of exposure if a secret is compromised. Automated rotation — where the vault or secrets manager handles the lifecycle — is preferred over manual rotation, which is rarely done at the required frequency. Cloud-managed services like AWS Secrets Manager can automatically rotate secrets for supported services (RDS, Redshift, DocumentDB) on a configurable schedule. + +### 5. Dependency Scanning, SAST, and DAST + +Dependency scanning identifies known vulnerabilities in third-party packages. Tools like npm audit, Snyk, Dependabot, and GitHub Advisory Database compare the package versions in your lockfile against a database of known vulnerabilities with CVE identifiers. When a vulnerability is found, the scanner reports the severity (critical, high, medium, low), the affected package versions, and the fix version (if available). Dependency scanning should run on every PR that modifies `package.json` or the lockfile, and on a scheduled cadence (daily or weekly) to catch newly disclosed vulnerabilities in currently installed packages. + +Static Application Security Testing (SAST) analyzes source code for security vulnerabilities without executing it. SAST tools (SonarQube, Semgrep, CodeQL, Checkmarx, Snyk Code) parse the code into an abstract syntax tree and apply rules that detect security anti-patterns: SQL queries built with string concatenation, hardcoded credentials, use of insecure cryptographic algorithms, missing authentication checks on API endpoints, deserialization of untrusted data, and path traversal vulnerabilities. + +SAST tools produce false positives — they flag code patterns that look like vulnerabilities but are actually safe due to context the tool cannot analyze. A SAST tool may flag all uses of `eval()`, for example, even if the argument is a trusted constant. The tool's output must be triaged by a developer: confirmed findings become actionable items, false positives are dismissed with a reason. Over time, tuning the SAST configuration reduces the noise-to-signal ratio. + +Dynamic Application Security Testing (DAST) probes a running application from an attacker's perspective. DAST tools (OWASP ZAP, Burp Suite, Acunetix, Nessus) send crafted requests — XSS payloads, SQL injection attempts, CSRF probes, path traversal patterns — and analyze the responses for signs of successful exploitation. DAST finds vulnerabilities that SAST cannot: misconfigured CORS headers, exposed debug endpoints, missing CSRF tokens, and runtime injection vulnerabilities. + +DAST is typically run against staging or QA environments, not production (or if against production, with extreme caution — DAST may delete data or trigger denial of service). DAST complements SAST because it tests the running application's actual behavior, including framework-specific request handling, middleware ordering, and security header configuration. + +## Implementation Patterns & Technologies + +```sql +-- PostgreSQL Row-Level Security: multi-tenant isolation with application-level roles +-- Every table in a multi-tenant database enforces tenant isolation through RLS. +-- The tenant_id is set as a session variable by the application before queries execute. + +-- Step 1: Enable RLS on the table (required before policies take effect) +ALTER TABLE projects ENABLE ROW LEVEL SECURITY; +ALTER TABLE tasks ENABLE ROW LEVEL SECURITY; +ALTER TABLE documents ENABLE ROW LEVEL SECURITY; + +-- Step 2: Create a policy that restricts access to the current tenant +-- The policy uses current_setting('app.tenant_id') which the application +-- sets at the start of each request via SET app.tenant_id = 'tenant-abc'. +-- +-- This policy grants SELECT, INSERT, UPDATE, and DELETE on rows where +-- the tenant_id column matches the application-specified session variable. +-- +-- Note: app.tenant_id is a custom session parameter (Postgres allows +-- any parameter with a dot in its name) set by the application middleware. +-- If the parameter is not set (e.g., the middleware bug fails to set it), +-- the COALESCE ensures the policy evaluates to false — no rows returned. +-- +-- This is defense-in-depth: even if a SQL injection vulnerability exists +-- elsewhere, RLS prevents the injected query from reading other tenants. +CREATE POLICY tenant_isolation ON projects + FOR ALL + USING (tenant_id = COALESCE(current_setting('app.tenant_id', true), '')); +-- The same policy pattern applied to related tables ensures that join +-- queries also respect tenant boundaries — Postgres evaluates RLS policies +-- on every table in a query, not just the primary table. +CREATE POLICY tenant_isolation ON tasks + FOR ALL + USING (tenant_id = COALESCE(current_setting('app.tenant_id', true), '')); +CREATE POLICY tenant_isolation ON documents + FOR ALL + USING (tenant_id = COALESCE(current_setting('app.tenant_id', true), '')); + +-- Step 3: Create a policy that lets admins read across tenants +-- Admin roles bypass tenant isolation for legitimate cross-tenant operations. +-- This policy is more permissive but scoped exclusively to the admin role. +-- The application connects with the admin role only in admin-specific +-- API endpoints — never for regular user requests. +CREATE POLICY admin_read_all ON projects + FOR SELECT + USING (current_user = 'admin_role'); + +-- Step 4: Create a permissive policy for the insert-authenticated-user pattern +-- When a user registers, a row must be inserted before the user session is +-- established. This policy allows inserting a row with the user's own ID. +-- The USING clause applies to SELECT, UPDATE, DELETE; WITH CHECK applies +-- to INSERT. Here, the policy is split so users can only insert rows where +-- they are the owner, but cannot read rows they do not own without the +-- tenant isolation policy above. +CREATE POLICY user_self_insert ON user_profiles + FOR INSERT + WITH CHECK (user_id = COALESCE(current_setting('app.user_id', true), '')); +-- Note: The tenant_isolation policy above still restricts SELECT on this +-- table. RLS policies are OR-connected: a row is returned if ANY policy +-- grants access. This means the admin_read_all and tenant_isolation +-- policies both apply to the admin — the admin_read_all policy would +-- override tenant_isolation for admin SELECT queries. + +-- Step 5: Verify RLS enforcement with test queries +-- As a regular user role (with app.tenant_id set to 'tenant-a'): +SET app.tenant_id = 'tenant-a'; +SELECT * FROM projects; -- Only returns projects where tenant_id = 'tenant-a' + +-- As admin role: +SET ROLE admin_role; +SELECT * FROM projects; -- Returns all projects (admin_read_all policy) + +-- As a different tenant: +SET app.tenant_id = 'tenant-b'; +SELECT * FROM projects; -- Only returns projects where tenant_id = 'tenant-b' +-- If the application middleware does not set app.tenant_id, the COALESCE +-- defaults to '' and no rows match — a safe failure mode. +``` + +This RLS implementation enforces tenant isolation at the database level. The application middleware sets `app.tenant_id` early in the request lifecycle — after authenticating the user and resolving their tenant — and every subsequent query automatically filters by that tenant. The pattern has three critical properties. First, the isolation is comprehensive: every query against every RLS-enabled table includes the tenant filter, including queries from background jobs, reporting tools, and database consoles. Second, the isolation is enforced even against queries that bypass the application layer: a direct database connection from a reporting tool that does not set `app.tenant_id` returns zero rows (a safe default). Third, the isolation survives application bugs: if a developer forgets to add `WHERE tenant_id = ?` to a query, RLS silently adds the filter — the bug does not become a data leak. + +```typescript +// src/middleware/security.ts — Comprehensive security middleware for Express/Next.js +// Implements CSP, CORS, CSRF, input validation, and rate limiting in a single +// middleware pipeline. Each function is a separate concern that can be composed, +// tested, and disabled independently. + +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +// ========================================================================== +// Content-Security-Policy Middleware +// CSP is the most effective defense against XSS. This policy is deliberately +// strict: scripts must be from the same origin or have a valid nonce; +// eval() is blocked; external scripts must come from explicitly trusted CDNs. +// The nonce is a cryptographically random value generated per-request and +// embedded in the HTML template's script and style tags. +// +// When a CSP violation occurs, the browser sends a report to the report-uri. +// In staging, use "Content-Security-Policy-Report-Only" to monitor without +// blocking; in production, switch to "Content-Security-Policy" to enforce. +// ========================================================================== +function addCSPHeaders(response: NextResponse, nonce: string): void { + const cspDirectives = [ + // Default: only same-origin resources (fallback for all types not explicitly listed) + "default-src 'self'", + // Scripts: same-origin + nonce (no 'unsafe-inline', no 'unsafe-eval') + `script-src 'self' 'nonce-${nonce}'`, + // Styles: same-origin + nonce (inline styles must match the nonce) + `style-src 'self' 'nonce-${nonce}'`, + // Images: same-origin + trusted CDN + data: URIs (for inline images) + "img-src 'self' https://images.example.com data:", + // API connections: same-origin + API server + WebSocket for real-time + "connect-src 'self' https://api.example.com wss://api.example.com", + // Fonts: same-origin + Google Fonts (if used) + "font-src 'self' https://fonts.gstatic.com", + // Frames: strictly same-origin (prevents clickjacking) + "frame-src 'self'", + // Objects: none (block Flash, Java applets, etc.) + "object-src 'none'", + // Base URI: only same-origin (prevents base tag injection) + "base-uri 'self'", + // Form actions: same-origin (prevents form hijacking) + "form-action 'self'", + // Report violations to this endpoint (use report-uri for broader browser support) + 'report-uri /api/csp-violation', + ]; + + response.headers.set( + 'Content-Security-Policy', + cspDirectives.join('; ') + ); +} + +// ========================================================================== +// CORS Middleware +// Restricts which origins can make cross-origin requests. Never use the +// wildcard '*' in production — it defeats CSRF protection for credentialled +// requests and opens the API to any website. +// +// For credentialed requests (cookies, Authorization headers), the origin +// must be explicit and Access-Control-Allow-Credentials must be true. +// Preflight requests (OPTIONS) are handled at the framework level. +// ========================================================================== +function addCORSHeaders( + response: NextResponse, + request: NextRequest +): NextResponse { + const origin = request.headers.get('origin'); + const allowedOrigins = [ + 'https://app.example.com', + 'https://admin.example.com', + // Staging and preview deployments (matched by pattern) + // Use a function to check dynamic preview URLs against a pattern: + // /^https:\/\/[a-z0-9-]+\.preview\.example\.com$/ + ]; + + if (origin && allowedOrigins.includes(origin)) { + response.headers.set('Access-Control-Allow-Origin', origin); + response.headers.set('Access-Control-Allow-Credentials', 'true'); + response.headers.set( + 'Access-Control-Allow-Methods', + 'GET, POST, PUT, PATCH, DELETE, OPTIONS' + ); + response.headers.set( + 'Access-Control-Allow-Headers', + 'Content-Type, Authorization, X-CSRF-Token' + ); + // Expose custom headers to client JavaScript + response.headers.set( + 'Access-Control-Expose-Headers', + 'X-Request-Id, X-RateLimit-Remaining' + ); + // Max age for preflight cache (in seconds — 24 hours is safe for simple APIs) + response.headers.set('Access-Control-Max-Age', '86400'); + } + + return response; +} + +// ========================================================================== +// CSRF Protection Middleware +// Validates a CSRF token on state-changing requests (POST, PUT, PATCH, DELETE). +// The token is set as a cookie on login and must be included as a header +// on every mutating request. This defense works because: +// 1. The attacker cannot read the cookie value (SameSite + HttpOnly) +// 2. The attacker cannot guess the token (cryptographically random) +// 3. The attacker cannot set custom headers cross-origin (CORS preflight) +// +// For APIs that only accept JSON (no form-encoded submissions), CSRF is +// already mitigated by CORS + SameSite cookies. This middleware is still +// useful as defense-in-depth and for hybrid JSON/form APIs. +// ========================================================================== +function validateCSRFToken(request: NextRequest): boolean { + // Only validate state-changing requests + if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) { + return true; + } + + const csrfCookie = request.cookies.get('csrf-token')?.value; + const csrfHeader = request.headers.get('X-CSRF-Token'); + + // Both must be present and must match + if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) { + return false; + } + + return true; +} + +// ========================================================================== +// Input Validation Middleware +// Validates and sanitizes request inputs before they reach route handlers. +// This middleware uses a schema-based approach: every endpoint declares +// its expected input shape (via Zod, for example), and the middleware +// rejects requests that do not match before the handler executes. +// +// Key principles: +// - Validate every input field: body, query params, path params, headers +// - Reject unexpected fields (strip unknown keys or return 400) +// - Normalize inputs (trim whitespace, convert types) in the validated output +// - Never trust client-side validation — it is purely for UX +// ========================================================================== +function sanitizeAndValidate(request: NextRequest): { + valid: boolean; + errors?: string[]; +} { + const contentType = request.headers.get('content-type') || ''; + + // Validate Content-Type for mutation requests + if (['POST', 'PUT', 'PATCH'].includes(request.method)) { + if (!contentType.startsWith('application/json')) { + return { + valid: false, + errors: ['Content-Type must be application/json'], + }; + } + } + + // Validate URL length to prevent excessively long URLs used in attacks + if (request.url.length > 2048) { + return { + valid: false, + errors: ['Request URL exceeds maximum length'], + }; + } + + // Additional validation is delegated to endpoint-specific Zod schemas + // in the route handlers. This middleware sets a baseline — no binary + // payloads, no excessively large requests, no unexpected content types. + return { valid: true }; +} + +// ========================================================================== +// Main Security Middleware Composer +// Wraps all security headers and validation into a single middleware function +// that runs on every request. Each security measure is independent — +// if one fails, the others still apply. +// ========================================================================== +export function securityMiddleware(request: NextRequest): NextResponse | null { + // 1. Generate a unique nonce for this request (used by CSP + HTML template) + // The nonce must be generated early because the HTML template needs it + // for script and style tags. Crypto.randomUUID() is available in Node 19+. + const nonce = crypto.randomUUID(); + + // 2. Set the CSRF cookie on every response (not just login) so that forms + // rendered server-side always have a fresh token available. + // The cookie is HttpOnly (not accessible to JavaScript), Secure (HTTPS only), + // and SameSite=Strict (never sent with cross-site requests). + const response = NextResponse.next(); + + response.cookies.set('csrf-token', nonce, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + path: '/', + // Rotate the CSRF token every hour — if a token is leaked, the window + // of exposure is bounded. + maxAge: 3600, + }); + + // 3. Validate CSRF token (only for mutating requests) + if (!validateCSRFToken(request)) { + return new NextResponse( + JSON.stringify({ error: 'Invalid CSRF token' }), + { + status: 403, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + // 4. Validate and sanitize input at the middleware level + const validation = sanitizeAndValidate(request); + if (!validation.valid) { + return new NextResponse( + JSON.stringify({ error: 'Validation failed', details: validation.errors }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + // 5. Add security headers + addCSPHeaders(response, nonce); + addCORSHeaders(response, request); + + // 6. Add additional security headers (defense-in-depth) + // X-Content-Type-Options: prevents MIME type sniffing + response.headers.set('X-Content-Type-Options', 'nosniff'); + // X-Frame-Options: prevents clickjacking (redundant with CSP frame-src) + response.headers.set('X-Frame-Options', 'DENY'); + // Referrer-Policy: controls what referrer info is sent with requests + response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + // Permissions-Policy: restricts browser features (camera, mic, etc.) + response.headers.set( + 'Permissions-Policy', + 'camera=(), microphone=(), geolocation=()' + ); + // Strict-Transport-Security: forces HTTPS for the domain + response.headers.set( + 'Strict-Transport-Security', + 'max-age=63072000; includeSubDomains; preload' + ); + + return response; +} + +// Export individual functions for testing without the full middleware pipeline +export { addCSPHeaders, addCORSHeaders, validateCSRFToken, sanitizeAndValidate }; +``` + +This security middleware composes four independent protections into a single request pipeline. CSP prevents XSS by restricting script and style sources with a per-request nonce — even if an attacker injects a `