Skip to main content

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

Layered architecture of the Task API An incoming HTTP request passes through a routing layer, then a handler layer that validates and applies logic, then a data store layer holding tasks in memory, and a response flows back out. Client curl · Postman Routing match method + path Handlers validate + logic Data store tasks in memory JSON response + status code
Figure 1 — One architecture, three implementations. Routing decides which handler runs; handlers validate and apply logic; the data store holds state. Keeping these separate is what makes the code portable.

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.

MethodPathPurposeSuccess code
GET/tasksList all tasks200 OK
GET/tasks/:idGet one task200 OK
POST/tasksCreate a task201 Created
PUT/tasks/:idUpdate a task200 OK
DELETE/tasks/:idDelete a task204 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.

flowchart LR M1[M1 · Design
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]
MilestoneDefinition of doneRough time
M1 — DesignContract table + layer sketch written down30–45 min
M2 — Node/ExpressAll 5 endpoints work; 404 + 400 handled2–3 hrs
M3 — Python/FlaskSame contract, passes the same tests1.5–2 hrs
M4 — PHPSame contract via built-in server2–3 hrs
M5 — Test & reflectAll three pass the curl suite; short write-up1 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 /tasks returns 200 and a JSON array
  • GET /tasks/:id returns 200 for a real id, 404 for a missing one
  • POST /tasks returns 201 and the created task with a new id
  • POST with no title returns 400 and a JSON error
  • PUT /tasks/:id updates fields and returns 200; missing id returns 404
  • DELETE /tasks/:id returns 204 with no body; missing id returns 404
  • ☐ 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.

DimensionNeeds workGoodExcellent
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=true for 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

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