ΛↃ LAMBDACOMBINE

Symbolic Systems Infrastructure

07 · CQRS

COMMAND QUERY SEPARATION · BROADCAST · MULTI-CLIENT
NASA Mission Control
NASA Mission Control

CQRS (Command Query Responsibility Segregation) separates reads from writes. In a Datastar application this maps naturally: the browser holds one long-lived GET stream for receiving updates (the query), and fires short POST requests for user actions (the commands).

The result is real-time multi-client state: a command from one tab immediately pushes the new state to every connected client.

CQRS is not a requirement in Datastar, but a pattern that Datastar makes easy to adopt - so much so that it is explicitly mentioned in the documentation. What can be confusing is that CQRS, to be used, requires backend code that is outside of Datastar's scope, something that isn't obvious to those first hearing about it.

Why use it

Let's assume that CQRS is just a random mix of uppercase letters, and that we are happily building our SSE endpoints: we have some that return immediately, but we also have an open stream that, say, sends up an update every 10 seconds. We now want to add a new feature, a button that shows a weather forecast: should we add a new SSE stream for this? Do we need to add as many streams as commmands? The answer is no, and this is where CQRS fits in: we want to make use of a single stream to bring us updates, while being able to send many different commands. For this to work, we can't rely on the 1:1 mapping between clicks and requests, so we need a way to receive commands, map them to a specific client, and send the result to the stream that is attached to that client.

The subscription model

This is where a registry comes in. When an SSE connection opens, register its generator in a shared list. When it closes, remove it. A broadcast walks the list and calls patch-elements on each generator. A failed write means the client disconnected; remove it and continue. In Lisp:

(defparameter *subscribers* nil)
(defparameter *lock* (bt:make-lock "subs"))

(defun register (gen)
  (bt:with-lock-held (*lock*)
    (pushnew gen *subscribers* :test #'eq)))

(defun unregister (gen)
  (bt:with-lock-held (*lock*)
    (setf *subscribers* (remove gen *subscribers* :test #'eq))))

(defun broadcast (fn)
  (let (snapshot)
    (bt:with-lock-held (*lock*) (setq snapshot (copy-list *subscribers*)))
    (dolist (gen snapshot)
      (handler-case (funcall fn gen)
        (error () (unregister gen))))))

This is the pattern (with some minor variations) that every multi-client Datastar application needs, and because of that the SDK packages an opt-in registry that can be used to get started.

The connection registry

datastar-cl/registry is an opt-in sub-system - broadcast is a CQRS concern, not every application needs it, and some will want to implement their own. Load it separately:

(ql:quickload '(:datastar-cl/hunchentoot :datastar-cl/registry))

Alias the package as reg (the convention in all SDK examples) and create one registry per topic:

(:local-nicknames (:d* :datastar-cl) (:reg :datastar-cl.registry))

(defvar *clients* (reg:make-sse-registry "my-app"))

The registry is thread-safe and prunes disconnected clients automatically. Use reg:register / reg:unregister in the stream lifecycle hooks, and reg:notify-subscribers to fan out:

(d*:with-sse (gen hunchentoot:*request*
              :on-connect    (lambda (g) (reg:register *clients* g))
              :on-disconnect (lambda (g) (reg:unregister *clients* g)))
(defun broadcast ()
  (let ((content (render-content)))
    (reg:notify-subscribers
     *clients*
     (lambda (g) (d*:patch-elements g content :selector "#content")))))

Command endpoint

The command handler updates state and broadcasts immediately. It does not hold an open SSE connection; it patches all subscribers and returns.

(defroute click (:post :application/json &key datastar)
  (declare (ignore datastar))
  (incf *clicks*)
  (broadcast))

Commands (writes) are send with POST, and requests (reads) get to us through a GET: review the Tao and the above examples should be (more) obvious now.)

Live Demo

Open this page in two browser tabs. Click Increment in one tab and watch both tabs update. The time ticks every second via the SSE loop; the click count updates instantly via broadcast.

Time: 03:24:54

Button clicks: 0


Local edit (not shared).

Full Source (standalone Hunchentoot)

;;;; -*- Mode: LISP; fill-column: 80; coding: utf-8 -*-

;;;; FAT-CQRS.LISP --- Fat morph with CQRS notification pattern
;;;;
;;;; Copyright (C) 2025, 2026 Frederico Muñoz / ΛↃ lambda combine
;;;;
;;;; This file is part of datastar-cl, the Common Lisp SDK for Datastar
;;;;
;;;; License: MIT

;;; Demonstrates two patterns:
;;;
;;; 1) CQRS: commands via POST (button), received through the SSE stream.
;;; 2) Fat morphing: every update patches the entire content area; no
;;;    fine-grained element targeting. The timer loop and button clicks both
;;;    go through a simple subscription model.
;;;
;;; Run with: sbcl --load fat-cqrs.lisp

;;; Single-file loading harness ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(ql:quickload '(:hunchentoot :spinneret :datastar-cl/hunchentoot :datastar-cl/registry))

(defpackage #:fat-cqrs
  (:use #:cl #:hunchentoot)
  (:local-nicknames (:sp  :spinneret)
                    (:d*  :datastar-cl)
                    (:reg :datastar-cl.registry)
                    (:ht  :hunchentoot)))

(in-package #:fat-cqrs)

;;; Parameters ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defparameter *clicks* 0)
(defvar *clients* (reg:make-sse-registry "fat-cqrs"))

;;; Broadcast ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defun broadcast ()
  (let ((content (render-content)))
    (reg:notify-subscribers
     *clients*
     (lambda (g) (d*:patch-elements g content :selector "#content")))))
;;: Content ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defun render-content ()
  (multiple-value-bind (s m h) (decode-universal-time (get-universal-time))
    (sp:with-html-string
        (:div
         :id "content"
         (:button :|data-on:click| (d*:sse-post "/click") "Click me")         
         (:p "Time: " (:strong (format nil "~2,'0d:~2,'0d:~2,'0d" h m s)))
         (:p "Button clicks: " (:strong *clicks*))
         (:hr)
         (:p "Below is a paragraph with " (:code "data-ignore-morph")
             ". It is never touched by morphing, so you can edit it freely.")
         (:div :data-ignore-morph "true"
               (:p :contenteditable "true"
                   :style "background-color: #ddd; padding:0.5em;"
                   "Edit me: morphing will not reset my content!"))))))

;;; Web server setup
(ht:define-easy-handler (index :uri "/") ()
  (setf (hunchentoot:content-type*) "text/html")
  (sp:with-html-string
    (:doctype)
    (:html
     (:head (:script :type "module" :src (d*:datastar-url)))
      (:body :data-init (d*:sse-get "/sse")
             (:h1 "Fat CQRS")
             (:p "The time updates every 1s via the SSE loop.  "
                 "Click the button: the count pushes instantly to all clients.")
            (:raw (render-content))))))

(ht:define-easy-handler (sse :uri "/sse") ()
  (d*:with-sse (gen hunchentoot:*request*
                :on-connect    (lambda (g) (reg:register *clients* g))
                :on-disconnect (lambda (g) (reg:unregister *clients* g)))
    (loop
      ;; Note that we're ommitting :selector "#content" ; in "outer" mode (the
      ;; default), Datastar morphs everything (we could pass an entire HTML
      ;; page).
      (d*:patch-elements gen (render-content))
      (sleep 1))))

(ht:define-easy-handler (click :uri "/click") ()
  (setf (hunchentoot:content-type*) "text/plain")
  (incf *clicks*)
  (broadcast)
  "")

(ht:start (make-instance 'ht:easy-acceptor :port 8989))
(format t "~&Server started on http://localhost:8989~%")

(defroute guide-cqrs (:get :text/html))