CRUD collection abstraction — wraps any data source in a uniform interface.
Implement the DataSource protocol for your storage layer (database, API, etc.),
then wrap it with collection to get ILookup, Seqable, Counted, Mutable, and
Wireable. One DataSource, one Collection, then compose behavior with wrappers:
read-only — disables mutations, delegates readswrap-mutable — custom mutation logic (auth, ownership), delegates readsvalidated — Malli validation of mutation input, delegates readslookup — non-enumerable keyword->value ILookup with lazy delay support;; 1. Implement DataSource for your storage
(defrecord MySource [conn]
DataSource
(fetch [_ query] ...)
(list-all [_] ...)
(create! [_ data] ...)
(update! [_ query data] ...)
(delete! [_ query] ...))
;; 2. Wrap in a Collection
(def posts (collection (->MySource conn) {:id-key :post/id}))
;; 3. Use standard Clojure verbs
(get posts {:post/id 3}) ; fetch by query (ILookup)
(seq posts) ; list all (Seqable)
(mutate! posts nil {:title "New"}) ; create
(mutate! posts {:post/id 3} data) ; update
(mutate! posts {:post/id 3} nil) ; delete
;; 4. Compose wrappers for different access levels
(def public (read-only posts))
(def restricted (wrap-mutable posts auth-fn))
For in-memory use, atom-source provides a built-in DataSource
with auto-incrementing IDs and transactional batch mutations via
transact! / snapshot.
CRUD collection abstraction — wraps any data source in a uniform interface.
Implement the DataSource protocol for your storage layer (database, API, etc.),
then wrap it with `collection` to get ILookup, Seqable, Counted, Mutable, and
Wireable. One DataSource, one Collection, then compose behavior with wrappers:
- `read-only` — disables mutations, delegates reads
- `wrap-mutable` — custom mutation logic (auth, ownership), delegates reads
- `validated` — Malli validation of mutation input, delegates reads
- `lookup` — non-enumerable keyword->value ILookup with lazy delay support
## Basic Usage
```clojure
;; 1. Implement DataSource for your storage
(defrecord MySource [conn]
DataSource
(fetch [_ query] ...)
(list-all [_] ...)
(create! [_ data] ...)
(update! [_ query data] ...)
(delete! [_ query] ...))
;; 2. Wrap in a Collection
(def posts (collection (->MySource conn) {:id-key :post/id}))
;; 3. Use standard Clojure verbs
(get posts {:post/id 3}) ; fetch by query (ILookup)
(seq posts) ; list all (Seqable)
(mutate! posts nil {:title "New"}) ; create
(mutate! posts {:post/id 3} data) ; update
(mutate! posts {:post/id 3} nil) ; delete
;; 4. Compose wrappers for different access levels
(def public (read-only posts))
(def restricted (wrap-mutable posts auth-fn))
```
For in-memory use, `atom-source` provides a built-in DataSource
with auto-incrementing IDs and transactional batch mutations via
`transact!` / `snapshot`.(atom-source)(atom-source {:keys [id-key initial] :or {id-key :id}})Create an atom-backed transactional data source.
Implements both DataSource (for individual CRUD) and TxSource (for atomic batch mutations).
Options:
Note: IDs must be positive integers. Auto-generated IDs start from 1 and increment. If providing initial data, all keys must be integers.
Examples:
(def src (atom-source))
(def src (atom-source {:id-key :user-id}))
(def src (atom-source {:initial {1 {:id 1 :name "Alice"}}}))
(def src (atom-source {:initial [{:id 10 :name "Bob"}]}))
Create an atom-backed transactional data source.
Implements both DataSource (for individual CRUD) and TxSource
(for atomic batch mutations).
Options:
- :id-key - Primary key field (default :id)
- :initial - Initial data as map {id -> item} or vector [item ...]
Note: IDs must be positive integers. Auto-generated IDs start from 1
and increment. If providing initial data, all keys must be integers.
Examples:
```clojure
(def src (atom-source))
(def src (atom-source {:id-key :user-id}))
(def src (atom-source {:initial {1 {:id 1 :name "Alice"}}}))
(def src (atom-source {:initial [{:id 10 :name "Bob"}]}))
```(collection data-source)(collection data-source {:keys [id-key indexes]})Create a Collection wrapping a DataSource.
Options:
The collection implements:
Create a Collection wrapping a DataSource.
Options:
- :id-key - Primary key field (default :id). Must match data-source config.
- :indexes - Set of indexed field sets for queries.
Default: #{#{<id-key>}} (primary key only)
Example: #{#{:id} #{:author} #{:status :type}}
The collection implements:
- ILookup: (get coll {:id 3}) -> fetch by query
- Seqable: (seq coll) -> list all
- Counted: (count coll) -> count all
- Mutable: (mutate! coll query value) -> create/update/delete
- Wireable: automatically serializes to vector for wire formatProtocol for CRUD data source backends.
Implement this for your storage layer (database, atom, API, etc.).
Protocol for CRUD data source backends. Implement this for your storage layer (database, atom, API, etc.).
(create! this data)Create new item. Returns created item with any generated fields.
Create new item. Returns created item with any generated fields.
(delete! this query)Delete item matching query. Returns true if deleted, false otherwise.
Delete item matching query. Returns true if deleted, false otherwise.
(fetch this query)Fetch item(s) matching query map. Returns item or nil.
Fetch item(s) matching query map. Returns item or nil.
(list-all this)List all items. Returns sequence.
List all items. Returns sequence.
(update! this query data)Update item matching query. Returns updated item or nil.
Update item matching query. Returns updated item or nil.
(lookup field-map)Create an ILookup + Wireable from a keyword->value map.
Delay values are dereferenced transparently on access.
->wire produces a plain map with all delays forced.
Use for non-enumerable resources where fields come from multiple sources with different costs:
(lookup {:id user-id ; cheap — used as-is
:email (:email session) ; cheap — used as-is
:slug (delay (db-lookup conn user-id)) ; expensive — computed once
:roles (or (:roles session) #{})}) ; cheap — used as-is
Delays are shared between ILookup and ->wire — a DB query runs at most once regardless of access path.
Create an ILookup + Wireable from a keyword->value map.
Delay values are dereferenced transparently on access.
`->wire` produces a plain map with all delays forced.
Use for non-enumerable resources where fields come from
multiple sources with different costs:
```clojure
(lookup {:id user-id ; cheap — used as-is
:email (:email session) ; cheap — used as-is
:slug (delay (db-lookup conn user-id)) ; expensive — computed once
:roles (or (:roles session) #{})}) ; cheap — used as-is
```
Delays are shared between ILookup and ->wire — a DB query
runs at most once regardless of access path.Protocol for collections that support CRUD mutations.
Protocol for collections that support CRUD mutations.
(mutate! coll query value)Perform mutation based on query and value:
Perform mutation based on query and value: - (mutate! coll nil data) -> CREATE - (mutate! coll query data) -> UPDATE - (mutate! coll query nil) -> DELETE
(read-only coll)Wrap a collection to make it read-only.
Delegates ILookup and Wireable. Implements Seqable and Counted only
when coll implements them, so wrapping a lookup-only collection
produces a lookup-only wrapper. Never implements Mutable, so mutating
via pattern fails with 'collection not mutable' error.
Use this for public API endpoints where reads are allowed but writes should be gated behind authentication.
Example:
(def posts (collection (atom-source)))
(def public-posts (read-only posts))
(seq public-posts) ; works
(get public-posts {:id 1}) ; works
(mutate! public-posts ...) ; throws - Mutable not implemented
Wrap a collection to make it read-only.
Delegates ILookup and Wireable. Implements Seqable and Counted only
when `coll` implements them, so wrapping a lookup-only collection
produces a lookup-only wrapper. Never implements Mutable, so mutating
via pattern fails with 'collection not mutable' error.
Use this for public API endpoints where reads are allowed but writes
should be gated behind authentication.
Example:
```clojure
(def posts (collection (atom-source)))
(def public-posts (read-only posts))
(seq public-posts) ; works
(get public-posts {:id 1}) ; works
(mutate! public-posts ...) ; throws - Mutable not implemented
```Protocol for transactional data sources.
Extends DataSource concept with ability to apply multiple mutations atomically and snapshot state for querying.
Protocol for transactional data sources. Extends DataSource concept with ability to apply multiple mutations atomically and snapshot state for querying.
(snapshot this)Get immutable snapshot of current state for querying.
Get immutable snapshot of current state for querying.
(transact! this mutations)Apply mutations atomically. Returns snapshot after mutations.
Each mutation is a map: {:op :create | :update | :delete :query map (for update/delete) :data map (for create/update)}
All mutations succeed or none do.
Apply mutations atomically. Returns snapshot after mutations.
Each mutation is a map:
{:op :create | :update | :delete
:query map (for update/delete)
:data map (for create/update)}
All mutations succeed or none do.(validated coll {:keys [query create update]})Wrap a mutable collection with Malli validation of mutation input.
Schemas is a map, any key omitted skips that check:
| key | checks | applies to |
|---|---|---|
:query | the query | UPDATE, DELETE |
:create | the value | CREATE |
:update | the value | UPDATE |
The query is checked first, so a malformed query fails before the value is looked at. Invalid input returns {:error {:type :invalid-mutation :message <humanized>}} without touching the inner collection.
Wrap outermost so it sees the raw client input, before other wrappers add server-side fields:
(validated (wrap-mutable posts ownership-fn)
{:query post-write-query
:create post-create-input
:update post-update-input})
These schemas are the write policy (writable fields, required-on-create) — usually a closed subset of the entity schema, not the read schema.
Wrap a mutable collection with Malli validation of mutation input.
Schemas is a map, any key omitted skips that check:
| key | checks | applies to |
|-----------|---------------------------------|----------------|
| `:query` | the query | UPDATE, DELETE |
| `:create` | the value | CREATE |
| `:update` | the value | UPDATE |
The query is checked first, so a malformed query fails before the
value is looked at. Invalid input returns
{:error {:type :invalid-mutation :message <humanized>}} without
touching the inner collection.
Wrap outermost so it sees the raw client input, before other
wrappers add server-side fields:
```clojure
(validated (wrap-mutable posts ownership-fn)
{:query post-write-query
:create post-create-input
:update post-update-input})
```
These schemas are the write policy (writable fields, required-on-create)
— usually a closed subset of the entity schema, not the read schema.Protocol for types needing custom wire serialization.
Implement this for custom types that should be converted to standard Clojure data for Transit/EDN serialization.
Protocol for types needing custom wire serialization. Implement this for custom types that should be converted to standard Clojure data for Transit/EDN serialization.
(->wire this)Convert to serializable Clojure data (maps, vectors, etc.)
Convert to serializable Clojure data (maps, vectors, etc.)
(wrap-mutable coll mutate-fn)Wrap a collection with custom mutation logic, delegating reads.
mutate-fn receives (coll query value) and should return:
Reads and Wireable delegate to the inner collection. Seqable and
Counted are implemented only when coll implements them, so wrapping
a lookup-only collection produces a lookup-only wrapper. Use this to
add authorization, ownership checks, or field injection without
reimplementing the full deftype boilerplate.
Example:
(def member-posts
(wrap-mutable posts
(fn [posts query value]
(if (owns? user-id query)
(mutate! posts query value)
{:error {:type :forbidden}}))))
Wrap a collection with custom mutation logic, delegating reads.
mutate-fn receives (coll query value) and should return:
- For create (nil query, some value): the created item
- For update (some query, some value): the updated item
- For delete (some query, nil value): true/false
- For errors: {:error {:type ... :message ...}}
Reads and Wireable delegate to the inner collection. Seqable and
Counted are implemented only when `coll` implements them, so wrapping
a lookup-only collection produces a lookup-only wrapper. Use this to
add authorization, ownership checks, or field injection without
reimplementing the full deftype boilerplate.
Example:
```clojure
(def member-posts
(wrap-mutable posts
(fn [posts query value]
(if (owns? user-id query)
(mutate! posts query value)
{:error {:type :forbidden}}))))
```cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |