~ / track D / clojure in practice

Modeling the domain with values

Intermediatepractice

Most 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.

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

A few habits keep maps healthy as the program grows:

  • Name keys with intent. :unit-price says 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.
loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

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:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

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

d‌e‌f‌r‌e‌c‌o‌r‌d creates a Java class with named fields. Records still behave like maps for the operations you'd expect (get, a‌s‌s‌o‌c, destructuring), but field access is faster, the constructor enforces arity, and they can participate in protocols.

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

Two things to watch:

  1. a‌s‌s‌o‌c of an unknown key downgrades the record to a map. (assoc price :rate 0.9) returns a plain map, not a Money — the record class only "owns" the fields it was defined with.
  2. Records don't share identity with maps, even when their fields match. (= (->Money 30.0 "USD") {:amount 30.0 :currency "USD"}) is false.

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:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

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

SituationReach for
First sketch of an entityplain map
Shape stable, performance mattersrecord
Need to implement a protocol on the valuerecord
Value crosses many subsystems / serializes to ednmap with namespaced keys
Value is "open" — different producers add their own fieldsmap

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.

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate
 status: new