Using Clojure.spec to Enforce Data Contracts at Trust Boundaries
Shows how to define specs, validate data at system edges, and use generative testing for confidence.
30 Jul 2026, 07:22 UTC

Requirements
When a Clojure service receives data from an external source—such as an HTTP request, a message queue, or a file—you need to guarantee that the shape and types of that data match what your business logic expects. The validation must happen early enough to prevent malformed data from propagating deeper into the system, yet it should not become a performance bottleneck in hot paths.
Smallest Suitable Design
Introduce a single, reusable spec for each distinct data shape that crosses a trust boundary. Use clojure.spec.alpha to define primitive predicates, combine them with s/and, s/or, and s/cat, and attach the spec to a namespaced keyword via s/def. At the boundary, call s/valid? (or s/conform) to decide whether to accept the payload.
Example Spec Definition
; Assuming project uses Clojure 1.11.2 and spec.alpha 0.2.176
(ns myapp.data.spec
(:require [clojure.spec.alpha :as s]))
; A user ID must be a positive integer
(s/def ::user-id (s/and int? pos?))
; An email address is a non‑empty string containing an @ sign
(s/def ::email
(s/and string?
seq?
#(re-find #"@" %)))
; A minimal user record
(s/def ::user
(s/keys :req-un [::user-id ::email]))
This spec lives in a dedicated namespace so it can be required wherever validation is needed.
Trust/Data Boundaries
Place the validation call at the first point where external data enters the system. For a Ring handler, that is inside the middleware or the handler function before any business logic runs.
Validation in a Ring Handler
(defn wrap-spec-validation [handler spec-key]
(fn [request]
(let [payload (:body request) ; assume body already parsed to Clojure data
ok? (s/valid? spec-key payload)]
(if ok?
(handler (assoc request :validated-body (s/conform spec-key payload)))
{:status 400
:body (s/explain-data spec-key payload)}))))
; Usage
(def app
(wrap-spec-validation my-handler ::user))
The middleware returns a 400 response with an explain map when the data fails the spec, preventing the handler from seeing invalid data.
Operational Checks
To make failures observable, log the explain data or forward it to a monitoring system. Because s/explain-data returns a plain map, it can be serialized with pr-str or json/write-str without extra dependencies.
Logging Failure Example
(defn log-spec-failure [spec-key data]
(let [problem (s/explain-data spec-key data)]
(when problem
(timbre/error "Spec validation failed" :spec spec-key :data data :problem problem))))
Integrate log-spec-failure into the validation middleware to capture every rejection.
Failure Modes
- Spec violation:
s/valid?returns false;s/explain-datayields a map describing which predicate failed and the offending value. - Performance overhead: Each validation walks the spec tree; in tight loops this can add measurable latency.
- Mutable data: Specs do not protect against later mutation of Java objects; a mutable map that passes validation can still be altered elsewhere.
Conditions That Would Change the Design
- High‑throughput path: If profiling shows validation cost exceeds a threshold (e.g., >1 ms per request), gate spec checks behind a feature flag or run them only in development/staging environments.
- Need for immutability guarantees: Replace direct validation with a defensive copy (
clojure.core/clonefor maps orvecfor vectors) before passing data to downstream code. - Complex nested contracts: When specs become deeply nested and hard to read, consider extracting sub‑specs into separate namespaces or using
s/mergeto combine reusable fragments.
Verification Steps
To confirm that a spec behaves as expected, follow these steps in a REPL:
- Start a project with
[org.clojure/clojure "1.11.2"]and[org.clojure/spec.alpha "0.2.176"]indeps.ednorproject.clj. - Require the spec namespace and define a simple spec, e.g.,
(s/def ::pos-int (s/and int? pos?)). - Check validity:
(s/valid? ::pos-int 5)→ true;(s/valid? ::pos-int -3)→ false. - Obtain an explanation:
(s/explain ::pos-int -3)prints a human‑readable reason. - Generate sample values:
(require '[clojure.test.check.generators :as gen])then(gen/sample (s/gen ::pos-int) 5)should produce five positive integers.
These steps demonstrate both runtime validation and the generative testing capability without asserting any particular output.
Limitations
Specs are executed at runtime, so they cannot catch errors at compile time. In performance‑critical loops, consider moving validation to a startup‑time schema check or using a lighter predicate. Additionally, because specs do not enforce immutability, treat data that crosses a trust boundary as potentially mutable; copy or use immutable data structures when downstream code must not mutate the input.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.