Validating Untrusted Data with Clojure spec at Your System's Trust Boundaries
An architecture note on using clojure.spec.alpha to validate untrusted HTTP and queue payloads at the perimeter: minimal design, operational checks, failure modes, and when to choose Malli instead.
07 Mar 2026, 23:05 UTC

Every Clojure service eventually faces the same problem: JSON bodies from HTTP clients and payloads from message queues arrive as plain maps, and nothing guarantees those maps contain what your domain code assumes. The useful takeaway is that clojure.spec.alpha lets you declare each payload's shape once and enforce it exactly where untrusted data enters — while keeping everything inside the perimeter free of validation noise.
This note covers the requirements, the smallest design that works, where to draw the trust boundary, how to operate it in production, and the conditions that should push you toward a different tool. It assumes Clojure 1.10+ with org.clojure/spec.alpha on the classpath.
Requirements
The design needs to satisfy four things:
- Reject malformed or semantically invalid external data before it reaches domain logic.
- Produce rejection feedback that is structured enough to log, metric, and (after translation) return to clients.
- Keep internal functions simple — they should receive already-validated, conformed maps.
- Avoid coupling every module to every other module's schema.
The smallest suitable design
Define one spec per inbound payload type, validate at the entry point, and pass only conformed data inward. Three spec forms do most of the work: s/def registers a named spec, s/keys describes a map's required and optional keys, and plain predicates describe individual values.
(ns myapp.boundary.order
(:require [clojure.spec.alpha :as s]))
(s/def ::order-id string?)
(s/def ::quantity (s/and int? pos?))
(s/def ::email #(re-matches #".+@.+\..+" %))
(s/def ::create-order-request
(s/keys :req-un [::order-id ::quantity ::email]))At a Ring handler or queue consumer entry point, conform and branch on the result:
(defn handle-create-order [request]
(let [payload (:body request)
conformed (s/conform ::create-order-request payload)]
(if (= conformed ::s/invalid)
{:status 400
:body {:error "invalid request"
:problems (translate-problems
(s/explain-data ::create-order-request payload))}}
(do (domain/create-order! conformed)
{:status 201 :body {:ok true}}))))Two details matter here. First, s/conform returns the sentinel ::s/invalid on failure — if you forget to check for it, that keyword silently flows downstream as if it were data. Second, s/explain-data returns a machine-oriented structure keyed by internal spec names; pass it through a translate-problems function before exposing it, because raw explain output leaks your internal naming and confuses API consumers.
Where the trust boundary sits
Treat spec as a perimeter concern, not a pervasive assertion. Validate at exactly two places: HTTP ingress (middleware or the outermost handler) and message consumer entry points. Everything inside operates on plain, already-conformed maps.
The alternative — spec'ing every internal function with s/fdef and instrumenting in production — adds runtime cost on every call and couples modules to each other's schemas. A change to one namespace's spec then breaks callers that were never exposed to external data. Reserve s/fdef and clojure.spec.test.alpha/instrument for development and test, where they catch integration mistakes for free.
Operational checks
Validation that no one observes is validation you cannot tune. Three practices make it operable:
- Metric per spec name. Increment a counter like
validation.rejected{spec="create-order-request"}on every failure. A sudden spike after a deploy usually means your spec drifted from what a real producer sends. - Sanitized logging. Log the failing path and predicate from
explain-data, not the full payload — request bodies routinely contain credentials and PII you do not want in log aggregation. - Generative testing in CI.
clojure.spec.test.alpha/checkexercises spec'd functions against generated inputs and catches edge cases hand-written tests miss. Bind it explicitly:
(require '[clojure.spec.test.alpha :as stest])
(stest/check `domain/create-order!
{:clojure.spec.test.check/opts {:num-tests 100}})Run this in a test context, not the deploy path — generation time is non-deterministic, and custom predicates need hand-written generators via s/with-gen or check will fail to produce values at all.
Failure modes
- Spec drift. Producers evolve; specs don't. The rejection metric above is your early warning.
- Overly permissive specs. An
s/keysform with only:opt-unkeys accepts any map, including an empty one. Require at least the keys your domain code actually reads. - Sentinel leaks. Unchecked
::s/invalidvalues propagating into domain code, as noted above. - Hostile explain output. Deeply nested specs produce deeply nested explain data. Without a translation layer, clients get noise instead of actionable errors.
Conditions that would change this design
Spec remains officially alpha. Its APIs are stable in practice, but pin the version in your dependency file and review the changelog before upgrading. If your requirements grow to include runtime JSON Schema generation for API documentation, human-friendly error messages without a translation layer, or high-performance coercion of string-typed JSON values into numbers and dates, evaluate Malli — it was designed around those needs. The long-stalled status of spec2 is also worth weighing before making spec load-bearing in a new system.
Verifying the boundary works
In a REPL or staging environment, send a known-bad request (for example, quantity as a string) and confirm you get a 400 with translated problem details, the rejection metric increments, and the sanitized log entry appears. Then send a valid request and confirm the domain function receives the conformed map. Finally, check your dependency tree (clj -Stree or lein deps :tree) to record exactly which spec version you are betting on.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.