Skip to content

Three Layers of Validation for One Query Parameter

Adityo Guni Waluyo

An enum filter guarded at the handler, the repository, and the SQL layer. Each layer protects a different class of caller.

TL;DR

Editor logic was embedded in a SQL query. The query was simplified to use a column directly. A three-layer validation system was added for security.

The clause that borrowed editor logic

The repository of a fictional city portal's public list API carried one WHERE branch that had long been an eyesore. The visual and text streams were not filtered from a column but reconstructed: visual meant the video mode, or a default mode that had an image; text meant noimage, or a default mode without one. That "has an image" test lived inside a SQL expression joining OR with a NOT EXISTS subquery into a media table. Editor logic lived in the query, while the display_mode column that already stored those decisions got re-interpreted on every call.

The gap was thin but decisive. As long as the has-image expression and editor habits agreed, every stream filled correctly. Once the meaning of "having an image" drifted, the visual stream could include entities that belonged in the table, or the reverse, without a single error. The query looked healthy, count matched the list, and the data was wrong. The fix in routing was simple: streams became pure clauses over the column, visual mapping to the pair of video and default modes, and text to noimage alone. The has-image expression disappeared entirely.

The first guess: HTTP-edge validation is enough

While the routing changed, a new query parameter was added for an exact per-mode filter. First guess: one switch in the handler suffices. A value outside the three official modes gets a 400 response with a VALIDATION code, done. For HTTP callers, that is indeed the only layer they ever see.

What makes that layer insufficient: the same module serves other paths. Non-HTTP callers, a job or an internal script, skip the handler entirely and talk to the service and repository directly. A wild value that slipped past the switch never meets the 400; it travels all the way into the query.

The next two layers: sentinel error and parameterized SQL

The second layer lives in the repository. A stream value outside the two official keywords is not guessed away or ignored; it comes back as a wrapped sentinel error:

default:
    return nil, 0, fmt.Errorf("%w: presentation harus visual atau text", ErrValidation)

The wrapping form matters. Plain formatting discards the original error and keeps only its text; the special verb carries the original error along and builds an error tree that can be inspected, exactly the mechanism introduced in Go 1.13's error wrapping announcement [6]. Callers inspect it with the standard library's errors.Is [5], the check the documentation recommends over plain equality. Service and repository become fail-closed: a non-HTTP call faces the same contract, one sentinel check at the top of its error handling.

The third layer guards the query structure itself. The filter value is never concatenated into SQL; the clause is parameterized and the enum literals come only from a whitelist switch. The OWASP SQL injection prevention cheat sheet [4] separates these two concerns: data values go through parameter binding, while query structure such as column names and keywords must come from a fixed application-owned mapping, never from input. One placement detail is easy to miss: the filter clause is written before the count calculation so count and list share the same WHERE string; filtering after the count is a recipe for a total that disagrees with its own rows.

The three layers do not replace one another. The handler gives clients the fastest answer; the sentinel guards internal callers; the parameterized clause makes sure the worst value is still treated as data. Checking order follows the same flow: a foreign value from the query string stops at the handler; a foreign value from an internal caller stops at the repository gate with a testable error. A combination valid at one layer but nonsensical at another still gets caught, because all three layers read the same enum dictionary: three editor-owned strings, defined once, used consistently everywhere.

Sources

  1. OWASP, SQL Injection Prevention Cheat Sheet, accessed 26 October 2026.
  2. Go Docs, the errors package, accessed 26 October 2026.
  3. The Go Blog, Working with Errors in Go 1.13, accessed 26 October 2026.

Related articles