In 03-01 we chose Express and did not argue about the decision: we had to start somewhere and Express is the most instructive starting point in the Node ecosystem, because it hides nothing. Every middleware we registered in src/app.js we wrote ourselves, and that is why we understand what each one does.

But Express is not the only option, not even within JavaScript, and the question "which framework do we use?" comes up on day one of any new project. It is almost always answered badly: out of habit, out of fashion or on the strength of a half-read benchmark.

This lesson provides the missing perspective. We are going to implement the same endpoint, GET /v1/coffees, in nine different frameworks across five languages, with the same contract: the filters from 02-06, mandatory pagination, the {data, total} response and the validation that produces the catalogue's 400. We will compare what the framework does for you and what it leaves to you, and we will finish with honest selection criteria —the ones that do not appear in requests-per-second charts— and with the most important observation in the module: almost nothing you have learned in this course depends on the framework.

This lesson is not an installation tutorial. You are not going to set up nine projects. The fragments are there to be read and compared, not run; each one assumes the project has already been created with that language's tooling.

Contents

  1. Why this lesson exists
  2. What is expected of an API framework today
  3. The reference endpoint
  4. Express: the minimum, everything on you
  5. Fastify: schemas, plugins and speed
  6. NestJS: opinionated architecture for large teams
  7. Hono: lightweight and multi-runtime
  8. FastAPI (Python): typing as the contract
  9. Django REST Framework (Python): serializers and viewsets
  10. Spring Boot (Java): the enterprise standard
  11. ASP.NET Core (C#): minimal APIs and performance
  12. Honourable mentions: Laravel, Rails API and Go
  13. The comparison table
  14. Honest selection criteria
  15. What does not depend on the framework
  16. Migrating between frameworks: from Express to Fastify
  17. Alternative runtimes and serverless

  1. Why this lesson exists

Three concrete situations make this comparison matter:

  • You are starting a project and have to choose. The decision shapes the following years: hiring, training, dependencies and delivery speed. It is taken in a one-hour meeting and paid for over five years.
  • You change job and the project uses another framework. If you understand which problem each one solves, you land in days instead of months.
  • Somebody proposes a migration. You need better arguments than "Fastify is faster" to decide whether it is worth it.

And there is a fourth, deeper reason: seeing the same endpoint nine times teaches you what is essential in a REST API —the contract, the codes, the validation, the pagination— and what is accidental, belonging to whichever framework you happen to use. It is the best vaccine against confusing Express with REST.

  1. What is expected of an API framework today

An API framework is judged by how much of this list it gives you for free, and how well:

Capability What it means How we solved it in Express
Routing Mapping method + path to a function Express's Router
Middleware Chain of functions before and after the handler 16 positions in src/app.js
Input validation Rejecting invalid data before the logic Zod + middleware/validation.js (03-04)
Output serialisation Converting domain objects into the contract's JSON Hand-written mappers (03-03)
Dependency injection Keeping the layers from instantiating each other Direct imports and repositories/index.js
Automatic documentation Producing OpenAPI without writing it separately By hand (02-08, 05-02)
Error handling A single point that translates exceptions into HTTP middleware/errors.js (03-07)
Performance Requests per second and latency under load Good enough; measured with autocannon (04-06)
Typing The compiler catching contract errors None: untyped JavaScript
Ecosystem A plugin existing for whatever you need Enormous
Maturity and support Still being alive in five years Maximum

Notice how many boxes in the right-hand column say "by hand". That is not a defect of Express: it is its proposition. The question in this lesson is what you gain and what you lose when another framework fills those boxes in for you.

  1. The reference endpoint

The contract all nine will implement, as we fixed it in 02-06 and 05-02:

GET /v1/coffees?roast=light&priceMax=15&limit=20&offset=0&sort=-priceEuros
Authorization: Bearer <jwt>
{
  "data": [
    {
      "id": "cof_001",
      "name": "Ethiopia Yirgacheffe",
      "origin": "Ethiopia",
      "roast": "light",
      "priceEuros": 14.50,
      "stock": 120
    }
  ],
  "total": 137
}

Rules each implementation must honour:

  1. limit defaults to 20, maximum 100; offset maximum 10,000.
  2. roast only accepts light, medium or dark.
  3. An invalid parameter produces 400 with {"error": {"code": "invalid_parameter", ...}}.
  4. The price is stored as whole cents and serialised as euros with two decimals.
  5. It requires authentication.

That fourth rule is the most revealing: it is where you see whether the framework serialises for you and whether it lets you control the transformation.

  1. Express: the minimum, everything on you

Our starting point, condensed so it can be compared:

// src/routes/coffees.js
import { Router } from 'express';
import { authenticate } from '../middleware/authentication.js';
import { validate } from '../middleware/validation.js';
import { asyncHandler } from '../middleware/async.js';
import { coffeeQuerySchema } from '../schemas/coffees.js';
import { getCoffees } from '../controllers/coffees.js';

export const coffeeRoutes = Router();

coffeeRoutes.get(
  '/',
  authenticate,                              // 03-06
  validate(coffeeQuerySchema, 'query'),      // 03-04
  asyncHandler(getCoffees),                  // 03-07: catches rejected promises
);
// src/schemas/coffees.js — the input contract, in Zod
import { z } from 'zod';

export const coffeeQuerySchema = z.object({
  roast: z.enum(['light', 'medium', 'dark']).optional(),
  origin: z.string().min(2).max(60).optional(),
  priceMin: z.coerce.number().min(0).optional(),
  priceMax: z.coerce.number().min(0).optional(),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  offset: z.coerce.number().int().min(0).max(10000).default(0),
  sort: z.string().default('name'),
}).strict();   // .strict(): an unknown parameter produces 400
// src/controllers/coffees.js
import { coffeeService } from '../services/coffees.js';
import { coffeeToRepresentation } from '../services/mappers.js';

export async function getCoffees(req, res) {
  const { data, total } = await coffeeService.list(req.validated.query);
  // The mapper converts cents into euros: the conversion is EXPLICIT and ours
  res.json({ data: data.map(coffeeToRepresentation), total });
}

Express in the balance. The code is transparent: it reads top to bottom and there is no magic. Every guarantee in the contract exists because we wrote it. The cost is that the "by hand" list in section 2 is long, and that nothing forces you: it is perfectly possible for a colleague to register a route without validate and for nobody to notice until a 500 arrives.

  1. Fastify: schemas, plugins and speed

Fastify was born asking whether you could have Express's simplicity with better performance. The answer was yes, and the mechanism is interesting: JSON Schema as the central piece.

// routes/coffees.js — Fastify
// The schema is NOT just validation: it also generates the documentation
// and compiles a specific serialiser, which is where the speed comes from.
const listCoffeesSchema = {
  tags: ['Coffees'],
  summary: 'Lists the coffee catalogue',
  operationId: 'getCoffees',
  security: [{ bearerJWT: [] }],
  querystring: {
    type: 'object',
    additionalProperties: false,          // unknown parameter → automatic 400
    properties: {
      roast: { type: 'string', enum: ['light', 'medium', 'dark'] },
      origin: { type: 'string', minLength: 2, maxLength: 60 },
      priceMin: { type: 'number', minimum: 0 },
      priceMax: { type: 'number', minimum: 0 },
      limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
      offset: { type: 'integer', minimum: 0, maximum: 10000, default: 0 },
      sort: { type: 'string', default: 'name' },
    },
  },
  response: {
    200: {
      type: 'object',
      properties: {
        data: {
          type: 'array',
          items: {
            type: 'object',
            properties: {
              id: { type: 'string' },
              name: { type: 'string' },
              origin: { type: 'string' },
              roast: { type: 'string' },
              priceEuros: { type: 'number' },
              stock: { type: 'integer' },
            },
          },
        },
        total: { type: 'integer' },
      },
    },
    400: { $ref: 'Error#' },
  },
};

export default async function coffeeRoutes(fastify) {
  fastify.get(
    '/coffees',
    { schema: listCoffeesSchema, preHandler: [fastify.authenticate] },
    async (request) => {
      // request.query is already validated AND has the defaults applied
      const { data, total } = await fastify.services.coffees.list(request.query);
      // No need for res.json(): returning the object is enough.
      return { data: data.map(coffeeToRepresentation), total };
    },
  );
}

Three things Fastify does differently that are worth understanding:

  • Validation is declarative and automatic. There is no validate middleware: the framework handles the querystring schema, and produces the 400 on its own. You gain a guarantee —you cannot forget— and you lose control over the error format, which has to be customised with setErrorHandler so that it fits our catalogue.
  • The response schema compiles a serialiser. Fastify turns that response.200 into a specialised serialisation function, faster than a generic JSON.stringify. A crucial side effect: fields not declared in the schema are stripped from the response. It is superb protection against accidental data leaks (04-02) and, at the same time, the number one cause of "I added a field and it does not show up".
  • Plugins have real encapsulation. A plugin registered in one scope does not contaminate the others, unlike app.use in Express, which is global. That makes it possible, for example, to apply a different rate limit to /v1/sessions without tricks.

Documentation is nearly free, because the schemas are already written:

// server.js — Fastify generates OpenAPI from the route schemas
await fastify.register(import('@fastify/swagger'), {
  openapi: {
    info: { title: 'Aroma Store API', version: '1.7.0' },
    servers: [{ url: 'https://api.aromastore.example/v1' }],
  },
});
await fastify.register(import('@fastify/swagger-ui'), { routePrefix: '/docs' });

Here is the practical difference with 05-02: in Express we wrote openapi.yaml by hand and risked drift; in Fastify the schema is the validation and is the documentation. In exchange, the resulting specification is poorer in descriptions and examples if nobody writes them.

  1. NestJS: opinionated architecture for large teams

NestJS is the most opinionated framework in the Node ecosystem. It brings TypeScript, decorators, modules and dependency injection; its mental model comes from Angular and, further back, from Spring.

// coffees/dto/coffee-query.dto.ts — the input contract as a CLASS
import { IsOptional, IsIn, IsInt, IsNumber, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';

export class CoffeeQueryDto {
  @ApiPropertyOptional({ enum: ['light', 'medium', 'dark'] })
  @IsOptional()
  @IsIn(['light', 'medium', 'dark'])
  roast?: 'light' | 'medium' | 'dark';

  @ApiPropertyOptional({ minimum: 0 })
  @IsOptional()
  @Type(() => Number)          // the query arrives as a string: it has to be converted
  @IsNumber()
  @Min(0)
  priceMax?: number;

  @ApiPropertyOptional({ default: 20, maximum: 100 })
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  limit: number = 20;

  @ApiPropertyOptional({ default: 0, maximum: 10000 })
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(0)
  @Max(10000)
  offset: number = 0;
}
// coffees/coffees.controller.ts
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOkResponse, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CoffeesService } from './coffees.service';
import { CoffeeCollectionDto } from './dto/coffee-collection.dto';

@ApiTags('Coffees')
@ApiBearerAuth()
@Controller('coffees')                     // the /v1 prefix is set globally
@UseGuards(JwtAuthGuard)                   // authentication for the whole controller
export class CoffeesController {
  // Constructor dependency injection: Nest resolves CoffeesService on its own.
  // This is what makes swapping it for a double in tests trivial.
  constructor(private readonly coffeesService: CoffeesService) {}

  @Get()
  @ApiOkResponse({ type: CoffeeCollectionDto })
  async getCoffees(@Query() query: CoffeeQueryDto): Promise<CoffeeCollectionDto> {
    // query is already validated and transformed by the global ValidationPipe.
    return this.coffeesService.list(query);
  }
}
// coffees/coffees.module.ts — the module declares what it uses and what it exposes
import { Module } from '@nestjs/common';
import { CoffeesController } from './coffees.controller';
import { CoffeesService } from './coffees.service';
import { CoffeesRepository } from './coffees.repository';

@Module({
  controllers: [CoffeesController],
  providers: [
    CoffeesService,
    // The repository is injected by token: swapping SQLite for PostgreSQL
    // or for a double in tests means changing this line, and nothing else.
    { provide: 'COFFEES_REPOSITORY', useClass: CoffeesRepository },
  ],
  exports: [CoffeesService],
})
export class CoffeesModule {}

What you gain. An identical structure across every project and team, which cuts onboarding for a new joiner down to days. Real dependency injection, which makes testing with doubles trivial —what in 03-08 we achieved by hand with the in-memory repository. OpenAPI documentation generated from the DTOs with @nestjs/swagger. And a convention so strong that arguments about folder structure simply disappear.

What you pay. A lot of code for a little: the DTO above is thirty lines for what takes six in Zod. A real learning curve —modules, providers, scopes, guards, interceptors, pipes. Higher start-up time and memory use. And a layer of abstraction that, when it fails, forces you to understand its internals.

When it pays off. Teams of more than five people, long-running projects, complex domains with many modules. For an API with six endpoints it is a suit several sizes too big.

  1. Hono: lightweight and multi-runtime

Hono answers a newer question: what if your API does not run on a Node server, but at the edge of the network —Cloudflare Workers, Deno Deploy, Bun?

// routes/coffees.ts — Hono
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { authenticate } from '../middleware/authentication';

const querySchema = z.object({
  roast: z.enum(['light', 'medium', 'dark']).optional(),
  priceMax: z.coerce.number().min(0).optional(),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  offset: z.coerce.number().int().min(0).max(10000).default(0),
});

export const coffeeRoutes = new Hono();

coffeeRoutes.get(
  '/coffees',
  authenticate,
  zValidator('query', querySchema, (result, c) => {
    // Our own error handler: this is how the 400 fits OUR catalogue
    if (!result.success) {
      return c.json({
        error: {
          code: 'invalid_parameter',
          message: 'Invalid query parameters.',
          details: result.error.issues.map((i) => ({
            field: i.path.join('.'),
            problem: i.message,
          })),
        },
      }, 400);
    }
  }),
  async (c) => {
    const query = c.req.valid('query');            // typed, with no assertions
    const { data, total } = await listCoffees(c.env.DB, query);
    return c.json({ data: data.map(coffeeToRepresentation), total });
  },
);

The interesting thing about Hono is not the syntax —very close to Express— but that it uses standard web APIs: Request, Response, fetch. The same code runs on Node, Deno, Bun, Cloudflare Workers and AWS Lambda. It is tiny (a few kilobytes), which matters a great deal in environments where cold start is measured in milliseconds.

Its limit is the other side of the same coin: at the edge you have no file system and no persistent TCP connections, so better-sqlite3 and the PostgreSQL pool from 03-05 do not exist; you have to use data services with an HTTP API. And its ecosystem is far smaller than Express's.

  1. FastAPI (Python): typing as the contract

FastAPI is probably the best demonstration of one idea: if the language has type annotations, the framework can derive validation, serialisation and documentation from them.

# routes/coffees.py — FastAPI
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field

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


class Coffee(BaseModel):
    """A coffee from the catalogue, as exposed by the API."""
    id: str = Field(pattern=r"^cof_[A-Za-z0-9]+$", examples=["cof_001"])
    name: str = Field(max_length=120)
    origin: str
    roast: Literal["light", "medium", "dark"]
    price_euros: float = Field(serialization_alias="priceEuros", ge=0)
    stock: int = Field(ge=0)


class CoffeeCollection(BaseModel):
    data: list[Coffee]
    total: int = Field(description="Total items matching the filter.")


@router.get(
    "",
    response_model=CoffeeCollection,
    operation_id="getCoffees",
    summary="Lists the coffee catalogue",
    responses={400: {"description": "Invalid query parameter."}},
)
async def get_coffees(
    # Each parameter is a typed argument: FastAPI validates and documents it.
    roast: Annotated[Literal["light", "medium", "dark"] | None, Query()] = None,
    price_max: Annotated[float | None, Query(ge=0, alias="priceMax")] = None,
    limit: Annotated[int, Query(ge=1, le=100)] = 20,
    offset: Annotated[int, Query(ge=0, le=10_000)] = 0,
    sort: Annotated[str, Query()] = "name",
    # Injected dependency: validates the JWT and returns the user, or raises 401.
    user: Annotated[User, Depends(current_user)] = None,
) -> CoffeeCollection:
    data, total = await coffee_service.list(
        roast=roast, price_max=price_max,
        limit=limit, offset=offset, sort=sort,
    )
    return CoffeeCollection(data=data, total=total)

What happens with those twenty lines, without writing anything else:

  • Full validation of types and ranges, with an automatic 422 (customisable into our 400).
  • Type conversion: limit=20 arrives as text and is handed over as an int.
  • Complete OpenAPI 3.1 documentation, served at /docs with Swagger UI and at /redoc with Redoc, without an extra line.
  • Serialisation with the price_eurospriceEuros aliases, which resolves the eternal clash between Python's snake_case and JSON's camelCase.
  • Dependency injection with Depends, which also makes replacing current_user in the tests trivial.

FastAPI is the most elegant answer on the scene to the code-versus-contract drift from 05-02. Its limits: Python is slower than compiled runtimes —although FastAPI, on top of async, is among the fastest in that ecosystem— and Python's async is easy to get wrong: a blocking call inside an async function freezes the entire event loop, a mistake as classic as the forgotten await in Node.

  1. Django REST Framework (Python): serializers and viewsets

DRF is the other school: not an API framework, but an API layer on top of a complete web framework. Its premise is that you already have Django models and want to expose them.

# coffees/serializers.py
from rest_framework import serializers
from .models import Coffee


class CoffeeSerializer(serializers.ModelSerializer):
    """Converts the Coffee model into the public representation and back."""
    # The model stores whole cents; the contract exposes euros.
    priceEuros = serializers.SerializerMethodField()
    tastingNotes = serializers.ListField(source="tasting_notes", child=serializers.CharField())

    class Meta:
        model = Coffee
        fields = ["id", "name", "origin", "roast", "priceEuros", "stock", "tastingNotes"]
        read_only_fields = ["id"]

    def get_priceEuros(self, obj) -> float:
        return obj.price_cents / 100
# coffees/views.py
from rest_framework import viewsets, permissions
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import OrderingFilter
from .models import Coffee
from .serializers import CoffeeSerializer
from .pagination import AromaPagination


class CoffeeViewSet(viewsets.ModelViewSet):
    """
    A ViewSet generates list, retrieve, create, update and destroy in one go.
    It is the fullest expression of Django's "convention over configuration".
    """
    queryset = Coffee.objects.all()
    serializer_class = CoffeeSerializer
    permission_classes = [permissions.IsAuthenticated]
    pagination_class = AromaPagination           # produces {"data": [...], "total": n}
    filter_backends = [DjangoFilterBackend, OrderingFilter]
    filterset_fields = {
        "roast": ["exact", "in"],
        "origin": ["exact", "icontains"],
        "price_cents": ["gte", "lte"],
    }
    ordering_fields = ["name", "price_cents", "stock"]
    ordering = ["name"]
# coffees/urls.py — the router generates every route for the resource
from rest_framework.routers import DefaultRouter
from .views import CoffeeViewSet

router = DefaultRouter()
router.register(r"coffees", CoffeeViewSet, basename="coffee")
urlpatterns = router.urls

Forty lines produce the resource's seven endpoints in full, with filtering, sorting, pagination, permissions and a browsable HTML console. It is, by a distance, the highest initial productivity in this whole tour.

The price. ViewSets expose the shape of your data model, not the shape of your API contract, and that collides head-on with 02-02: the day the contract has to diverge from the model —computed fields, aggregations, different names, state-transition subresources such as /orders/{id}/payment— you fight the framework. It can be done, with serializers and custom actions, but each exception costs more than it would have if you had written it by hand. On top of that, Django brings a whole philosophy with it: its ORM, its migration system, its admin. If you are not going to use them, DRF is dead weight.

When it pays off: a CRUD over a relational model that already exists, admin panel included. It is unbeatable on that ground.

  1. Spring Boot (Java): the enterprise standard

Spring Boot is the framework that underpins more corporate APIs than any other in the world. Its model —annotations, dependency injection, layers— is the one NestJS imitates.

// CoffeeController.java
package example.aromastore.coffees;

import jakarta.validation.constraints.*;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;

@RestController
@RequestMapping("/v1/coffees")
@Tag(name = "Coffees", description = "Speciality coffee catalogue")
public class CoffeeController {

    private final CoffeeService coffeeService;

    // Constructor injection: Spring resolves the dependency at start-up.
    public CoffeeController(CoffeeService coffeeService) {
        this.coffeeService = coffeeService;
    }

    @GetMapping
    @PreAuthorize("isAuthenticated()")
    @Operation(operationId = "getCoffees", summary = "Lists the coffee catalogue")
    public ResponseEntity<CoffeeCollection> getCoffees(
            @RequestParam(required = false)
            @Pattern(regexp = "light|medium|dark", message = "invalid roast")
            String roast,

            @RequestParam(required = false) @DecimalMin("0") BigDecimal priceMax,

            @RequestParam(defaultValue = "20") @Min(1) @Max(100) int limit,

            @RequestParam(defaultValue = "0") @Min(0) @Max(10000) int offset,

            @RequestParam(defaultValue = "name") String sort) {

        var filter = new CoffeeFilter(roast, priceMax, sort);
        var page = coffeeService.list(filter, limit, offset);

        return ResponseEntity.ok()
                .eTag(page.etag())                       // 04-06
                .header("Link", page.linkHeader())       // 02-06
                .body(new CoffeeCollection(page.data(), page.total()));
    }
}
// CoffeeCollection.java — a record: immutable and concise
public record CoffeeCollection(List<CoffeeDto> data, long total) {}

// CoffeeDto.java — the public representation, separate from the JPA entity
public record CoffeeDto(
        String id,
        String name,
        String origin,
        String roast,
        BigDecimal priceEuros,   // BigDecimal, NEVER double, for money
        int stock) {}

Java cleanly solves something that is a real problem in JavaScript: BigDecimal for money. In JavaScript, 0.1 + 0.2 is not 0.3, which is why in 02-05 we decided to store whole cents. Java has an exact decimal type out of the box, as does C# with decimal.

Its ecosystem is the strongest argument: Spring Security for OAuth and OIDC (04-03), Spring Data for persistence, Actuator giving you /health and Prometheus metrics (04-07) practically for free, springdoc-openapi for the documentation. Everything we built piece by piece in module 4 exists here as a standard, reviewed dependency with commercial support.

The price: verbosity, slow start-up (seconds, although GraalVM mitigates it), high memory use, and a long learning curve where the problem is not Java but the sheer number of Spring concepts.

  1. ASP.NET Core (C#): minimal APIs and performance

ASP.NET Core is, in public benchmarks, one of the fastest web frameworks in existence, and its minimal API mode removes a good part of C#'s classic ceremony.

// Program.cs — a complete minimal API
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<ICoffeeService, CoffeeService>();   // dependency injection
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
builder.Services.AddEndpointsApiExplorer();                    // metadata for OpenAPI
builder.Services.AddSwaggerGen();

var app = builder.Build();

app.MapGet("/v1/coffees", async (
        [AsParameters] CoffeeQuery query,
        ICoffeeService service) =>
    {
        // Range validation is done with an endpoint filter or with
        // a library such as FluentValidation; C# does not ship it.
        if (query.Limit is < 1 or > 100)
        {
            return Results.BadRequest(new ApiError(
                "invalid_parameter", "The 'limit' parameter must be between 1 and 100."));
        }

        var (data, total) = await service.ListAsync(query);
        return Results.Ok(new CoffeeCollection(data, total));
    })
    .RequireAuthorization()
    .WithName("getCoffees")
    .WithTags("Coffees")
    .WithOpenApi();

app.Run();

// Contract types: immutable records, with decimal for money
record CoffeeQuery(string? Roast, decimal? PriceMax, int Limit = 20,
                   int Offset = 0, string Sort = "name");

record CoffeeDto(string Id, string Name, string Origin, string Roast,
                 decimal PriceEuros, int Stock);

record CoffeeCollection(IReadOnlyList<CoffeeDto> Data, long Total);

record ApiError(string Code, string Message, object[]? Details = null);

Notable points: [AsParameters] groups the query parameters into a typed record, decimal gives exact arithmetic for money, and performance on the same hardware is usually among the best around. Its relatively weak spot is declarative validation, which does not ship with the power of Pydantic or Zod.

When to choose it: organisations already in the Microsoft ecosystem, or when per-server performance is a real cost factor. And it is worth dismantling the prejudice: ASP.NET Core is cross-platform, open source and runs on Linux and in containers without fuss.

  1. Honourable mentions: Laravel, Rails API and Go

Laravel (PHP). Still dominant in the PHP web, and its API mode with Eloquent, API Resources and Sanctum is productive and well documented. Its hosting is the cheapest and most available in the world. Stigmatised for no reason: modern PHP with types has little to do with the PHP of fifteen years ago.

Ruby on Rails API (rails new --api). Father of the "convention over configuration" that inspired half the industry. Enormous initial productivity, ideal for prototypes and startups. Its per-process performance is modest and the ecosystem has lost momentum against others.

Go with net/http (which since Go 1.22 routes with method-and-path patterns), or with Gin or Echo:

// handlers/coffees.go — Go with the standard library
func GetCoffees(service *CoffeeService) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		query, err := parseCoffeeQuery(r.URL.Query())
		if err != nil {
			// Explicit error handling is Go's trademark:
			// verbose, but no error slips through unnoticed.
			respondError(w, http.StatusBadRequest, "invalid_parameter", err.Error())
			return
		}

		data, total, err := service.List(r.Context(), query)
		if err != nil {
			respondError(w, http.StatusInternalServerError, "internal_error", "")
			return
		}

		respondJSON(w, http.StatusOK, CoffeeCollection{Data: data, Total: total})
	}
}

Go stands out in three ways: a single binary with no runtime (Docker images of a few megabytes, instant start-up), concurrency with goroutines and very little memory per request, and a standard library so complete that many teams use no framework at all. In exchange, you write more code: there is no declarative validation and no automatic OpenAPI generation without additional tooling.

  1. The comparison table

Framework Language Curve Opinionated Validation Automatic OpenAPI Relative performance Typing Ecosystem When to choose it
Express JavaScript Very low Not at all Manual (Zod) No Medium Optional (JSDoc/TS) Enormous Learning; small APIs; maximum control
Fastify JavaScript/TS Low A little Built-in JSON Schema Yes (plugin) High Good with TS Large Node with performance and contract demands
NestJS TypeScript High Very class-validator Yes (@nestjs/swagger) Medium Excellent Large Large teams, complex domain, long term
Hono TypeScript Low A little Zod (adapter) Yes (plugin) Very high Excellent Medium Edge, serverless, multi-runtime
FastAPI Python Low Medium Pydantic, built in Yes, native and complete Medium-high Excellent Large Data-heavy APIs, ML, fast but serious prototyping
Django REST Python Medium Very Serializers Yes (drf-spectacular) Medium-low Weak Enormous CRUD over a relational model with admin
Spring Boot Java High Very Bean Validation Yes (springdoc) High Excellent Enormous Enterprise, integrations, long-term support
ASP.NET Core C# Medium Medium Manual/FluentValidation Yes (Swashbuckle) Very high Excellent Large Microsoft ecosystem; per-server cost
Laravel PHP Low Very Form Requests Yes (packages) Medium Medium Enormous PHP web, cheap hosting, fast delivery
Rails API Ruby Low Very Model Yes (packages) Medium-low Weak Large Prototypes, startups, initial productivity
Go + Gin Go Medium A little Manual/tags Partial Very high Excellent Medium High-performance services, minimal containers

How to read the performance column. It is an approximate ranking of requests per second in synthetic "return a small JSON" tests. It almost never describes your real API, because as soon as there is a database query, a network call or a template, the framework stops being the bottleneck. A GET /v1/coffees that takes 40 ms of which 35 are the SQL query performs practically the same in Express as in Fastify. If your p99 is high, measure before blaming the framework: the answer is usually in the indexes and N+1 section of 04-06.

  1. Honest selection criteria

The real criteria, ordered by the weight they should carry:

1. The language your team knows. It is criterion number one, by a long way. A team expert in Python will deliver sooner and with fewer bugs in FastAPI than in the fastest Node framework in the world. The cost of learning a new language —not the syntax, but its idioms, its debugging, its packaging, its traps— is measured in months of reduced productivity and in subtle bugs in production.

2. Hiring and the job market. If tomorrow you need two more people, can you find them? In your city, or in your time zone? Choosing a niche framework because it is elegant and then discovering there is nobody to hire is an expensive and common mistake.

3. Maturity and support horizon. Who maintains it? Is there a company behind it, a foundation, one person? What is the version policy? Spring and Django have been going for more than fifteen years and will keep going; a two-year-old framework with a single maintainer is a bet. This criterion counts double if the API is long-lived and triple if you are in a regulated sector.

4. Fit with the problem. A CRUD over a relational model with an admin panel is crying out for DRF. An API at the edge with minimal latency calls for Hono. A complex domain with twenty modules and fifteen people calls for NestJS or Spring. Forcing the tool against the problem is paid for every week.

5. Ecosystem for what you need. Not the ecosystem in the abstract: is there a mature client for your database, your OAuth provider, your payment gateway, your queue system? Discovering in week six that your gateway's SDK does not exist in that language is a serious problem.

6. Performance. Last, unless you are in a specific case where it matters: tens of thousands of requests per second, sub-10 ms latency, or a server bill big enough that 30 % efficiency is real money. For 95 % of APIs, the difference between frameworks is irrelevant next to one query with no index.

And a warning about public benchmarks: they measure an endpoint returning {"hello":"world"}, with configurations tuned by specialists and with no database, no authentication, no validation and no logs. They are useful for ruling out orders of magnitude and misleading for everything else. Measure your real API with autocannon (04-06) before taking any decision based on speed.

  1. What does not depend on the framework

This is the central observation of the lesson. Look back over the course:

Module Does it depend on the framework?
1 — HTTP, REST, constraints, HATEOAS No. It is HTTP and architecture.
2 — Resources, methods, codes, errors, pagination, versioning No. It is contract design.
3 — Environment, routes, validation, persistence, authentication, errors, testing Yes. Here everything changes.
4 — Security, OAuth, rate limiting, CORS, caching, observability Almost nothing. The concepts and the headers are identical; only the library changes.
5 — Postman, OpenAPI, contracts, CI/CD, gateways No. It all works over HTTP.
6 — Case studies and evolution No.

Out of six modules, one changes. And not even all of it: the layer separation from 03-03 —routes, controllers, services, repositories— survives in all of them under other names, because it is not an Express idea.

Concrete examples of what transfers unchanged:

  • That POST /v1/orders requires Idempotency-Key and returns 201 with Location is contract. The same in all nine.
  • That an ETag matching If-None-Match produces 304 is dictated by HTTP. What changes is the function that writes it, not the rule.
  • That the JWT carries sub, role and exp, and that validation checks signature, expiry and issuer, is OAuth and JWT. The library changes.
  • That an error comes back as {"error": {"code", "message", "details"}} is your catalogue.
  • That metric labels must not include ord_5001 because it blows up cardinality is a Prometheus rule.

Practical conclusion: if you know how to design and operate APIs, learning a new framework is a matter of a week or two. If all you know is Express, you do not know how to build APIs: you know how to use Express. The transferable knowledge is that of modules 1, 2, 4 and 5.

  1. Migrating between frameworks: from Express to Fastify

Suppose Aroma Store decides to migrate to Fastify for performance and for the automatic OpenAPI generation. What happens to the project?

graph TD
    A[Express project] --> B{What is kept?}
    B --> C[openapi.yaml<br/>The contract does not change]
    B --> D[services/<br/>Pure business logic]
    B --> E[repositories/<br/>SQL and persistence]
    B --> F[Zod schemas<br/>convertible to JSON Schema]
    B --> G[integration tests<br/>Supertest over HTTP]
    B --> H[Postman collection<br/>and Newman]
    A --> I{What is rewritten?}
    I --> J[routes/<br/>Router → plugins]
    I --> K[middleware/<br/>hooks and decorators]
    I --> L[app.js<br/>order of the chain]
    I --> M[controllers/<br/>req,res signature → async]

What is kept is everything that does not touch req and res: the services in src/services/, the repositories in src/repositories/, the mappers, src/errors/api-error.js, the migrations and —most valuable of all— openapi.yaml and the integration tests from 03-08, because they speak HTTP and do not care who answers. Those tests are the safety net that makes the migration possible: if they pass with Fastify, the migration is correct by definition.

What is rewritten is the delivery layer: routes, middleware and the composition of the application.

Here is the same endpoint, before and after:

// BEFORE — Express
coffeeRoutes.get('/', authenticate, validate(coffeeQuerySchema, 'query'),
  asyncHandler(async (req, res) => {
    const { data, total } = await coffeeService.list(req.validated.query);
    res.json({ data: data.map(coffeeToRepresentation), total });
  }));

// AFTER — Fastify
// - `authenticate` goes from middleware to `preHandler`.
// - `validate` disappears: the framework's own schema does it.
// - `asyncHandler` disappears: Fastify catches rejected promises out of the box.
// - `res.json(...)` is replaced by returning the object.
fastify.get('/coffees', {
  schema: listCoffeesSchema,
  preHandler: [fastify.authenticate],
}, async (request) => {
  const { data, total } = await coffeeService.list(request.query);
  return { data: data.map(coffeeToRepresentation), total };
});

The controller is identical except for the signature. The Zod schemas are converted with zod-to-json-schema, the tool we already saw in 05-02.

The migration recipe, if you ever have to do it:

  1. Freeze the contract. Not one functional change during the migration. If you mix the two, you will not know whether a failure comes from the migration or from the new feature.
  2. Secure the integration tests first. They are the objective criterion for success. If endpoint coverage is low, raise it before touching anything.
  3. Migrate resource by resource, not all at once. With a proxy in front you can serve /v1/coffees from the new service and everything else from the old one. It is the strangler fig pattern, and it is what makes migrating a production system feasible.
  4. Compare responses byte for byte. Run the Postman collection from 05-01 against both and diff them. The surprises are usually in the headers and in the field order.
  5. Watch the small details, because they are the ones that bite: the exact format of the validation error, the order of JSON keys, whether the ETag is weak or strong, the encoding of repeated parameters.

Is it worth it? Almost never for performance alone. It is worth it when the current framework blocks something important: lack of typing in a growing team, the absence of an ecosystem you need, or abandoned maintenance. "It is more modern" is not a reason; it is spending with no return.

  1. Alternative runtimes and serverless

Two more axes, briefly, because they affect the choice as much as the framework does.

JavaScript runtimes:

Runtime Proposition Status
Node.js The standard; complete npm ecosystem What Aroma Store uses. Node 20 LTS.
Deno Secure by default (explicit permissions), native TypeScript, its own standard library Mature; npm compatibility already good
Bun Extreme speed, built-in package manager and test runner Young but usable; compatible with most of Express

Deno's most interesting detail for what we saw in 04-02: permissions are explicit (--allow-net=api.aromastore.example), so a compromised dependency cannot read your disk or open connections to an unknown server. It is a real defence against the supply-chain attacks we mentioned alongside npm audit.

Serverless. Your API does not run as a permanent process, but as functions invoked on demand: AWS Lambda with API Gateway, Google Cloud Functions, Azure Functions, Cloudflare Workers.

Permanent server Serverless
Cost with no traffic The server's Zero
Cost with heavy traffic Predictable Can spiral
Scaling You configure it Automatic
Cold start Does not exist From tens of ms to seconds
Database connections Stable pool A serious problem: you need a pooler
In-memory state Possible (local cache) Unreliable
Local debugging Straightforward More awkward

Direct implications for what we have built: the rate limiting with local memory from 04-04 does not work in serverless —each invocation can land on a different instance— so Redis moves from advisable to mandatory; the in-process cache disappears; and the connection pool from 03-05 becomes a problem that demands an external pooler.

Express runs on Lambda with adapters (serverless-http), but Hono is designed for that environment and starts in a fraction of the time. It is a good example of how the deployment environment —the subject of 05-05— shapes the choice of framework as much as the language does.

Common Mistakes and Tips

  • Choosing by benchmark. The charts measure {"hello":"world"} with no database and no authentication. Your p99 is dominated by the SQL query, not by the router.
  • Choosing by fashion. The framework everyone is talking about this year may be unmaintained in three. Check who is holding it up and with what version policy.
  • Choosing a language the team does not know. It is the decision that sinks the most projects. Syntax is learned in a week; debugging, packaging and the runtime's traps take months.
  • Confusing the framework with the architecture. DRF or NestJS do not give you a good API design: they give you a structure. A misused ViewSet exposes your data model and violates the whole of module 2.
  • Migrating without tests. Without the integration tests from 03-08, a migration is a blind rewrite. Secure them before starting.
  • Mixing migration and new features. When something fails you will not know which half it came from. Freeze the contract.
  • Forgetting that Fastify strips undeclared fields from the response schema. It is a security virtue and the most frequent cause of "my new field does not come out".
  • Believing that "code first" spares you from thinking about the contract. Types are generated; the design from 02-02 is not.
  • Tip: learn a second framework in another language, not another Node one. Comparing Express with Fastify teaches you little; comparing Express with FastAPI or Spring teaches you what is essential and what is accidental.
  • Tip: keep the business logic out of the framework. If your services import nothing from Express, migrating means changing the delivery layer. That is the whole point of the separation in 03-03, and its value is only appreciated the day you need it.
  • Tip: write an ADR with the decision (04-01), with the criteria and the alternatives you rejected. In two years somebody will ask why, and without an ADR the answer will be "just because".

Exercises

Exercise 1: choosing a framework for three scenarios

For each scenario, choose a framework, justify it with at least three criteria from section 14 and name the alternative you are rejecting and why:

A) A three-person startup with Python experience is launching the MVP of a restaurant-booking API in eight weeks. They need an internal admin panel from day one and they expect constant model changes.

B) A bank is modernising its transaction-enquiry API. Ten people, audit and traceability requirements, integration with a corporate identity provider, and guaranteed support for ten years.

C) A geolocation API receiving 50,000 requests per second, returning very small responses from an in-memory cache, and required to answer in under 20 ms anywhere in the world.

Exercise 2: porting the reviews endpoint

Here is the GET /v1/coffees/{id}/reviews endpoint in Express:

coffeeRoutes.get('/:id/reviews',
  authenticate,
  validate(coffeeIdParamsSchema, 'params'),
  validate(reviewQuerySchema, 'query'),
  cacheFor({ maxAge: 300, isPublic: true }),
  asyncHandler(async (req, res) => {
    const { id } = req.validated.params;
    const { limit, offset, ratingMin } = req.validated.query;
    const { data, total } = await reviewService.listForCoffee(id, {
      limit, offset, ratingMin,
    });
    res.set('Link', buildLinkHeader(req, total, limit, offset));
    res.json({ data: data.map(reviewToRepresentation), total });
  }));

Port it to Fastify keeping the same contract: same parameters and validations, same Cache-Control and Link headers, and the same invalid_parameter error format. State which pieces disappear, which ones change name and what has to be added explicitly that in Express lived in a middleware.

Exercise 3: what carries over from this course

A colleague is joining a Spring Boot project coming from this course, which was done in Express. Write a one-page guide telling them: which knowledge from modules 1, 2, 4 and 5 applies as it is (with three concrete examples), what they have to relearn from module 3, and what the Spring equivalent is of five pieces of our project (middleware/authentication.js, middleware/errors.js, repositories/coffees-sqlite.js, schemas/coffees.js and observability/metrics.js).

Solutions

Solution 1

A) Booking startup → Django REST Framework.

Criterion Analysis
The team's language They already know Python. It is the heaviest criterion and it rules out Node and Java from the start.
Fit with the problem CRUD over a relational model (restaurants, tables, bookings, customers) with an admin panel: it is literally the case DRF exists for.
Deadline Eight weeks. The free django-admin saves weeks against building a panel. ViewSets give the seven endpoints per resource with almost no code.
Ecosystem Migrations, authentication and admin already solved; the team only writes business rules.

Rejected alternative: FastAPI. Better contract and better automatic documentation, but it brings no ORM, no migrations and no admin: you would have to assemble SQLAlchemy, Alembic and a panel, and there go the eight weeks. It would be reconsidered in two years if the API stops being a CRUD and the contract starts to diverge from the model.

B) Bank → Spring Boot.

Criterion Analysis
Maturity and horizon More than fifteen years, versions with extended support and commercial backing from VMware/Broadcom. In a regulated sector, "who answers for it" is a requirement, not a preference.
Ecosystem Spring Security with OIDC integrated with the corporate provider (04-03); Actuator gives health and Prometheus metrics (04-07) out of the box; audit and traceability are standard pieces.
Team size Ten people: the opinionated structure prevents ten different ways of organising the code and speeds up onboarding.
Hiring The enterprise Java market is the deepest, especially in banking.
Decimal precision BigDecimal out of the box: with money, a hard requirement.

Rejected alternative: NestJS. It gives a similar architecture with TypeScript and would be a defensible choice, but it loses on support horizon, on the maturity of the enterprise security and audit ecosystem, and on the depth of the banking job market. On top of that, JavaScript has no native decimal type, which in a financial system is constant friction.

C) Geolocation at 50,000 rps → Go, or Hono on Cloudflare Workers.

Criterion Analysis
Performance Here it really is a first-order criterion: 50,000 rps with cached responses is exactly the scenario where the framework is the bottleneck.
Fit Small responses from memory, with no database per request: the work is routing, looking up and serialising.
Global latency (<20 ms) Impossible from a single region: it demands presence at the edge. Cloudflare Workers with Hono solves it by design; with Go you would need multi-region deployments and anycast routing.
Cost Go gives images of a few megabytes and minimal memory use: fewer machines for the same traffic.

Decision: Hono at the edge if the dataset fits in a distributed KV-style store; multi-region Go if the data is large or the computation is intensive. Rejected: Express, NestJS and DRF: at that volume the efficiency difference translates straight into the bill, and DRF additionally drags along an ORM this case does not use.

Solution 2

// routes/reviews.js — Fastify

// The schema declares the WHOLE input and output contract.
const coffeeReviewsSchema = {
  tags: ['Reviews'],
  summary: 'Lists the reviews of a coffee',
  operationId: 'getCoffeeReviews',
  security: [{ bearerJWT: [] }],
  params: {
    type: 'object',
    required: ['id'],
    properties: {
      id: { type: 'string', pattern: '^cof_[A-Za-z0-9]+$' },
    },
  },
  querystring: {
    type: 'object',
    additionalProperties: false,
    properties: {
      limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
      offset: { type: 'integer', minimum: 0, maximum: 10000, default: 0 },
      ratingMin: { type: 'integer', minimum: 1, maximum: 5 },
    },
  },
  response: {
    200: {
      type: 'object',
      properties: {
        data: { type: 'array', items: { $ref: 'Review#' } },
        total: { type: 'integer' },
      },
    },
    400: { $ref: 'Error#' },
    404: { $ref: 'Error#' },
  },
};

export default async function reviewRoutes(fastify) {
  fastify.get('/coffees/:id/reviews', {
    schema: coffeeReviewsSchema,
    preHandler: [fastify.authenticate],
    // HTTP caching is applied with an onSend hook, because in Fastify
    // response headers are touched in the lifecycle, not in a middleware.
    onSend: async (request, reply, payload) => {
      reply.header('Cache-Control', 'public, max-age=300');
      reply.header('Vary', 'Accept-Encoding, Origin');
      return payload;
    },
  }, async (request, reply) => {
    const { id } = request.params;
    const { limit, offset, ratingMin } = request.query;

    const { data, total } = await fastify.services.reviews.listForCoffee(id, {
      limit, offset, ratingMin,
    });

    // The Link header is still built by hand: it is contract logic,
    // not something any framework solves for you.
    reply.header('Link', buildLinkHeader(request, total, limit, offset));

    return { data: data.map(reviewToRepresentation), total };
  });
}

And the global error handler, essential so that Fastify's automatic 400 speaks our language:

// app.js — translates Fastify's validation errors into our catalogue
fastify.setErrorHandler((error, request, reply) => {
  if (error.validation) {
    const inQuery = error.validationContext === 'querystring';
    return reply.status(400).send({
      error: {
        code: inQuery ? 'invalid_parameter' : 'invalid_data',
        message: inQuery
          ? 'Invalid query parameters.'
          : 'The body contains invalid fields.',
        details: error.validation.map((v) => ({
          field: v.instancePath.replace('/', '') || v.params?.additionalProperty,
          problem: v.message,
        })),
      },
    });
  }
  // ApiError and the rest are translated just as in src/middleware/errors.js
  return errorHandler(error, request, reply);
});

Balance of this endpoint's migration:

Piece in Express In Fastify
validate(coffeeIdParamsSchema, 'params') Disappears: schema.params
validate(reviewQuerySchema, 'query') Disappears: schema.querystring
asyncHandler(...) Disappears: it catches promises out of the box
authenticate Changes name: preHandler
cacheFor({...}) Changes shape: onSend hook
res.set('Link', ...) reply.header('Link', ...)
res.json({...}) return {...}
The 400 error format Has to be added: setErrorHandler
buildLinkHeader(...) Identical: it is contract logic
reviewToRepresentation(...) Identical: it is a pure mapper

What has to be added explicitly is the most important item on the list: in Express we controlled the 400 format because we wrote it ourselves; in Fastify, if setErrorHandler is not registered, the automatic validation returns the framework's format and breaks the error contract from 02-04 without anyone noticing until a consumer complains. It is the perfect illustration that a framework's automatic guarantees have to be steered towards your contract, not the other way round.

Solution 3

Onboarding guide: from Express to Spring Boot

What you apply as it is (modules 1, 2, 4 and 5).

80 % of what you know is still valid, because it describes HTTP and design, not Express. Three concrete examples:

  1. The contract design (module 2). That resources are plural nouns, that state transitions are expressed as subresources (POST /orders/{id}/payment) and not as a PATCH on status, that a 201 carries Location, that a filter with no results is 200 with an empty list and not 404, and that pagination is mandatory with limit and offset. None of this changes: you will write it with @PostMapping instead of router.post, and that is all.
  2. Caching and concurrency (04-06). ETag, If-None-Match304, If-Match412, Cache-Control with max-age. Spring exposes it with ResponseEntity.ok().eTag(...) and ShallowEtagHeaderFilter, but the rules are HTTP's and the policy decisions are the same ones you took.
  3. The whole of module 5. The Postman collection works without touching a line, because it speaks HTTP. openapi.yaml is the same document. The CI pipeline swaps npm ci for mvn verify and little else. And a gateway in front cannot tell what is behind it.

What you have to relearn (module 3).

Only the delivery layer and its tools: annotations instead of middleware, Bean Validation instead of Zod, JPA or JDBC instead of better-sqlite3, JUnit and MockMvc instead of node:test and Supertest, and —the most alien thing coming from Node— the dependency injection container: in Spring you do not instantiate your services, you declare them and the framework builds and injects them.

Table of equivalences:

Aroma Store piece Spring Boot equivalent Note
src/middleware/authentication.js Spring Security's SecurityFilterChain with oauth2ResourceServer().jwt() Spring validates signature, expiry, issuer and scopes; roles are checked with @PreAuthorize("hasRole('ADMINISTRATOR')") on the method, finer-grained than our requireRole.
src/middleware/errors.js @RestControllerAdvice with @ExceptionHandler methods Exactly the same concept: a single point that translates exceptions into HTTP. ApiError becomes an exception of your own; it is worth using ProblemDetail (RFC 9457) or keeping your format with a DTO.
src/repositories/coffees-sqlite.js Interface CoffeeRepository extends JpaRepository<Coffee, String> The repository pattern is not our idea: Spring Data implements it on its own from the interface. Query methods are derived from the name (findByRoastAndPriceCentsLessThan). If you prefer explicit SQL, @Query or JdbcTemplate.
src/schemas/coffees.js (Zod) Bean Validation annotations (@NotNull, @Size, @Min, @Pattern) on the DTO, switched on with @Valid Less expressive than Zod for transformations and unified with the contract; the messages are customised in messages.properties.
src/observability/metrics.js (prom-client) Micrometer + spring-boot-starter-actuator HTTP, JVM and connection-pool metrics practically for free at /actuator/prometheus; /actuator/health gives separate liveness and readiness, exactly what we wrote by hand in 04-07. The cardinality rules are identical: never label with ord_5001.

Final onboarding tip: spend the first day reading the project's openapi.yaml, not the code. The contract is the thing you already know how to read, and it will give you the complete map of the system before you face a single Spring annotation.

Conclusion

You have seen the same endpoint —GET /v1/coffees, with its filters, its mandatory pagination, its catalogue 400 and its cents-to-euros conversion— solved in Express, Fastify, NestJS, Hono, FastAPI, Django REST Framework, Spring Boot, ASP.NET Core and Go. And with it you have seen the three models that share the landscape: the minimalist one, where you write every guarantee yourself and that is why you understand them; the schema-centred one, where a single document serves as validation, serialisation and documentation —Fastify and FastAPI are its best exponents, and they solve at the root the contract drift we had to fight with tooling in 05-02—; and the opinionated one, NestJS, Spring and DRF, which in exchange for more ceremony give an identical structure, dependency injection and enormous productivity when the problem matches what the framework expects.

The selection criteria, in their real order: the language your team knows, hiring, maturity and support horizon, fit with the problem, the specific ecosystem you need and, last except in very specific cases, performance —because benchmarks measure {"hello":"world"} with no database and your p99 is dominated by the SQL query from 04-06. And you know that a migration is saved or lost on one single thing: whether your business logic imports the framework or not. In Aroma Store it does not, which is why src/services/, src/repositories/, the mappers, openapi.yaml, the Postman collection and —above all— the integration tests from 03-08 would survive a move to Fastify intact; only src/routes/, src/middleware/ and src/app.js would be rewritten.

The most important thing in this lesson is what does not change. Of the course's six modules, only the third depends on the framework, and not even all of it. That an order requires Idempotency-Key, that a matching ETag produces 304, that errors carry a stable code from the catalogue, that the SPA needs CORS and the admin panel a token with scopes, that metric labels must not include identifiers: none of that is Express. If you know how to design and operate APIs, learning a new framework takes two weeks; if all you know is Express, you know how to use Express.

We now return to the project and to a loose end that 05-02 left behind. We have a complete contract, validated as a document, but nothing yet guarantees that the server complies with it, nor that a change in the YAML will not break the SPA without warning. In 05-04, Contracts, mocks and automated API testing, we close that loop: we will bring up a mock with Prism from openapi.yaml so the SPA can move in parallel, we will use msw on the front end and nock for the outbound calls to SwiftShip, we will validate real responses against the schemas inside the Supertest tests we already have, we will detect breaking changes with oasdiff applying the rules from 02-07, we will see when contract testing with Pact pays off and when it is over-engineering, and we will organise the complete purchase journey as an end-to-end test, with the table of what runs on save, in the pull request, on deployment and in production.

REST API Course: Principles of Designing and Developing RESTful APIs

Module 1: Introduction to RESTful APIs

Module 2: Designing RESTful APIs

Module 3: Building RESTful APIs

Module 4: Best Practices and Security

Module 5: Tools and Frameworks

Module 6: Case Studies and Projects

© Copyright 2026. All rights reserved