Skip to content

One Query Param, Three Layers: A Safe ?module= Filter

Adityo Guni Waluyo

Adding a ?module= filter is not just a WHERE clause: 400 validation at the handler, a parameterized subquery, and a cache key per filter combination.

TL;DR

A naive SQL-only module filter would poison the shared mappoints:all cache, leaking filtered results to unfiltered requests and vice versa. The fix layers validation against six allowed modules with a 400 error, a parameterized subquery, and branched cache keys by module and category. This isolates each response variant and prevents cross-contamination.

I was reading the cache logs on the map points endpoint. At that moment the key was always the same one: mappoints:all. Now imagine I naively bolted on a ?module=PARIWISATA filter at the SQL query level only, without touching the cache logic at all. The first request asking for tourism data would store its result under that mappoints:all key, and the next request with no filter would be handed tourism data. Or the other way around: unfiltered data leaking into a request that asked for something specific.

When I first sized this up, my head said: just add one WHERE condition, done. But access patterns are not linear; some people open the page with no filter, some jump straight to a specific category. With a static cache key, the data that comes out gets shuffled between them. HTTP is also stateless: every request is validated on its own, without leaning on the one before it [5]. And if the database is your only line of defense, CPU and I/O already got burned on a query that should have been rejected up front.

The cache trap you never see

The core problem is a data collision. A filtered request storing its result under that neutral key is like poisoning a cache that was meant for everyone. The fix cannot live in one place: a single query param needs three layers of defense so there is no gap. Rejected early, filtered in SQL, and separated in the cache too.

Layers one and two: the 400 validation and the parameterized subquery

Layer one sits in the handler. Before touching any database, the module value is checked against a fixed enum: PARIWISATA, EKONOMI_KREATIF, OLAHRAGA, BUDAYA, INFORMASI, KEPEMUDAAN; 6 values total are allowed [1]. Wrong value? An immediate 400 INVALID_MODULE, complete with the list of valid values in the message [1]. That matches the HTTP contract: 400 means the server refuses to process the request because the fault sits on the client side, and replaying it unchanged will fail again [1].

A small detail that matters: q.Get returns an empty string when the param is absent [4]. That is why the condition reads module != "" && !ValidModule(module); only explicitly wrong values get rejected, while empty means no filter and that is legitimate. The validation is one shared predicate, scope.ValidModule, used across 3 endpoints: listEntities, getMapPoints, listCategories. No validation variant left behind at any door.

Layer two lives in the repository: the parameterized subquery AND category_id IN (SELECT id FROM categories WHERE module = ?) grafted uniformly onto the list, map points, and map points viewport queries. Parameterized, not string-spliced, so the value travels from query param to database as data, never as code.

Layer three: the cache key carries the filter

The third layer is what makes the poisoning scenario above impossible: the cache key branches to follow the filter combination. You get mappoints:module:m:cat:id, mappoints:module:m, mappoints:cat:id, or mappoints:all, all with a 5 minute TTL. A filtered request will never read, let alone poison, the unfiltered cache [2]. The concept is one cousin of the Vary header in HTTP caching: responses are separated per request part that affects the content [2]. The difference is that here the separation is explicit on the server, not left to CDN behavior that differs from one provider to the next.

The rest is documentation: the enum is written into openapi.yaml on all three endpoints plus a note that unknown values get a 400 [3]. Clients hold a written contract instead of guessing [3]. The final shape in the handler looks like this:

module := q.Get("module")
if module != "" && !scope.ValidModule(module) {
    response.Error(w, http.StatusBadRequest, "INVALID_MODULE",
        "unknown module; allowed: PARIWISATA, EKONOMI_KREATIF, OLAHRAGA, BUDAYA, INFORMASI, KEPEMUDAAN")
    return
}

cacheKey := "mappoints:all"
switch {
case module != "" && categoryID > 0:
    cacheKey = fmt.Sprintf("mappoints:module:%s:cat:%d", module, categoryID)
case module != "":
    cacheKey = "mappoints:module:" + module
case categoryID > 0:
    cacheKey = fmt.Sprintf("mappoints:cat:%d", categoryID)
}

One detail that is easy to miss: the categories endpoint already had ?module= before this. The commit added the 400 validation to that door and rolled the filter out to list and map points at the same time. The handler test (107 lines) covers the 400 and the filter combinations; the module-specific repository test (90 lines) pins the query shape. With fail fast in the first layer, the two layers below focus on one job: filtering, not guessing.

Sources

Related articles