~ / track C / clojure ecosystem
Data-oriented programming
IntermediatepracticeIn an object-oriented design, behavior lives with the data: each class defines its own methods, and changing the shape ripples through dozens of files. In data-oriented programming the data is a plain, transparent value — a map or a vector — and functions operate on it from the outside. Anyone can read it, anyone can transform it; nothing is hidden, nothing is private.
The slogan: generic data + generic functions > specialized objects. Once you commit to maps as entities, Modeling the domain with values covers namespaced keys and validation at the boundary; The expression problem shows how to add new operations without touching existing data shapes.
Minimal example
Represent a domain value as a plain map. There is no constructor, no class, no DSL — just keys and values:
The "methods" are ordinary functions. They take the data as a first argument and return a new value:
Practical example
Because the data is just data, many small functions compose freely. Here a discount pipeline applies independent transforms without any object lifecycle to manage:
Notice the pipeline: a chain of pure functions, each producing the next
version of the order. The original order is untouched.
Where it pays off
- Logging, serialization, debugging come for free: a value is just a
printable map. There is no toStringclojure.core/toStringview on clojuredocs →/
__repr__to keep updated. - Tests are trivial: build a map literal, call the function, compare to a map literal. No mocks, no fixtures.
- Tools compose: every function that works on maps in general (assocclojure.core/assocReturn a new map with k -> v associated.view on clojuredocs →,
mergeclojure.core/mergeRight-most map wins; nil-safe.view on clojuredocs →, select-keysclojure.core/select-keysSub-map containing only the requested keys.view on clojuredocs →,
walk,spec) works on yours.
The trade-off: there is no compiler-enforced schema. Tools like clojure.spec
or Malli cover that gap when you want it.
In a Clojure service
Domain entities in a Clojure API are plain maps end to end. An order arrives
as JSON, gets keywordized into {:order/id ... :order/items [...]}, and
flows through generic functions like total and validate-order without any
class hierarchy. The handler trusts data that already passed validation;
inner functions never re-check shape — they transform values they receive.
That transparency pays off in logs, REPL debugging, and tests built from map
literals.
(def order-sketch ;; domain as plain data
{:order/id "ord-42"
:order/status :pending
:order/items [{:item/sku "A" :item/qty 2}]})
(defn validate-order [order] ;; generic function (sketch)
(and (string? (:order/id order))
(vector? (:order/items order))
(every? #(and (string? (:item/sku %)) (pos? (:item/qty %)))
(:order/items order))))Check yourself
? quiz
What is the practical implication of representing the `order` as a plain map instead of an `Order` class with methods?
Exercise
Add a :notes field to an order without modifying any existing function. Then
write add-note, a pure function that appends a string to :notes (creating
the vector if it doesn't exist).