Chapter 022-3 hours

Routing, the where of every request.

HTTP methods describe the what of a request, your intent. Routing describes the where : which resource on the server your intent is aimed at. This manual covers routing end to end, static and dynamic routes, path and query parameters, nesting, versioning, and catch-alls, with worked routers in Go, Python, JavaScript, TypeScript and Java.

01

What Routing Is

Every request carries two things bundled together. The HTTP method answers “what do I want to do?”, fetch, add, update, or delete data. That is your intent, your action. The route answers “where do I want to do it?”, which resource, at which address on the server, you want to perform that action on.

You have to tell the server where to go. Send a GET request (intent: fetch) to the route /users (the address), and the server replies with an array of users. Routing is the machinery that takes the what and the where, maps them to a particular handler, and lets that handler run the business logic and return the data.

Routing, definition

Routing is mapping URL paths (plus the HTTP method) to server-side logic: a handler that performs the business logic, database operations, and whatever else is needed, then returns a response. That is all there is to it.

The words used in this manual

  • Intent / action: what the request wants to do, expressed by the HTTP method (fetch, add, update, delete).
  • Resource: the “thing” the request acts on, usually a noun in the path such as books or users. (Resources are studied properly under REST APIs; here we treat them simply as the route’s target.)
  • Route / route path / URL path: the address part that says where on the server you are going, e.g. /api/books.
  • Handler: the set of instructions the server runs when a request matches a route; it owns the logic for that endpoint.

02

Method + Path -> Handler

This is the heart of routing. The server takes the method and the route and treats the two together as a unique key that maps to one handler. It checks the method first, then the route, concatenates them, and forms a unique routing path.

Consider two requests to the same address. GET /api/books fetches the books; POST /api/books creates one. The route is identical, but the method differs, so the keys are different and they route to different handlers. The methods differentiate between two otherwise identical routes, and the keys never clash.

GET/api/booksintent: fetch the booksPOST/api/booksintent: add a bookDELETE/api/books/9intent: remove book 9ROUTERmethod + path= unique keyGET /booksPOST /booksDEL /books/:idlistBooks()-> returns all bookscreateBook()-> creates, returns booksdeleteBook(id)-> removes the book

One key, one handler. The server checks method, then route, concatenates them into a unique key, and dispatches to the matching handler. Same path + different method = different destination.

03

Static Routes

A static route is a fixed path with no variable parameters inside it. /api/books is static: the string stays consistent, it is a constant. You always use that same string when you make the request, nothing in it changes, and it always returns the same kind of response. That is exactly why it is called “static.”

Static route

A constant path string with no dynamic segments, e.g. /api/books. The first two examples from the source, GET and POST on /api/books, are both static routes.

Matching a static route is a literal string comparison. Most APIs begin here: one endpoint that lists a collection and one that creates an item, both on the same constant path.

04

Dynamic Routes & Path Parameters

A dynamic route has a variable parameter inside the path, so one pattern can serve many specific resources. GET /api/users/123 fetches the details of one user, the 123 is the user’s ID, which the server extracts from the route to do its work (fetch that user from the database and return them).

On the server side, the route definition for this looks like a method plus a pattern. Written out, the matching part is something like:

r.get("/api/users/:id", handler)
        //          +--- ":id" = a dynamic parameter slot

The method part matches GET; the path part matches /api/users/ followed by any string. The colon convention, :id, says “this segment is dynamic; the client supplies a value here.” You will find this :name convention across Node, Python, Go, Rust, and Java; it is an industry-wide practice.

/api/users/:idpattern (the route definition)/api/users/123/api/users/abc/api/users/9f2each binds ->id = “123” | “abc” | “9f2”

One pattern, many matches. The dynamic segment :id matches any string in that position and binds it to a named value the handler reads.

Path parameter (route parameter)

A dynamic value that lives inside the path, right after a forward slash. Because it is part of the route, it is called a path parameter or route parameter, and it carries semantic meaning: it identifies which resource the request is about.

  • Everything in a path arrives as a string. 123 looks like a number, but in route paths numbers and special characters are all treated as strings; the handler casts it if needed.
  • Read aloud, GET /api/users/123 says “fetch the data of the user whose ID is 123.” That human-readable construct is the whole idea behind REST-style routing, it gives routes a clear semantic meaning.

05

Query Parameters

A query parameter is a key/value pair attached to the end of the route after a question mark. Multiple pairs are joined by an ampersand. For example, /api/search?query=some+value, the route is /api/search, and after the ? comes the key query with the value some+value.

/api/search?query=some+value&page=2&limit=20the route? begins the querykeysvalues& joins pairs

Structure. First pair after ?, the rest after &, each a key=value. A space in a value is written as +.

Why query parameters exist

POST and PUT requests have a body: a container for sending user-defined data to the server. But GET requests, by REST convention, have no body. So how does a GET send extra values? Query parameters are the answer: they let a GET ship a set of key/value pairs carrying metadata about the request: not the identity of the resource, but instructions for shaping the response.

Query parameter

An optional key=value pair in the query string, used to filter, sort, paginate, or search. Typically used with GET requests because they have no body. Optional, repeatable, and order-independent.

The main example: pagination

Imagine an endpoint that returns a list of books in paged format. The first call, /api/books, takes the server defaults and returns a chunk of data plus some metadata describing the whole set:

{
  "data": [ /* ...the first 20 books (the default page)... */ ],
  "total":        100,   // how many books exist in total
  "currentPage":  1,     // which page this is (default = 1)
  "limit":        20,    // page size (default limit)
  "totalPages":   5      // 100 / 20 = 5 pages
}

The client uses this response metadata to make the next request. The default needed no parameter; to get page two the client sends /api/books?page=2. The same mechanism carries filters (e.g. filter by a user-defined value) and sort order (ascending or descending), all as key/value pairs in the query.

06

Path vs Query Parameters

Both put data in the URL, and it is easy to confuse them, so it is worth being precise. They serve different purposes.

/api/users/123?sort=name&page=2PATH PARAMETERIdentifies WHICH resource.Part of the route. Semantic.e.g. user whose ID is 123.QUERY PARAMETERShapes HOW it’s returned.After the route. Metadata.filter / sort / paginate / search.

Rule of thumb. If it decides which thing you get -> path parameter. If it only changes the shape or subset of the response -> query parameter.

Path parameters serve a particular purpose: a semantic expression of which resource, “the user whose ID is 123.” You could technically push a search value into the path (/api/search/some+value), but it is hard to maintain and defeats the whole purpose of giving routes a clear semantic meaning. Metadata like a search term belongs in the query string instead: /api/search?query=some+value.

07

Nested Routes

Nesting is not really a separate type of route, it is a practice you will see everywhere. In REST-style APIs, you often nest different resources to express a semantic relationship. Each deeper level narrows the meaning and maps to its own handler, returning a different granularity of data.

/api/users-> list all users/api/users/123-> one user/api/users/123/posts-> that user’s posts/api/users/123/posts/456-> one post

Each level is its own endpoint. Stop at any rung and you get a different response: all users, one user, that user’s posts, or one specific post.

Walking the deepest example, /api/users/123/posts/456:

  • /api/users, a static part; on its own it returns the list of all users.
  • 123, a path parameter for the user ID; /api/users/123 returns that one user.
  • posts, a static part naming a sub-collection; /api/users/123/posts returns all posts of that user.
  • 456, a path parameter for a specific post; the full route fetches “the post with ID 456 belonging to the user with ID 123.”

That is why it is called a nested route, you nest different types of information at different levels to express different semantic meanings, and each produces a different response. You will see this used pretty much everywhere once an API has even medium complexity.

08

The Routing Lifecycle

Putting the pieces together, here is the path a single request travels, the routing step in context.

1 / ARRIVEGET /api/users/1232 / READsplit route / query3 / MATCHmethod+route -> route4 / EXTRACTid = 1235 / HANDLERbusiness logic / DB6 / RESPONDreturn the data

Six beats. Arrive -> read the route -> match (the routing step) -> extract parameters -> run the handler -> return the response.

09

Versioning & Deprecation

Route versioning is a very common practice for managing change in API endpoints. You put a version keyword in the route, /api/v1/products and /api/v2/products. It looks like an ordinary route with a v1 or v2 segment added.

Why bother? Suppose v1 returns each product as { id, name, price }. Later, new requirements arrive, say you now serve a mobile app and must change the response shape, renaming name to title. If you mutate the live route, you break every existing client. Versioning lets both shapes live at once:

/api/v1/products/api/v2/products
{ id, name, price }{ id, title, price }

This does two things. It expresses intent clearly, v1 of the response and v2 of the response side by side. And it means you did not have to change the whole route (you didn’t need a /api/new-products); you just bumped the version.

time ->/api/v1/products, live/api/v2/products, liveMIGRATION WINDOWv2 shipsv1 deprecated -> removed

The safe workflow. v2 ships beside v1; clients get a window to migrate; then v1 is deprecated and removed. Eventually v2 may be promoted to the default.

Deprecation

Announcing that an endpoint (e.g. v1) is going away once v2 is released. Engineers get a defined migration window to move their requests over to v2; only after that does the team remove v1. Versioning plus deprecation gives a stable workflow for shipping breaking changes without breaking clients in the moment.

10

Catch-All Routes

A catch-all route handles requests for routes the server does not actually serve. Suppose a request comes in for /api/v3/products but there is no handler for it. The server places a catch-all last, after all the specific routes and methods, typically written with a wildcard, /*. Any request that falls through every earlier match lands there.

incoming: GET /api/v3/products (no handler exists)match GET /api/v1/products ?Nomatch GET /api/v2/products ?Nomatch GET /api/users/:id ?Nomatch /* (catch-all)Yesfriendly 404”this route does not exist”

Fall-through. Because it sits last, the catch-all only fires when nothing else matched, turning a confusing default into a clear “not found”.

Without catch-all handling, the default behaviour is often a null response, which is confusing. With it, the handler returns a user-friendly message, “this route does not exist” / “route not found”, so the client clearly understands the server does not cater to that endpoint.

Wildcard & ordering

The * wildcard matches anything, which is exactly why the catch-all must be registered last. Placed early, it would intercept every request before the real routes get a turn. The reliable rule: specific routes first, the catch-all at the very end.

11

Building a Router

A router is, at bottom, a small object-oriented dispatch problem: a table from “method + route” to a handler. Below is the same router in five languages, pick a tab. Each one expresses the four OOP pillars in its own way, and the comments tagged OOP point out each one:

  • Go: interfaces (abstraction + polymorphism), unexported fields (encapsulation), and struct embedding (composition, Go’s form of inheritance).
  • Python: classic class syntax, an abstract base class for the contract (abstraction), real subclassing (inheritance), overridden methods (polymorphism), and a name-mangled private attribute (encapsulation).
  • JavaScript: no interface keyword to lean on, so the contract is a base class whose handle() throws until a subclass overrides it (abstraction + polymorphism), extends gives real inheritance, and a # field is private at runtime rather than by convention (encapsulation).
  • TypeScript: the JavaScript design with the compiler enforcing it, a real interface for the contract, an abstract base class so a missing handle() fails to compile, and a Method union type that rejects a misspelled verb before the code ever runs.
  • Java: a real interface for the contract (abstraction), an abstract base class that handlers extend (inheritance), @Override methods (polymorphism), and private fields enforced by both the compiler and the JVM (encapsulation).
package main

import ("fmt"; "strings")

// ABSTRACTION: Handler is an interface, it names a capability
// ("be able to Handle a request") without saying how. Any type
// with a Handle method IS-A Handler. The router depends only on
// this contract, never on a concrete type.
type Handler interface {
    Handle(req Request) Response
}

type Request struct {
    Method string
    Path   string
    Params map[string]string // extracted path params (:id)
    Query  map[string]string // query params (?page=2)
}
type Response struct { Status int; Body string }

// ENCAPSULATION: "routes" is lowercase => unexported => private to
// the package. Outsiders cannot touch the table directly; they go
// through the methods we expose (Register / Dispatch).
type Router struct {
    routes map[string]Handler // key = "METHOD /route"
}

func NewRouter() *Router {
    return &Router{routes: make(map[string]Handler)}
}

// Register binds method + route -> handler (the dispatch table).
func (r *Router) Register(method, pattern string, h Handler) {
    r.routes[method+" "+pattern] = h // method + path = the unique key
}

// POLYMORPHISM: Dispatch calls h.Handle(req) without knowing which
// concrete type h is. Same call, different behaviour per handler.
func (r *Router) Dispatch(req Request) Response {
    for key, h := range r.routes {
        method, pattern, _ := strings.Cut(key, " ")
        if method == req.Method && match(pattern, req.Path, &req) {
            return h.Handle(req) // polymorphic dispatch
        }
    }
    return Response{404, "route not found"} // catch-all fallback
}

// match compares "/users/:id" against "/users/123", filling Params.
func match(pattern, path string, req *Request) bool {
    p, q := strings.Split(pattern, "/"), strings.Split(path, "/")
    if len(p) != len(q) { return false }
    for i := range p {
        if strings.HasPrefix(p[i], ":") { // dynamic segment
            req.Params[p[i][1:]] = q[i] // bind :id => "123"
        } else if p[i] != q[i] { // static segment must match exactly
            return false
        }
    }
    return true
}

// "INHERITANCE" via COMPOSITION: BaseHandler holds shared logic;
// concrete handlers EMBED it and reuse its methods.
type BaseHandler struct{ Name string }

func (b BaseHandler) log(req Request) {
    fmt.Printf("[%s] %s %s\n", b.Name, req.Method, req.Path)
}

// GetUser embeds BaseHandler (composition) and satisfies Handler.
type GetUser struct{ BaseHandler }
func (h GetUser) Handle(req Request) Response {
    h.log(req) // reused (inherited) behaviour
    return Response{200, "user id = " + req.Params["id"]}
}

type ListBooks struct{ BaseHandler }
func (h ListBooks) Handle(req Request) Response {
    h.log(req)
    page := req.Query["page"] // query param drives pagination
    if page == "" { page = "1" }
    return Response{200, "books page " + page}
}

func main() {
    r := NewRouter()
    r.Register("GET", "/api/users/:id", GetUser{BaseHandler{"users"}})
    r.Register("GET", "/api/books",     ListBooks{BaseHandler{"books"}})

    req := Request{Method: "GET", Path: "/api/users/123",
        Params: map[string]string{}, Query: map[string]string{}}
    fmt.Println(r.Dispatch(req).Body) // => user id = 123
}

12

Routing Glossary

Every routing term used above, in one place.

TermMeaning
RoutingMapping a URL path (+ HTTP method) to a server-side handler / logic.
Intent / actionWhat the request wants to do, fetch, add, update, delete (the HTTP method).
Route / route pathThe address part saying where on the server, e.g. /api/books.
HandlerThe instructions that run when a route matches; runs the business logic.
ResourceThe “thing” a route targets, a noun like users or books.
Unique keyMethod + route concatenated; what the server looks a handler up by.
Static routeFixed, constant path with no variable parameters.
Dynamic routeRoute with a variable parameter in the path.
Path parameterA dynamic value inside the path (:id); identifies which resource. Also “route parameter”.
Query parameterA key=value pair after ?; metadata for filter/sort/paginate/search.
PaginationReturning a list in pages, driven by query params like page and limit.
Nested routeHierarchy of segments expressing ownership, /users/123/posts/456.
Semantic meaningA route written so it reads like a clear, human-readable statement (the goal of REST routing).
VersioningPutting /v1/, /v2/ in the route to evolve an API safely.
DeprecationMarking an old version as going away; clients get a migration window.
Catch-all routeLast-resort /* route returning a friendly “route not found”.
WildcardA token (*) that matches anything; lives in the catch-all.

Backend from First Principles / Chapter 02 / Routing. Code targets Go 1.22+, Python 3.11+, Node.js 20+, TypeScript 5+ and Java 17+.