🏁 Weekend Project: A Polyglot Mini-Server
This is your first real backend build. Over a focused weekend you'll design one clean architecture for a Task API — then implement it three ways: Node.js/Express, Python/Flask, and plain PHP. The point isn't three throwaway scripts; it's to feel how the same architecture survives a change of language, and to leave with something you'd be happy to show.
🎯 Learning Objectives
By the end of this project, you will be able to:
- Design a small layered backend architecture (routing → handler/controller → data store) before writing code
- Implement a complete CRUD REST API for tasks in three languages that share one contract
- Choose correct HTTP methods and status codes for each operation
- Add basic validation and error handling so bad input fails safely
- Evaluate your own work against a "what good looks like" rubric
Estimated Time: A weekend (6–10 hours) • Difficulty: Intermediate
Hands-on: Build the same Task API three times, working through five milestones and a completion checklist.
In This Lesson
The Brief
Your client (you) needs a small backend service to manage a to-do list. It must store tasks, let a client create/read/update/delete them, and behave like a proper REST API — correct verbs, correct status codes, sensible errors. Simple on the surface, but it exercises every core skill from this module.
To keep the focus on architecture rather than tooling, storage stays in memory (a plain array/list) for now. That's a deliberate constraint: a database is a later module, and building without one first makes the boundary between "web layer" and "data layer" obvious.
📖 What "architecture" means here
Architecture is the shape of your code — which piece is responsible for what, and how requests flow between them. Good architecture means you can swap the language, the framework, or the storage without rewriting everything, because each concern lives in its own place.
💡 Why build it three times? A skill you can only perform in one framework is a framework skill. A skill you can reproduce in three is an architecture skill. By the third implementation you'll stop thinking "how do I do routing in Flask?" and start thinking "where does routing belong?" — that's the whole goal.
Design the Architecture First
Before a single line of code, sketch the layers. Every one of your three servers will fill these same boxes; only the syntax changes.
Notice each layer has a single job:
- Routing — inspects the HTTP method and URL, then dispatches to the right handler. It knows nothing about tasks.
- Handlers (controllers) — validate input, decide the correct status code, and call the data store. This is where your business rules live.
- Data store — the only code that reads or writes the task list. Swap this out later for a database and nothing else needs to change.
✅ The payoff of separation
Because the data store is isolated, "add a database in Module 21" becomes a change to one layer instead of a rewrite. That's the practical reason architects obsess over boundaries.
The API Contract
All three servers must honour the same contract. Design it now and treat it as the source of truth — your tests will check it, and any client should work against any of your three servers unchanged.
| Method | Path | Purpose | Success code |
|---|---|---|---|
GET | /tasks | List all tasks | 200 OK |
GET | /tasks/:id | Get one task | 200 OK |
POST | /tasks | Create a task | 201 Created |
PUT | /tasks/:id | Update a task | 200 OK |
DELETE | /tasks/:id | Delete a task | 204 No Content |
The task resource looks like this:
{
"id": 1,
"title": "Learn RESTful APIs",
"completed": false
}
⚠️ Error cases are part of the contract
A missing task returns 404 Not Found. A POST or PUT with no title returns 400 Bad Request and a JSON error body. Decide these before coding — error behaviour is where beginner APIs fall apart.
Milestones & Timeline
Break the weekend into five milestones. Finish each one — including its test — before moving on. Working in small, verified steps is the single biggest predictor of finishing.
contract & layers] --> M2[M2 · Node/Express
full CRUD] M2 --> M3[M3 · Python/Flask
same contract] M3 --> M4[M4 · PHP
same contract] M4 --> M5[M5 · Test all three
& reflect]
| Milestone | Definition of done | Rough time |
|---|---|---|
| M1 — Design | Contract table + layer sketch written down | 30–45 min |
| M2 — Node/Express | All 5 endpoints work; 404 + 400 handled | 2–3 hrs |
| M3 — Python/Flask | Same contract, passes the same tests | 1.5–2 hrs |
| M4 — PHP | Same contract via built-in server | 2–3 hrs |
| M5 — Test & reflect | All three pass the curl suite; short write-up | 1 hr |
💡 Do M2 thoroughly. Your first implementation is where you actually design the handlers and error cases. M3 and M4 are mostly translation — they go fast because M2 did the thinking.
Guided Build (Three Ways)
Below is a reference implementation of each server. Read them, but type them yourself — muscle memory matters. Each keeps the three layers visible even in a single file.
Milestone 2 — Node.js (Express)
Express gives you routing and JSON body parsing out of the box. Install it first:
mkdir task-api-node && cd task-api-node
npm init -y
npm install express
// server.js — Task API (Node.js + Express)
const express = require('express');
const app = express();
app.use(express.json()); // parse JSON request bodies
// --- Data store layer (swap for a DB later) ---
let tasks = [{ id: 1, title: 'Learn RESTful APIs', completed: false }];
let nextId = 2;
// --- Handler layer ---
// GET /tasks — list all
app.get('/tasks', (req, res) => {
res.status(200).json(tasks);
});
// GET /tasks/:id — one task
app.get('/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === Number(req.params.id));
if (!task) return res.status(404).json({ error: 'Task not found' });
res.status(200).json(task);
});
// POST /tasks — create
app.post('/tasks', (req, res) => {
const { title, completed = false } = req.body;
if (!title) return res.status(400).json({ error: 'title is required' });
const task = { id: nextId++, title, completed: Boolean(completed) };
tasks.push(task);
res.status(201).json(task);
});
// PUT /tasks/:id — update
app.put('/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === Number(req.params.id));
if (!task) return res.status(404).json({ error: 'Task not found' });
if (req.body.title !== undefined) task.title = req.body.title;
if (req.body.completed !== undefined) task.completed = Boolean(req.body.completed);
res.status(200).json(task);
});
// DELETE /tasks/:id — remove
app.delete('/tasks/:id', (req, res) => {
const index = tasks.findIndex(t => t.id === Number(req.params.id));
if (index === -1) return res.status(404).json({ error: 'Task not found' });
tasks.splice(index, 1);
res.status(204).end();
});
app.listen(3000, () => console.log('Node API on http://localhost:3000'));
Run it
node server.js
# → Node API on http://localhost:3000
Milestone 3 — Python (Flask)
Flask's decorators are the routing layer. Install and run inside a virtual environment:
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install flask
# app.py — Task API (Python + Flask)
from flask import Flask, request, jsonify
app = Flask(__name__)
# --- Data store layer ---
tasks = [{"id": 1, "title": "Learn RESTful APIs", "completed": False}]
next_id = 2
def find_task(task_id):
return next((t for t in tasks if t["id"] == task_id), None)
# --- Handler layer ---
@app.get("/tasks")
def list_tasks():
return jsonify(tasks), 200
@app.get("/tasks/")
def get_task(task_id):
task = find_task(task_id)
if task is None:
return jsonify(error="Task not found"), 404
return jsonify(task), 200
@app.post("/tasks")
def create_task():
global next_id
data = request.get_json(silent=True) or {}
if not data.get("title"):
return jsonify(error="title is required"), 400
task = {"id": next_id, "title": data["title"],
"completed": bool(data.get("completed", False))}
next_id += 1
tasks.append(task)
return jsonify(task), 201
@app.put("/tasks/")
def update_task(task_id):
task = find_task(task_id)
if task is None:
return jsonify(error="Task not found"), 404
data = request.get_json(silent=True) or {}
if "title" in data:
task["title"] = data["title"]
if "completed" in data:
task["completed"] = bool(data["completed"])
return jsonify(task), 200
@app.delete("/tasks/")
def delete_task(task_id):
task = find_task(task_id)
if task is None:
return jsonify(error="Task not found"), 404
tasks.remove(task)
return "", 204
if __name__ == "__main__":
app.run(port=5000, debug=True)
Run it
python app.py
# → Running on http://localhost:5000
Milestone 4 — PHP
Plain PHP has no built-in router, so you write a tiny one — which makes the routing layer wonderfully explicit. No dependencies needed; use PHP's built-in server.
<?php
// index.php — Task API (plain PHP)
header('Content-Type: application/json');
// --- Data store layer (in-memory per request; see note below) ---
session_start();
if (!isset($_SESSION['tasks'])) {
$_SESSION['tasks'] = [['id' => 1, 'title' => 'Learn RESTful APIs', 'completed' => false]];
$_SESSION['nextId'] = 2;
}
function &store() { return $_SESSION['tasks']; }
function find_task($id) {
foreach ($_SESSION['tasks'] as $i => $t) {
if ($t['id'] === (int)$id) return $i;
}
return -1;
}
function send($data, $code = 200) {
http_response_code($code);
if ($data !== null) echo json_encode($data);
exit;
}
// --- Routing layer ---
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$body = json_decode(file_get_contents('php://input'), true) ?? [];
// --- Handler layer ---
if (preg_match('#^/tasks/(\d+)$#', $path, $m)) {
$id = (int)$m[1];
$index = find_task($id);
if ($method === 'GET') {
$index === -1 ? send(['error' => 'Task not found'], 404)
: send($_SESSION['tasks'][$index]);
}
if ($method === 'PUT') {
if ($index === -1) send(['error' => 'Task not found'], 404);
if (isset($body['title'])) $_SESSION['tasks'][$index]['title'] = $body['title'];
if (isset($body['completed'])) $_SESSION['tasks'][$index]['completed'] = (bool)$body['completed'];
send($_SESSION['tasks'][$index]);
}
if ($method === 'DELETE') {
if ($index === -1) send(['error' => 'Task not found'], 404);
array_splice($_SESSION['tasks'], $index, 1);
send(null, 204);
}
}
if ($path === '/tasks') {
if ($method === 'GET') {
send(array_values($_SESSION['tasks']));
}
if ($method === 'POST') {
if (empty($body['title'])) send(['error' => 'title is required'], 400);
$task = ['id' => $_SESSION['nextId']++, 'title' => $body['title'],
'completed' => (bool)($body['completed'] ?? false)];
$_SESSION['tasks'][] = $task;
send($task, 201);
}
}
send(['error' => 'Not found'], 404);
Run it
php -S localhost:8000
# serves index.php at http://localhost:8000
💡 A note on the PHP data store
Node and Flask keep one long-running process, so a plain array persists between requests. PHP starts fresh on every request, so the reference above uses $_SESSION to survive across calls in the same session — a deliberate reminder that the runtime model shapes the data layer. In production you'd use a database regardless.
Testing Your API
Don't eyeball it — hit every endpoint. These curl commands form your acceptance test; run the same set against all three servers, changing only the port (Node 3000, Flask 5000, PHP 8000).
# Create a task → expect 201 + the new task
curl -i -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Write tests", "completed": false}'
# List all tasks → expect 200 + array
curl -i http://localhost:3000/tasks
# Get one → expect 200; get a missing one → expect 404
curl -i http://localhost:3000/tasks/1
curl -i http://localhost:3000/tasks/999
# Update → expect 200 + updated task
curl -i -X PUT http://localhost:3000/tasks/1 \
-H "Content-Type: application/json" \
-d '{"completed": true}'
# Delete → expect 204 (no body)
curl -i -X DELETE http://localhost:3000/tasks/1
# Bad create (no title) → expect 400
curl -i -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" -d '{}'
⚠️ Check the status codes, not just the bodies
The -i flag prints the response headers so you can confirm the code. A create that returns 200 instead of 201, or a delete that returns a body instead of 204, is a contract violation — fix it before moving on.
Prefer a GUI? Import the same requests into Postman or Insomnia as a collection so you can re-run the whole suite against each server with one click.
Completion Checklist
You're done when every box is ticked for all three servers. Print this or copy it into your project README.
✅ Per-server checklist
- ☐
GET /tasksreturns200and a JSON array - ☐
GET /tasks/:idreturns200for a real id,404for a missing one - ☐
POST /tasksreturns201and the created task with a newid - ☐
POSTwith notitlereturns400and a JSON error - ☐
PUT /tasks/:idupdates fields and returns200; missing id returns404 - ☐
DELETE /tasks/:idreturns204with no body; missing id returns404 - ☐ All responses set
Content-Type: application/json(except the empty 204) - ☐ The three code layers (routing / handlers / data store) are visibly separate
📦 Deliverables
- ☐ Three working servers in one repo (
/node,/python,/php) - ☐ A short README per server: how to install and run it
- ☐ A one-page reflection: which felt cleanest, and why
- ☐ Committed to Git with meaningful messages
What Good Looks Like
Anyone can make endpoints return something. Here's how to tell a solid submission from a shaky one — use it to grade yourself honestly.
| Dimension | Needs work | Good | Excellent |
|---|---|---|---|
| Status codes | Everything returns 200 | 201/204/404/400 used correctly | Correct codes + helpful JSON error bodies |
| Architecture | Logic, routing, data tangled together | Three layers identifiable | Data store fully isolated — DB swap would be trivial |
| Validation | Bad input crashes the server | Missing title rejected with 400 | Types coerced/validated; unexpected fields ignored safely |
| Consistency | Three servers behave differently | Same contract across all three | One curl suite passes unchanged against all three |
| Readability | No comments, cryptic names | Clear names, some comments | Self-documenting; a stranger could extend it |
🏆 The signature of an excellent submission
You can point at any one endpoint and say, in one sentence, which layer does what — and your single curl test suite passes against Node, Flask, and PHP without edits. That means you built one architecture, not three programs.
💡 Stretch goals (only after the checklist is green)
- Add query support:
GET /tasks?completed=truefor filtering - Add pagination:
?limit=and?offset= - Extract the data store into its own module/file to prove the boundary
- Write an automated test script (Jest, pytest, or a shell loop) that runs the suite for you
Summary & Quiz
🎉 Key Takeaways
- Design before code: a written contract and a layer sketch made all three builds fast and consistent.
- One architecture, three languages: routing → handlers → data store is the same everywhere; only syntax changes.
- Status codes and errors are part of the contract — 201 for create, 204 for delete, 404/400 for failures.
- Isolating the data store is what will let you drop in a real database next module without a rewrite.
- Working in verified milestones is how a weekend project actually gets finished.
🎯 Quick Quiz
Question 1: Which HTTP status code should a successful DELETE /tasks/:id return when there's no response body?
Question 2: Why does isolating the "data store" layer matter most for this project's future?
Question 3: A POST /tasks request arrives with an empty JSON body {}. What should the API do?
📚 Further Reading
- Express.js — Routing Guide
- Flask — Quickstart
- PHP — Built-in Web Server
- RESTful API Design Guidelines
- MDN — HTTP Status Codes
🚀 What's Next?
Your data store is the last hand-rolled piece. In the next module we replace that in-memory array with a real database — starting with the concepts and types of databases you'll choose between.
🎉 You shipped a backend!
Three servers, one architecture, all passing the same tests. That's exactly how professionals think about portable code.