In many ecosystems, frameworks define architecture for you. In Lisp, you define your own abstractions. The biggest difference was macros. Rather than repeating patterns such as JSON parsing,Error normalization, API response formatting and Exception mapping. I abstracted them into macros that standardized the flow across the entire backend. This allowed controllers to focus purely on business logic. For example, API responses are wrapped automatically (defmacro with-api-response (result) `(let ((res ,result)) (cond ((null res) `(200 (:content-type "application/json") (,(jonathan:to-json '(:status "success" :data ())))))
((eq res :invalid)
`(400 (:content-type "application/json")
(,(jonathan:to-json '(:status "error")))))
(t
`(200 (:content-type "application/json")
(,(jonathan:to-json
(list :status "success" :data res))))))))
Instead of writing error handling and serialization logic repeatedly, I could define the contract once.I also implemented a lightweight file reload mechanism. Instead of restarting the server on every change, the system checks file timestamps on access and reloads only what changed. This gave me a workflow similar to frontend hot reloading, but implemented manually.
(defun reload-dev () (dolist (file '("controllers/controllers-package")) (let* ((pathname (asdf:system-relative-pathname "mindmap" (format nil "~A.lisp" file))) (new-time (file-write-date pathname)) (old-time (gethash file file-mod-times 0))) (when (> new-time old-time) (load pathname) (setf (gethash file file-mod-times) new-time))))) Because everything is just code and macros, the system remains transparent and hackable.
The combination of: Macros for abstraction, Symbolic error flow, Minimal external, framework constraints and Explicit layering. resulted in a backend that feels small, consistent, and expressive. It reminded me somewhat of Go’s explicit design philosophy, but with a far more powerful metaprogramming layer. Lisp did not give me more libraries. It gave me more control over structure.
(defmacro with-invalid (&body body) `(handler-case (progn ,@body) (error (e) (format error-output "ERROR: ~A~%" e) :invalid)))
Every layer speaks the same language: either valid data or :invalid. This made the layering extremely clear and reduced cognitive load.