~ / track D / clojure in practice
Modeling the domain with values
IntermediatepracticeMost Clojure programs ultimately do one thing: take some data in, transform it, and hand new data out. The first design decision in any non-trivial program is therefore what shape your domain entities take as data. This page walks the choices: plain maps, namespaced keys, records, and when each earns its keep. This is the next step after Data-oriented programming; pair it with Make illegal states unrepresentable when you want the type system to reject bad combinations early.
The default: a plain map
A Clojure map is the simplest entity in the language and almost always the
right starting point. There is no class to define, no migration when a field
is added, and every seq/threading function in clojure.core already knows
how to read and write one.
A few habits keep maps healthy as the program grows:
- Name keys with intent.
:unit-pricesays more than:price; the qualifier prevents ambiguity when the same map later gets a:total-price. - Compute, don't store. Anything you can derive from other fields (line totals, grand total, item count) belongs in a function, not a key. Stored derivations rot the moment the source field changes.
- Keep the shape narrow. Resist the urge to add "convenience" keys that duplicate other data. The map should be the smallest description of the thing.
Namespaced keys
Once two subsystems share a value (an order moves from the cart to the
billing service, say), bare keywords like :id and :status start
colliding. Namespaced keys solve this without any framework:
The namespace tells you whose field it is. :order/id and :user/id can
coexist in the same map without ambiguity, and you can read a function that
takes {:keys [:order/id :user/id]} and immediately know what it touches.
The reader shorthand ::id expands the current namespace, which is convenient
inside a single file but less so when values cross namespace boundaries —
prefer the explicit :order/id form for entities that travel.
Records — when shape is fixed and hot
defrecordclojure.core/defrecordDefine a record: a typed, map-like class implementing protocols.view on clojuredocs → creates a Java class with named fields. Records still behave like
maps for the operations you'd expect (get, assocclojure.core/assocReturn a new map with k -> v associated.view on clojuredocs →, destructuring), but
field access is faster, the constructor enforces arity, and they can
participate in protocols.
Two things to watch:
- assocclojure.core/assocReturn a new map with k -> v associated.view on clojuredocs → of an unknown key downgrades the record to a map.
(assoc price :rate 0.9)returns a plain map, not aMoney— the record class only "owns" the fields it was defined with. - Records don't share identity with maps, even when their fields match.
(= (->Money 30.0 "USD") {:amount 30.0 :currency "USD"})isfalse.
Use a record when (a) the shape is fixed and well-known, (b) you'll create millions of them and the construction cost matters, or (c) you want to attach protocols. Otherwise stick with the map.
Validating shape at the edges
Domain entities arrive from JSON bodies, database rows, CSV files — all places where the shape can be wrong. The pattern is validate once at the boundary, then trust the value through the rest of the program. A schema library (Malli, Spec) is the usual tool; even without one, a small predicate function pays its way:
Inside the program, code can assume the shape holds; outside, validation runs at every entry point. This is the same pattern as Functional Core, Imperative Shell — push uncertainty to the rim of the system.
Choosing between map and record
| Situation | Reach for |
|---|---|
| First sketch of an entity | plain map |
| Shape stable, performance matters | record |
| Need to implement a protocol on the value | record |
| Value crosses many subsystems / serializes to edn | map with namespaced keys |
| Value is "open" — different producers add their own fields | map |
In a Clojure service
When an order crosses service boundaries — cart → billing → fulfillment —
bare :id and :status keys collide with other subsystems. Namespaced
keys like :order/id and :item/sku travel through JSON, EDN, and Kafka
messages without ambiguity. The handler builds the entity with explicit
namespaces once, at the boundary, and every downstream function
destructures {:order/keys [id items]} knowing exactly whose field it
reads. Validation runs on the namespaced shape before the value enters the
pure core.
(defn create-order-handler [{:keys [db]} {:keys [body]}] ;; sketch
(let [order {:order/id (str (random-uuid))
:order/status :pending
:order/customer (:customer body)
:order/items (mapv (fn [{:keys [sku qty]}]
{:item/sku sku :item/qty qty})
(:items body))}]
(save-order! db order)
{:status 201 :body {:order/id (:order/id order)}}))Check yourself
? quiz
Which statement about `defrecord` is **wrong**?
Exercise
Model a shopping cart as a value: a map with :cart/id, :cart/customer,
and :cart/lines (each a map with :line/sku, :line/qty, :line/price).
Write cart-total that returns the grand total, and add-line that returns
a new cart with one extra line.