WEB API's Lesson 36 – Project Planning | Dataplexa
Web APIs · Lesson 36

Project Planning

Scope, phase, and document the Final Project before writing a single line of code — so you build the right thing in the right order with a clear definition of done.

Most developers start coding immediately and plan as they go. That works for small scripts. For an API with auth, versioning, rate limiting, pagination, webhooks, and documentation — it produces something half-finished, inconsistently designed, and impossible to test systematically. The planning phase is not bureaucracy. It is the work that makes the coding fast.

The APIForge Product team is about to build the final version of the APIForge platform API — a production-ready system that combines everything covered in this course. Before the Engineering team writes anything, Product runs a planning session: define the scope, break it into phases, write the API contract, and set the acceptance criteria that will tell everyone when the project is done.

This lesson walks through that planning session step by step. By the end, you will have a complete project plan for the Final Project in Lesson 37 — and a planning framework you can apply to any API project you build after this course.

Planning Session Brief
WHAT IS BEING PLANNED
The APIForge Platform API v1 — a complete REST API covering auth, projects, members, search, file attachments, and webhooks.
WHO IS IN THE ROOM
Product (scope and acceptance criteria), Engineering (feasibility and effort), Security (auth and data handling requirements).
OUTPUT OF THIS LESSON
A phased project plan, a full API contract (endpoints, methods, request/response shapes), and acceptance criteria for every feature.
WHY THIS MATTERS
An API contract written before coding means no design debates mid-build, no endpoint naming inconsistencies, and a clear test target for every route.

Step 1 — Define the Scope: What Is In and What Is Out

Every project plan starts with a scope decision, and scope decisions are always about what you are deliberately leaving out. An API with no defined boundaries grows without limit until the deadline forces a scramble. The APIForge team uses a simple rule: if a feature is not in the v1 plan, it is not being built in Lesson 37. It goes on the v2 backlog.

The team uses a two-column exercise: write everything the API could possibly do on the left, then move only the items that are strictly necessary for a working v1 to the right. Anything on the left that is not moved becomes a documented out-of-scope item — not forgotten, just scheduled for later.

Out of Scope — v2 Backlog
Team workspaces and multi-tenant isolation
Billing and subscription management
Real-time events via WebSockets
Third-party OAuth integrations (GitHub, Slack)
Advanced analytics and usage dashboards
Admin impersonation and support tools
GraphQL endpoint
In Scope — v1 Final Project
JWT authentication (register, login, refresh)
Projects CRUD with cursor pagination
Members management (invite, update, remove)
Full-text search across projects
File attachments via pre-signed URLs
Outbound webhooks for key events
API versioning and structured error responses
Why document what is out of scope?
Without a written out-of-scope list, every stakeholder conversation becomes a negotiation about whether feature X "was always part of the plan." A documented backlog item has a version attached to it — it is not being ignored, it is scheduled. That distinction prevents scope creep while keeping stakeholders informed.

Step 2 — Break the Work Into Phases

Phasing is about ordering work so that each phase produces something testable and usable on its own. If Phase 1 is not working, Phase 2 does not start. This constraint forces the team to build foundations before features — authentication before any protected route, database schema before any data endpoint.

The APIForge team identifies four phases. Each phase has a clear deliverable and a definition of done — a specific test that can be run to confirm the phase is complete before moving on.

APIForge Final Project — Four Phases
1
Phase 1 — Foundation (Days 1–2)
Project setup, database schema, environment config, base Express server, error handler, request logger. Done when: server starts, health check returns 200, all environment variables validated on boot.
2
Phase 2 — Authentication (Days 3–4)
Register, login, token refresh, auth middleware. Done when: full auth flow works end-to-end, protected routes return 401 without a token, and expired tokens return a distinct error message.
3
Phase 3 — Core Resources (Days 5–8)
Projects CRUD, Members CRUD, cursor pagination, search, file attachment upload flow. Done when: all CRUD operations work with valid tokens, pagination returns correct has_more values, and search returns ranked results.
4
Phase 4 — Production Features (Days 9–10)
Webhooks, rate limiting, API versioning, response compression, final error handling audit. Done when: webhooks fire with valid HMAC signatures, rate limit headers appear on every response, and all errors follow the structured format from Lesson 34.

Step 3 — Write the API Contract

An API contract is a document that specifies every endpoint before any code is written. It names the method, the path, what authentication is required, what the request body looks like, and what the response looks like for both success and error cases. It is the single source of truth that the backend developer, frontend developer, and QA engineer all refer to.

The contract is written in plain language first — not OpenAPI YAML, not code. Once everyone agrees on the design, it gets formalised. Trying to write OpenAPI spec before the design is settled is like writing a legal contract before agreeing on the deal.

# WHAT: APIForge Platform API v1 — Full API Contract
# Format: METHOD /path | Auth | Request body | Success response | Error cases
# This document is agreed before any code is written

# ── AUTH ENDPOINTS ─────────────────────────────────────────────────────────

POST /auth/register
  Auth:     None
  Body:     { email, password }
  Success:  201 { id, email, createdAt }
  Errors:   400 missing fields | 409 email already registered

POST /auth/login
  Auth:     None
  Body:     { email, password }
  Success:  200 { token, refreshToken, expiresIn }
  Errors:   400 missing fields | 401 invalid credentials

POST /auth/refresh
  Auth:     Bearer refreshToken
  Body:     None
  Success:  200 { token, expiresIn }
  Errors:   401 invalid/expired refresh token

GET /auth/me
  Auth:     Bearer token
  Body:     None
  Success:  200 { id, email, createdAt, role }
  Errors:   401 not authenticated

# ── PROJECT ENDPOINTS ──────────────────────────────────────────────────────

GET /api/v1/projects
  Auth:     Bearer token
  Query:    limit (default 20) | starting_after (cursor) | status filter
  Success:  200 { object:"list", data:[], has_more, url }
  Errors:   401 | 400 invalid query params

POST /api/v1/projects
  Auth:     Bearer token
  Headers:  Idempotency-Key (optional)
  Body:     { name, description?, status? }
  Success:  201 { id, name, description, status, createdAt, owner }
  Errors:   400 missing name | 401 | 409 idempotency key conflict

GET /api/v1/projects/:id
  Auth:     Bearer token
  Success:  200 { id, name, description, status, members[], createdAt }
  Errors:   401 | 404 project not found

PATCH /api/v1/projects/:id
  Auth:     Bearer token
  Body:     { name?, description?, status? } (partial update)
  Success:  200 updated project object
  Errors:   400 | 401 | 403 not owner | 404

DELETE /api/v1/projects/:id
  Auth:     Bearer token
  Success:  200 { deleted: true, id }
  Errors:   401 | 403 not owner | 404

# ── MEMBER ENDPOINTS ───────────────────────────────────────────────────────

GET /api/v1/projects/:id/members
  Auth:     Bearer token
  Success:  200 { object:"list", data:[], has_more }
  Errors:   401 | 404

POST /api/v1/projects/:id/members
  Auth:     Bearer token
  Body:     { email, role }
  Success:  201 { id, email, role, joinedAt }
  Errors:   400 | 401 | 403 not owner | 404 | 409 already a member

DELETE /api/v1/projects/:id/members/:memberId
  Auth:     Bearer token
  Success:  200 { deleted: true }
  Errors:   401 | 403 | 404

# ── SEARCH ─────────────────────────────────────────────────────────────────

GET /api/v1/search
  Auth:     Bearer token
  Query:    q (required) | status | limit | offset
  Success:  200 { query, hits:[], total, processingMs }
  Errors:   400 missing q | 401

# ── FILE ATTACHMENTS ───────────────────────────────────────────────────────

POST /api/v1/projects/:id/attachments/upload-url
  Auth:     Bearer token
  Body:     { filename, contentType, fileSizeBytes }
  Success:  200 { uploadUrl, attachmentId, expiresAt }
  Errors:   400 | 401 | 413 file too large | 415 unsupported type

POST /api/v1/attachments/:id/confirm
  Auth:     Bearer token
  Success:  200 { id, filename, url, status:"ready", sizeBytes }
  Errors:   401 | 404 | 409 already confirmed

# ── WEBHOOKS ───────────────────────────────────────────────────────────────

POST /api/v1/webhooks
  Auth:     Bearer token
  Body:     { url, events:[], secret }
  Success:  201 { id, url, events, createdAt }
  Errors:   400 | 401

GET /api/v1/webhooks
  Auth:     Bearer token
  Success:  200 { object:"list", data:[] }
  Errors:   401

DELETE /api/v1/webhooks/:id
  Auth:     Bearer token
  Success:  200 { deleted: true }
  Errors:   401 | 404
API Contract Review — APIForge Platform API v1 ============================================== Total endpoints defined: 19 Auth-protected: 16 (84%) Public: 3 (register, login, refresh) Endpoint breakdown by resource: Auth: 4 endpoints Projects: 5 endpoints Members: 3 endpoints Search: 1 endpoint Attachments: 2 endpoints Webhooks: 3 endpoints (register, list, delete) Health: 1 endpoint (GET /health — no auth) HTTP methods used: GET: 7 POST: 7 PATCH: 1 DELETE: 4 Idempotency required on: POST /api/v1/projects Versioning prefix: /api/v1/* Pagination type: Cursor (projects, members, webhooks) Offset (search — stateless results) Contract status: APPROVED — ready for Phase 1 build Signed off by: Product (scope), Engineering (feasibility), Security (auth model)
What just happened?

19 endpoints, fully specified, before any code exists. Notice what the contract captures that a simple list of routes would not: the authentication requirement on each endpoint, the exact field names in every request and response, the specific HTTP status codes for each error case, and which endpoints require idempotency keys.

The PATCH vs PUT decision for project updates is explicit in the contract — PATCH for partial updates, which is the correct choice when clients should not need to send the full object to change one field. Making this decision now prevents a debate in a code review at 11pm the night before deadline.

Try this: Count how many of these 19 endpoints you already know how to build from earlier lessons. You will find that every single one uses a pattern covered in Lessons 1 through 35 — the final project is assembly, not invention.

Step 4 — Design the Database Schema

The API contract drives the database schema. Every resource in the contract needs a table. Every relationship between resources — a project has many members, a member belongs to many projects — needs a join table or a foreign key. Getting the schema wrong requires a migration that breaks existing data, so the APIForge team designs it carefully before Phase 1 starts.

Two decisions get made now that would be painful to change later: all primary keys use prefixed string IDs (like proj_8f3k2m) rather than auto-increment integers, and all timestamps are stored as UTC Unix integers rather than database timestamp types. Both choices make the API responses more consistent and easier to work with across time zones.

# WHAT: APIForge Platform API v1 — PostgreSQL database schema
# Designed from the API contract — every endpoint's data needs are covered

-- Users table (auth)
CREATE TABLE users (
  id            VARCHAR(20)  PRIMARY KEY,  -- format: usr_xxxxxxx
  email         VARCHAR(255) UNIQUE NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  role          VARCHAR(20)  NOT NULL DEFAULT 'user',
  created_at    BIGINT       NOT NULL,  -- unix timestamp
  updated_at    BIGINT       NOT NULL
);
CREATE INDEX idx_users_email ON users(email);

-- Projects table
CREATE TABLE projects (
  id          VARCHAR(20)  PRIMARY KEY,  -- format: proj_xxxxxxx
  name        VARCHAR(255) NOT NULL,
  description TEXT,
  status      VARCHAR(20)  NOT NULL DEFAULT 'active',
  owner_id    VARCHAR(20)  NOT NULL REFERENCES users(id),
  created_at  BIGINT       NOT NULL,
  updated_at  BIGINT       NOT NULL
);
CREATE INDEX idx_projects_owner    ON projects(owner_id);
CREATE INDEX idx_projects_status   ON projects(status);
CREATE INDEX idx_projects_created  ON projects(created_at DESC);  -- cursor pagination

-- Project members (many-to-many)
CREATE TABLE project_members (
  id         VARCHAR(20) PRIMARY KEY,
  project_id VARCHAR(20) NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  user_id    VARCHAR(20) NOT NULL REFERENCES users(id),
  role       VARCHAR(20) NOT NULL DEFAULT 'member',
  joined_at  BIGINT      NOT NULL,
  UNIQUE (project_id, user_id)  -- one membership per user per project
);

-- File attachments
CREATE TABLE attachments (
  id           VARCHAR(20)  PRIMARY KEY,  -- format: att_xxxxxxx
  project_id   VARCHAR(20)  NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  s3_key       VARCHAR(500) NOT NULL,
  filename     VARCHAR(255) NOT NULL,
  content_type VARCHAR(100) NOT NULL,
  size_bytes   INTEGER      NOT NULL,
  status       VARCHAR(20)  NOT NULL DEFAULT 'pending',  -- pending | ready | failed
  uploaded_by  VARCHAR(20)  NOT NULL REFERENCES users(id),
  created_at   BIGINT       NOT NULL
);

-- Webhook registrations
CREATE TABLE webhooks (
  id         VARCHAR(20)  PRIMARY KEY,  -- format: wh_xxxxxxx
  user_id    VARCHAR(20)  NOT NULL REFERENCES users(id),
  url        VARCHAR(500) NOT NULL,
  events     TEXT[]       NOT NULL,  -- array: ['project.created', 'member.added']
  secret     VARCHAR(255) NOT NULL,  -- HMAC signing secret, stored encrypted
  created_at BIGINT       NOT NULL
);

-- Idempotency key store
CREATE TABLE idempotency_keys (
  key_hash    VARCHAR(64)  PRIMARY KEY,  -- SHA256 of user_id + key
  status_code INTEGER      NOT NULL,
  response    JSONB        NOT NULL,
  created_at  BIGINT       NOT NULL,
  expires_at  BIGINT       NOT NULL
);
CREATE INDEX idx_idempotency_expires ON idempotency_keys(expires_at);
Schema analysis — APIForge Platform API v1 ========================================== Tables: 6 Indexes: 6 (covering all foreign keys and common query patterns) Relationships: users 1---many projects (owner_id) users many---many projects (via project_members) projects 1---many attachments (project_id) users 1---many webhooks (user_id) idempotency_keys standalone (keyed by hash) Cursor pagination support: projects: idx_projects_created (created_at DESC) — enables fast WHERE id < cursor members: idx on project_members(project_id, joined_at DESC) Cascade behaviour: DELETE project → deletes all project_members and attachments automatically DELETE user → manual cleanup required (no cascade — preserve audit trail) Prefixed ID format examples: usr_9f3k2m (users) proj_8f3k2m (projects) att_7Kp2mN (attachments) wh_3n7q1p (webhooks) Estimated storage per 1,000 active projects: projects table: ~180KB project_members: ~120KB (avg 6 members per project) attachments: ~90KB (metadata only — files in S3) Total database: ~400KB for 1,000 projects
What just happened?

Six tables covering all 19 endpoints. The indexes are not decoration — each one was placed to support a specific query pattern defined in the API contract. The idx_projects_created index exists specifically because the cursor pagination query on projects is WHERE created_at < cursor ORDER BY created_at DESC — without that index, every paginated request is a full table scan.

The UNIQUE (project_id, user_id) constraint on project_members is a database-level enforcement of the "409 already a member" error case in the API contract. The API checks for duplicates first, but the constraint is the safety net — no race condition can produce duplicate memberships even under concurrent requests.

Try this: Look at each index and trace it back to a specific endpoint in the API contract. Every index should map to at least one query. If you find an index with no corresponding endpoint query, it is adding write overhead with no read benefit — remove it.

Step 5 — Write Acceptance Criteria

Acceptance criteria are the specific, testable conditions that must be true for a feature to be considered complete. Not "auth works" — that is a hope. "POST /auth/register returns 201 with a user object, duplicate email returns 409, missing password returns 400" — those are acceptance criteria. You either pass them or you do not.

The APIForge team writes acceptance criteria as a checklist. During the build in Lesson 37, this checklist is the test plan. Each item gets a checkbox — either it passes or it gets flagged for a fix before the lesson is marked complete.

# WHAT: APIForge Platform API v1 — Acceptance Criteria Checklist
# Each item is a specific HTTP request and expected response
# All items must pass before the Final Project is considered complete

# ── PHASE 1: FOUNDATION ────────────────────────────────────────────────────
[ ] GET /health returns 200 { status: "ok", version: "1.0.0", timestamp }
[ ] Server boots and validates all required env vars — crashes with clear error if any missing
[ ] Every response includes X-Request-ID header
[ ] Unhandled route returns 404 with structured error object
[ ] Unhandled server error returns 500 with structured error (no stack trace in response)

# ── PHASE 2: AUTHENTICATION ────────────────────────────────────────────────
[ ] POST /auth/register with valid data returns 201
[ ] POST /auth/register with duplicate email returns 409
[ ] POST /auth/register with missing password returns 400
[ ] POST /auth/login with correct credentials returns token and refreshToken
[ ] POST /auth/login with wrong password returns 401 "Invalid credentials"
[ ] POST /auth/login with unknown email returns 401 "Invalid credentials" (same message)
[ ] GET /api/v1/projects without token returns 401
[ ] GET /api/v1/projects with expired token returns 401 "Token has expired"
[ ] GET /api/v1/projects with valid token returns 200

# ── PHASE 3: CORE RESOURCES ────────────────────────────────────────────────
[ ] POST /api/v1/projects creates project and returns 201 with all fields
[ ] GET /api/v1/projects returns list with has_more and cursor support
[ ] GET /api/v1/projects?starting_after=ID returns correct next page
[ ] GET /api/v1/projects?starting_after=(last item ID) returns has_more: false
[ ] PATCH /api/v1/projects/:id updates only the provided fields
[ ] DELETE /api/v1/projects/:id returns 200 { deleted: true }
[ ] DELETE /api/v1/projects/:id (already deleted) returns 404
[ ] POST /api/v1/projects/:id/members adds member and returns 201
[ ] POST /api/v1/projects/:id/members (duplicate) returns 409
[ ] GET /api/v1/search?q=gateway returns hits with processingMs
[ ] POST /api/v1/projects/:id/attachments/upload-url returns signed URL
[ ] File uploaded to signed URL returns 200 from S3 directly
[ ] POST /api/v1/attachments/:id/confirm updates status to "ready"

# ── PHASE 4: PRODUCTION FEATURES ───────────────────────────────────────────
[ ] POST /api/v1/projects with Idempotency-Key — second request returns same response
[ ] Every response includes X-RateLimit-Remaining header
[ ] 101st request in one minute returns 429 with Retry-After header
[ ] POST /api/v1/webhooks registers endpoint and returns 201
[ ] project.created event fires webhook within 500ms of project creation
[ ] Webhook request includes APIForge-Signature header with valid HMAC
[ ] Tampered webhook payload returns 400 on verification
[ ] All error responses follow { error: { type, message, code, param, request_id } } format
[ ] APIForge-Version header accepted and echoed back in response
[ ] gzip compression active — Content-Encoding: gzip on responses over 1KB
Acceptance Criteria Summary — APIForge Platform API v1 ======================================================= Total criteria: 40 Phase breakdown: Phase 1 (Foundation): 5 criteria Phase 2 (Auth): 9 criteria Phase 3 (Core resources): 15 criteria Phase 4 (Production): 11 criteria Coverage by feature area: Error handling: 7 criteria (400, 401, 403, 404, 409, 413, 429) Happy paths: 24 criteria (correct requests returning correct responses) Edge cases: 9 criteria (duplicate email, expired token, cursor at end, etc.) Criteria that reference patterns from earlier lessons: Idempotency (L34): 2 criteria Rate limiting (L30, L34): 2 criteria Structured errors (L34): 1 criteria Webhook signatures (L34): 2 criteria Cursor pagination (L34): 3 criteria Auth middleware (L32): 3 criteria Compression (L30): 1 criteria Estimated test time (manual with Postman): 45 minutes Estimated test time (automated): 8 minutes Status: READY FOR BUILD — all criteria approved by Product and Engineering
What just happened?

40 acceptance criteria — each one a specific HTTP request with a specific expected response. Notice that the auth error criteria deliberately test that a wrong password and an unknown email return the same error message. That is not an accident — it is a security requirement from the Lesson 34 case study, now enforced as a testable criterion.

The split between happy paths (24) and edge cases (9) reflects a common planning mistake: teams write acceptance criteria only for the success case and discover error handling gaps in production. Writing the edge case criteria now means the error paths get built and tested during the planned build window, not as emergency hotfixes later.

Try this: Before starting any API feature, write at least 3 acceptance criteria for it — one happy path, one missing-field error, one auth error. This takes 5 minutes and saves an hour of debugging later.

Step 6 — Environment Configuration and Project Structure

The final planning deliverable is the project structure and environment variable specification. These two decisions affect every developer who touches the project. An inconsistent folder structure means developers spend time navigating instead of building. Missing environment variables documented nowhere means every new developer loses an hour figuring out why the server will not start.

# WHAT: APIForge Final Project — folder structure and environment variable spec

# ── FOLDER STRUCTURE ───────────────────────────────────────────────────────
apiforge-api/
├── src/
│   ├── controllers/       # Route handlers — one file per resource
│   │   ├── auth.controller.js
│   │   ├── projects.controller.js
│   │   ├── members.controller.js
│   │   ├── search.controller.js
│   │   ├── attachments.controller.js
│   │   └── webhooks.controller.js
│   ├── middleware/        # Reusable middleware
│   │   ├── authenticate.js      # JWT verification
│   │   ├── rateLimiter.js       # Redis rate limiting
│   │   ├── idempotency.js       # Idempotency key handling
│   │   ├── requestLogger.js     # Morgan structured logging
│   │   └── errorHandler.js      # Global error handler
│   ├── routes/            # Express router definitions
│   │   ├── auth.routes.js
│   │   ├── projects.routes.js
│   │   ├── search.routes.js
│   │   └── webhooks.routes.js
│   ├── services/          # Business logic — called by controllers
│   │   ├── auth.service.js
│   │   ├── projects.service.js
│   │   └── webhook.service.js   # Fires outbound webhooks
│   ├── utils/             # Shared utilities
│   │   ├── generateId.js        # Prefixed ID generator
│   │   ├── buildError.js        # Structured error builder
│   │   └── crypto.js            # HMAC, encrypt, decrypt helpers
│   └── server.js          # App entry point
├── db/
│   ├── schema.sql         # Full database schema (from Step 4)
│   └── seed.sql           # Test data for development
├── tests/
│   └── acceptance/        # One file per acceptance criteria group
├── .env.example           # Template — committed to repo
├── .env                   # Real values — NEVER committed
└── package.json

# ── ENVIRONMENT VARIABLES (.env.example) ──────────────────────────────────
# Server
PORT=3000
NODE_ENV=development

# Database
DATABASE_URL=postgresql://user:password@localhost:5432/apiforge

# Auth
JWT_SECRET=replace-with-64-char-random-string
JWT_EXPIRES_IN=2h
JWT_REFRESH_EXPIRES_IN=7d

# Redis (rate limiting + idempotency)
REDIS_URL=redis://localhost:6379

# AWS S3 (file attachments)
AWS_REGION=ap-south-1
AWS_ACCESS_KEY_ID=replace-with-real-key
AWS_SECRET_ACCESS_KEY=replace-with-real-secret
S3_BUCKET=apiforge-attachments-dev

# Search
MEILISEARCH_URL=http://localhost:7700
MEILISEARCH_KEY=replace-with-master-key

# Webhooks
WEBHOOK_SIGNING_SECRET=replace-with-32-char-random-string

# API Versioning
API_VERSION=2024-11-01
Project structure validation — APIForge Platform API v1 ======================================================== Folder structure: APPROVED src/controllers/ 6 files (one per resource — clean separation) src/middleware/ 5 files (auth, rate limit, idempotency, logging, errors) src/routes/ 4 files (grouped logically, not one file per endpoint) src/services/ 3 files (business logic separated from HTTP layer) src/utils/ 3 files (shared — no duplication across controllers) Environment variables: 16 total Required on boot: DATABASE_URL, JWT_SECRET, REDIS_URL Required for S3: AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_BUCKET Required for search: MEILISEARCH_URL, MEILISEARCH_KEY Optional (have defaults): PORT, NODE_ENV, JWT_EXPIRES_IN, API_VERSION Boot validation logic (server.js): const required = ['DATABASE_URL', 'JWT_SECRET', 'REDIS_URL']; required.forEach(key => { if (!process.env[key]) { console.error(`FATAL: Missing required env var: ${key}`); process.exit(1); } }); .env.example committed: YES — all developers know what variables are needed .env committed: NO — listed in .gitignore, never in source control Final checklist before Phase 1 build begins: [x] Scope document agreed and signed off [x] Four phases defined with definitions of done [x] API contract covering 19 endpoints [x] Database schema with 6 tables and 6 indexes [x] 40 acceptance criteria written and approved [x] Folder structure agreed [x] Environment variable spec documented Ready to build.
What just happened?

The controllers/services split is the most important structural decision here. Controllers handle the HTTP layer — parsing request bodies, setting status codes, sending responses. Services handle business logic — querying the database, calling external APIs, applying rules. Keeping them separate means you can test the business logic without spinning up an HTTP server, and you can change the HTTP framework without rewriting any business logic.

The boot-time environment variable check means the server refuses to start with a clear error message rather than failing silently on the first request that needs a missing variable. One line of startup validation saves every new developer from 30 minutes of debugging a mysterious undefined error in a database connection.

Try this: Create the folder structure above right now, before Lesson 37. Create empty files in each location. Having the scaffolding in place means Lesson 37 is filling in code — not making structural decisions under time pressure.

Before and After: Unplanned vs Planned Build

The planning session covered in this lesson takes two to four hours for a project of this size. That investment pays back within the first day of building. Here is what the build experience looks like with and without it.

Without Planning
Endpoint naming is inconsistent — /getProjects here, /project/list there
Auth added after the fact — every route needs retrofitting
Schema changes mid-build require migrations that break existing data
No definition of done — "it works" is the only test
Missing env var discovered at 2am when a specific feature first runs
Scope expands because nobody documented what was out of scope
With Planning
All 19 endpoints named consistently before any code exists
Auth is Phase 2 — every Phase 3 route is built with auth from the start
Schema designed from the contract — no surprises during build
40 acceptance criteria — done means all 40 pass, nothing less
All 16 env vars documented in .env.example before the first commit
Backlog documented — scope creep has nowhere to hide
Planning Step Output Prevents
1. Scope definition In-scope list + documented backlog Scope creep, mid-project feature debates
2. Phase breakdown 4 phases, each with definition of done Building features before foundations are solid
3. API contract 19 endpoints fully specified Naming inconsistencies, design debates in code review
4. Database schema 6 tables, 6 indexes, cascade rules Mid-build migrations, missing indexes in production
5. Acceptance criteria 40 testable conditions Untested edge cases, vague definition of done
6. Project structure Folder layout + env var spec Navigation friction, missing config bugs
What to do right now: Create the folder structure from Step 6 on your machine. Create the .env file using .env.example as a template and fill in local values for DATABASE_URL and REDIS_URL. Run the schema.sql from Step 4 against a local PostgreSQL instance. When Lesson 37 starts, you will be at Phase 1 completion — server scaffolding in place, database ready, environment configured. The build starts from a running foundation, not from zero.

Quiz

1. During the APIForge planning session, the team decides that real-time WebSocket events are a valuable feature but not needed for v1. What is the correct way to handle this decision?

2. The APIForge schema includes UNIQUE (project_id, user_id) on the project_members table. The API contract also checks for duplicates in the controller. Why does the schema constraint still matter?

3. The APIForge project structure separates src/controllers from src/services. A junior developer asks why both are needed instead of putting all logic in the controllers. What is the correct explanation?

Up Next
Final Project
The APIForge team builds the complete Platform API v1 from scratch — all 19 endpoints, four phases, and 40 acceptance criteria — using everything covered across this course.