~ / track C / clojure ecosystem

Data-oriented programming

Intermediatepractice

In 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:

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

The "methods" are ordinary functions. They take the data as a first argument and return a new value:

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

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:

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

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 t‌o‌S‌t‌r‌i‌n‌g/__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 (a‌s‌s‌o‌c, m‌e‌r‌g‌e, s‌e‌l‌e‌c‌t‌-‌k‌e‌y‌s, 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).

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