Adopting core.spec for Boundary Validation in a Clojure Microservice
An architecture note on using Clojure's core.spec to validate data at service boundaries, instrument functions in CI, and generate tests — with the smallest design that works and the failure modes to watch.
28 Nov 2025, 03:43 UTC

A Clojure service that accepts JSON from other teams' services has a recurring problem: malformed payloads fail deep inside business logic, producing stack traces that say nothing about which field was wrong. The useful takeaway from core.spec is that you can declare the shape of that data once, enforce it at the trust boundary, and reuse the same declarations for generative tests — without adopting a static type system or a new dependency. Spec has shipped with Clojure since 1.9 (stabilized as clojure.spec.alpha in 1.10), so the examples below assume Clojure 1.10+.
Requirements
The service in question is a typical Ring/compojure-style HTTP API. Its requirements:
- Reject malformed requests at the edge with a 400 and a readable explanation of what was wrong.
- Catch contract violations between internal functions during development and CI, not in production hot paths.
- Generate test inputs that cover edge cases (empty strings, boundary numbers, missing optional keys) that hand-written tests miss.
- Keep contracts versionable and reviewable as plain code.
The smallest suitable design
Spec is data. A spec for an incoming order payload looks like this:
(ns orders.spec
(:require [clojure.spec.alpha :as s]))
(s/def ::id uuid?)
(s/def ::quantity (s/and int? pos?))
(s/def ::sku (s/and string? #(re-matches #"[A-Z]{3}-\d{4}" %)))
(s/def ::email (s/and string? #(re-find #"@" %)))
(s/def ::order-request
(s/keys :req-un [::sku ::quantity]
:opt-un [::email]))Three design decisions keep this minimal:
- Validate only at boundaries. The HTTP handler calls
s/valid?on the parsed body before touching business logic. Internal functions trust each other in production. - Instrument in dev and CI only.
clojure.spec.test.alpha/instrumentwraps functions that haves/fdefdefinitions and checks arguments on every call. This is valuable in tests but too expensive on a hot request path. - Conform once, pass conformed data inward.
s/conformboth validates and normalizes (e.g., parsing numeric strings), so downstream code receives clean data.
Trust and data boundaries
Draw the boundary explicitly: everything outside the process (HTTP bodies, queue messages, files) is untrusted and gets s/conform; everything inside is trusted and flows as plain Clojure maps. A typical handler:
(defn create-order-handler [request]
(let [parsed (:body-params request)
conformed (s/conform :orders.spec/order-request parsed)]
(if (= conformed ::s/invalid)
{:status 400
:body {:error "invalid request"
:problems (s/explain-str :orders.spec/order-request parsed)}}
(do (orders.core/create! conformed)
{:status 201}))))Note that s/explain-str output is verbose. For production logs, log a trimmed version (or just the failing keys via ::s/problems from s/explain-data) and keep full explanations for dev. This is a deliberate trade: actionable client errors without flooding log storage.
Function specs and instrumentation
For internal functions whose misuse is a common bug source, attach an s/fdef:
(s/fdef orders.core/create!
:args (s/cat :order :orders.spec/order-request)
:ret uuid?)Then, in your test namespace or a dev-only entry point:
(require '[clojure.spec.test.alpha :as stest])
(stest/instrument `orders.core/create!)Run this in a REPL or in CI's test bootstrap — not in the production main. Any call with wrong-shaped arguments now throws an ex-info with a spec explanation. To verify instrumentation is active, deliberately call the function with a bad argument in a REPL and confirm you get a spec error rather than a downstream exception.
Generative testing
Because specs describe the data, spec can generate it. stest/check exercises a function's :ret and :fn specs against generated inputs:
(stest/check `orders.core/create!)Generators come from predicates automatically, but constrained predicates (like the SKU regex) often need a custom generator via s/with-gen:
(s/def ::sku
(s/with-gen (s/and string? #(re-matches #"[A-Z]{3}-\d{4}" %))
(fn [] (gen/fmap (fn [[cs n]] (str (apply str cs) "-" (format "%04d" n)))
(gen/tuple (gen/vector (gen/elements (map char (range 65 91))) 3)
(gen/choose 0 9999))))))Run check in CI as a normal clojure.test test that asserts the :failure key of the result summary is nil. A practical check: introduce a deliberate off-by-one bug in a branch, run the generative test, and confirm it fails — that proves the generator reaches the interesting inputs.
Operational checks
- CI gate: run unit tests with instrumentation enabled; any
:argsviolation fails the build. - Staging soak: enable instrumentation on a canary instance and watch for spec errors and latency changes.
- Performance check: profile a representative request with and without instrumentation. Expect measurable overhead; the design assumes it stays out of production. If your throughput is low, you may decide the safety is worth keeping it on — measure first.
- Error quality review: read actual 400 responses in staging and confirm a client developer could fix their payload from the message alone.
Failure modes
- Stale specs. When business logic changes but specs don't, you get false rejections at the boundary or false confidence in tests. Mitigate by co-locating specs with the namespaces they describe and reviewing them in the same pull request.
- Verbose production logs. Full
explainoutput on every bad request is noise. Trim as described above. - Generator explosions. Recursive or heavily constrained specs can make generation slow or hang. Keep specs simple; write custom generators for constrained leaves.
- False sense of type safety. Spec validates shape at runtime. It will not catch a wrong calculation on well-shaped data; you still need meaningful assertions in tests.
Conditions that would change the design
Reconsider this minimal design if: request throughput makes even boundary validation measurably expensive (move to conforming only fields you use); contracts must be shared with non-Clojure consumers (publish JSON Schema or Protobuf instead, and generate specs from it); or the team needs compile-time guarantees (spec will never provide that — look at static analysis or a typed language for those components). For a service whose main risk is bad data crossing process boundaries, though, spec at the edge plus instrumentation in CI is the smallest design that actually addresses it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.