Can Macros Tame Crud?
Feb 2026 - Alex Alejandre

I want to make a forum, easy enough. An MVP only needs to:

  • read .json (lazy initial db) into hash map
  • on page visit, turn hash map into page
  • modify map (for posts)
  • save json on edit (ignore other processes and threads to KISS)

That’s not actually different from a to do app, how easy! (So the examples come from my notes.) I’ve even told people “CRUD is a solved problem”. Let’s start a Joy project.

A script if you want to work along…

(Can anyone tell me how to narrow these code block?)

I hope you’re on Linux

# install janet
git clone https://github.com/janet-lang/janet
cd janet && git fetch --tags && git checkout $(git describe --tags "$(git rev-list --tags --max-count=1)")
export CFLAGS='-fPIC -O2 -flto' # speed gains
sudo make install
make install-jpm-git # Janets old package manager
cd ..
sudo rm -rf janet

# install libraries
sudo jpm install joy
sudo jpm install spork

Now just run joy new example && cd example and open main.janet!

(defn home [request]
  (def data ((json/decode (slurp "entries.json") true) :active)) # true turns strings into keywords!
  (defn url [d] (get-in d [:links 0 :target]))

  [:div {:class "tc"}
   [:h1 "I'm a title!"]
   (map
    (fn [d] [:p
             [:b (d :title)]
             (when-let [u (url d)]
               [:a {:href u} "Link"])
             [:p (d :explanation)]])
    data)])
Not a typo, examplest is cute
Note forma/examplest
{
    "active": [
        {
            "time": "2025-03-27 05:46:35",
            "topic": "fennel",
            "explanation": "Fennel looks promising, give it a spin",
            "title": "Build Fennel forum \/ ssg",
            "done": false
        },
        {
            "time": "2025-03-27 07:18:11",
            "topic": "finance, stock, ",
            "explanation": "",
            "title": "GBX - Greenbrier",
            "done": false,
            "links": [
                {
                    "target": "https:\/\/www.gbrx.com\/",
                    "internal": false,
                    "type": [
                        "reference"
                    ]
                }
            ]
        },
        {
            "time": "2025-03-27 07:15:59",
            "topic": "security, website",
            "explanation": "",
            "title": "Minimize your Browser Fngerprint",
            "done": false,
            "links": [
                {
                    "target": "https:\/\/coveryourtracks.eff.org",
                    "internal": false,
                    "type": [
                        "reference"
                    ]
                }
            ]
        },
	    {
            "time": "2025-03-27 06:03:29",
            "topic": "china, security, finance",
            "explanation": "",
            "title": "Chinese Money Laundering Practices",
            "done": false,
            "links": [
                {
                    "target": "https:\/\/globalchinapulse.net\/moving-bricks-money-laundering-practices-in-the-online-scam-industry\/",
                    "internal": false,
                    "type": [
                        "reference"
                    ]
                }
            ]
        }]}

In Janet like k, data structures are their own accessor functions: ({:key "value} :key) which is much nicer than get etc. but it’s still repetitive/tedious. In CRUD we are pairing schemas around, isn’t there a better way?

get? Like getters and setters in OOP? Common Lispers are smart and like OOP. And aren’t objects just hash maps which Clojure-likes love? We can try that. We even have CLOS at home (actually better), but let’s keep it simple, this isn’t an interview. Besides, Janet already has some OOP built in:

(def EntryProto
  @{:title (fn [self] (self "title"))
    :explanation (fn [self] (self "explanation"))
    # Calculated property that handles the nesting safely
    :url (fn [self] (get-in self ["links" 0 "target"]))})

(defn wrap-entry [raw-data]
  (table/setproto raw-data EntryProto))

(def data (map wrap-entry ((json/decode (slurp "entries.json")) "active"))) # not keywords, true not set

(defn home [request]
  [:div {:class "tc"}
   [:h1 "You found joy!"]
   (map (fn [d]
          [:p
           [:b (:title d)]  # Calls the :title method in EntryProto
           (when-let [u (:url d)]
             [:a {:href u} "Link"])
           [:p (:explanation d)]])
        data)])
(array/push data (wrap-entry @{"title" "Bla"
                               "links" @[@{"target" "https://google.com"}]}))

Whelp, that’s worse (a separate for everything?! Why don’t short funcs like |($ "target") work here!?) and this is Lisp (Lispers know Worse Is Better) so let’s make a macro:

(defmacro def-entity [name & p]
  (def schema-map (apply struct p)) 
  (def getters (map (fn [[k path]]
                      (if (or (tuple? path) (array? path))
                        ~(defn ,(symbol k) [d] (get-in d ,path))
                        ~(defn ,(symbol k) [d] (get d ,path)))) # get keeps symmetry
                    (pairs schema-map)))
  (def constructor
    ~(defn make [&keys args]
       (def res @{})
       (loop [[k v] :in (pairs args)]
         (if-let [path (get ,schema-map k)]
           (if (string? path)
             (put res path v)
             (eprint "Unhandled stuff: " k))
           (eprint "Unknown schema key: " k)))
       res))

  ~(upscope ,;getters ,constructor))

(def-entity Entry
  :time "time"
  :topic "topic"
  :explanation "explanation"
  :title "title"
  :links "links"
  :url ["links" 0 "target"]   # note the nesting!
  )

(def data ((json/decode (slurp "entries.json")) "active"))

(defn home [request]
  [:div {:class "tc"}
   [:h1 "You found joy!"]
   (map (fn [d]
          [:p
           [:b (title d)]         # now call generated funcs
           (when (url d)
             [:a {:href (url d)} "Link"])
           [:span (explanation d)]])
        data)])

The schema’s all duplication, so let’s use an array based schema (and keywords). We should also handle other things like SQL:

(defmacro def-entity [name & fields]
  (def schema   # Normalize fields to {name path}
    (from-pairs
     (map (fn [f]
            (if (keyword? f)
              [f f]
              f))
          fields)))

  (def getters
    (map (fn [[k path]]
           (def func-name (symbol k))
           (def fetch-expr
             (if (or (tuple? path) (array? path))
               ~(get-in d ,path)
               ~(get d ,path)))
           ~(defn ,func-name [d] ,fetch-expr))
         (pairs schema)))

  # make the wrapping proto
  (def assignments
    (map (fn [[k path]]
           (def fetch-expr
             (if (or (tuple? path) (array? path))
               ~(get-in source ,path)
               ~(get source ,path)))
           ~(put res ,k ,fetch-expr))
         (pairs schema)))
  (def constructor-name (symbol "make-" name))
  (def constructor
    ~(defn ,constructor-name [source]
       (def res @{})
       ,;assignments
       res))

  ~(upscope ,;getters ,constructor))

(def-entity Entry
  :time
  :topic
  :title
  :explanation
  [:url [:links 0 :target]])

(def data ((json/decode (slurp "entries.json") true) :active))

(defn home [request]
  [:div {:class "tc"}
   [:h1 "You found joy!"]
   (map (fn [d]
          [:p
           [:b (title d)]         # now call generated funcs
           (when (url d)
             [:a {:href (url d)} "Link"])
           [:span (explanation d)]])
        data)])

For an SQL example, just drop in def-entity and this to replace create in examples/export:

(def-entity JDSchema
  :body # simple field w matching names
  [:account-id    [:account :id]] # flatten objects
  [:account-login [:account :login]]
  [:binding-id    [:binding :id]]
  [:binding-name  [:binding :name]]
  [:package-id    [:package :id]]
  [:package-name  [:package :name]])

(defn create [request]
  (def {:body body :params params} request)
  (when-let [login   (get-in request [:session :login])
             account (db/find-by :account :where {:login login})
             binding (db/find-by :binding :where {:id (params :binding-id)})
             package (db/find-by :package :where {:id (binding :package-id)})]

    (if (blank? (body :body))
      (new (merge request {:errors {:body "Body can't be blank"}}))

      (do
        (db/insert :example (make-JDSchema {:account account # this struct holds values
                                            :binding binding
                                            :package package
                                            :body    (body :body)}))
        (redirect (binding-show-url binding))))))

I leave deduplicating JDSchema and the holding struct as an exercise to the reader, but is this really better than the original?

(defn create [request]
  (def {:body body :params params} request)
  (when-let [login (get-in request [:session :login])
             account (db/find-by :account :where {:login login})
             binding (db/find-by :binding :where {:id (params :binding-id)})
             package (db/find-by :package :where {:id (binding :package-id)})]

    (if (blank? (body :body))
      (new (merge request {:errors {:body "Body can't be blank"}}))

      (do
        (db/insert :example {:account-id (account :id)
                             :account-login (account :login)
                             :binding-id (binding :id)
                             :binding-name (binding :name)
                             :package-id (package :id)
                             :package-name (package :name)
                             :body (body :body)})
        (redirect (binding-show-url binding))))))

We don’t have to specify package-id etc. but at minimum we must always indicate what data we’re changing: [account binding package (body :body)] That’s the real issue. Really, we can only choose between:

  • (ds key
  • (ds :key)
  • (:getter ds)
  • (getter ds)

(I’m not sure what I prefer.) Even if we just do key, how far does that get us? Is there a better way? Has Betteridge struck again? Hold tight while we hunt for tacit, point-free CRUD!