How Patcho works under the hood.
Patcho uses Clojure multimethods as a patch registry. When you write:
(patch/upgrade :myapp/database "1.0.0"
(create-tables!))
The upgrade macro expands to:
(defmethod _upgrade [:myapp/database "1.0.0"]
[_ _]
(create-tables!))
This registers a multimethod implementation that dispatches on the [topic version] tuple.
Patches are registered when namespaces load—not when you call level! or apply.
sequenceDiagram
participant App as Application
participant NS as myapp.database namespace
participant MM as Multimethods
App->>NS: (require 'myapp.database)
NS->>MM: (defmethod version :myapp/database ...) → "2.0.0"
NS->>MM: (defmethod _upgrade [:myapp/database "1.0.0"] ...)
NS->>MM: (defmethod _upgrade [:myapp/database "1.5.0"] ...)
NS->>MM: (defmethod _upgrade [:myapp/database "2.0.0"] ...)
NS->>MM: (defmethod _downgrade [:myapp/database "2.0.0"] ...)
NS-->>App: Namespace loaded, patches registered
This is why patches can live anywhere in your codebase—they just need to be in a namespace that gets loaded before level! is called.
;; Target version for a topic
(defmulti version (fn [topic] topic))
;; Upgrade patches: dispatch on [topic version]
(defmulti _upgrade (fn [topic version] [topic version]))
;; Downgrade patches: dispatch on [topic version]
(defmulti _downgrade (fn [topic version] [topic version]))
The methods function gives you all registered implementations:
(methods _upgrade)
;; => {[:myapp/database "1.0.0"] #fn
;; [:myapp/database "1.5.0"] #fn
;; [:myapp/database "2.0.0"] #fn
;; [:myapp/cache "1.0.0"] #fn
;; ...}
This is how Patcho discovers all patches without a separate registry.
When you call (patch/apply topic current target):
flowchart TD
A[apply topic current target] --> B{current = target?}
B -->|Yes| Z[Return nil - nothing to do]
B -->|No| C{target > current?}
C -->|Yes| D[Direction: UPGRADE]
C -->|No| E[Direction: DOWNGRADE]
D --> F[Get all _upgrade methods]
E --> G[Get all _downgrade methods]
F --> H[Filter by topic]
G --> H
H --> I[Sort by version]
I --> J{Direction?}
J -->|Upgrade| K[Sort ascending - oldest first]
J -->|Downgrade| L[Sort descending - newest first]
K --> M[Filter valid range]
L --> M
M --> N[Execute patches in sequence]
N --> O[Write target version to store]
O --> P[Return target version]
For upgrades (current=1.0.0, target=2.0.0):
version > current AND version <= targetFor downgrades (current=2.0.0, target=1.0.0):
version <= current AND version > targetPatcho uses version-clj for semantic version comparison:
(require '[version-clj.core :as vrs])
(vrs/older? "1.0.0" "2.0.0") ;; => true
(vrs/newer? "2.0.0" "1.0.0") ;; => true
(vrs/older-or-equal? "1.0.0" "1.0.0") ;; => true
This allows proper sorting: 1.0.0 < 1.5.0 < 1.10.0 < 2.0.0
Starting at version 1.0.0, upgrading to 2.0.0:
flowchart LR
subgraph Store
S1[Installed: 1.0.0]
S2[Installed: 2.0.0]
end
subgraph Patches
P1[1.0.0 - skip, already applied]
P2[1.5.0 - RUN]
P3[2.0.0 - RUN]
end
S1 --> P1
P1 -.->|skip| P2
P2 -->|execute| P3
P3 -->|execute| S2
;; Registered patches
(patch/upgrade :myapp/db "1.0.0" (println "1.0.0")) ; skip - already done
(patch/upgrade :myapp/db "1.5.0" (println "1.5.0")) ; run
(patch/upgrade :myapp/db "2.0.0" (println "2.0.0")) ; run
;; Apply
(patch/apply :myapp/db "1.0.0" "2.0.0")
;; Output:
;; 1.5.0
;; 2.0.0
;; => "2.0.0"
Starting at version 2.0.0, downgrading to 1.0.0:
flowchart LR
subgraph Store
S1[Installed: 2.0.0]
S2[Installed: 1.0.0]
end
subgraph Patches
P1[2.0.0 - RUN - rollback this]
P2[1.5.0 - RUN - rollback this]
P3[1.0.0 - skip, that's target]
end
S1 --> P1
P1 -->|execute| P2
P2 -->|execute| P3
P3 -.->|skip| S2
;; Registered downgrade patches
(patch/downgrade :myapp/db "2.0.0" (println "rollback 2.0.0")) ; run
(patch/downgrade :myapp/db "1.5.0" (println "rollback 1.5.0")) ; run
(patch/downgrade :myapp/db "1.0.0" (println "rollback 1.0.0")) ; skip - target
;; Apply
(patch/apply :myapp/db "2.0.0" "1.0.0")
;; Output:
;; rollback 2.0.0
;; rollback 1.5.0
;; => "1.0.0"
level! is syntactic sugar that reads the installed version from the store:
(defn level! [topic]
(let [current (deployed-version topic) ; read from *version-store*
target (version topic)] ; read from multimethod
(when (not= current target)
(apply topic current target))))
This is the typical entry point—you don't specify versions, Patcho figures it out.
After patches execute successfully, the new version is persisted:
flowchart TD
A[level! :myapp/db] --> B[Read installed from store]
B --> C[Read target from version multimethod]
C --> D{Versions differ?}
D -->|No| E[Return nil]
D -->|Yes| F[Execute patches]
F --> G[Write new version to store]
G --> H[Return new version]
The store is pluggable via the VersionStore protocol:
(defprotocol VersionStore
(read-version [this topic])
(write-version [this topic version]))
The lifecycle system (patcho.lifecycle) is separate but complementary:
flowchart TD
subgraph "Lifecycle: start!"
L1[Resolve dependencies]
L2[Run setup if needed]
L3[Call :start function]
L4[Patches run inside :start via level!]
end
subgraph "Patch System"
P1[Read installed version]
P2[Compare to target]
P3[Execute patches]
P4[Write new version]
end
L1 --> L2 --> L3 --> L4
L4 --> P1 --> P2 --> P3 --> P4
Lifecycle handles when things run (dependency order, setup vs start). Patches handle what version things are at (migrations).
(methods multimethod)(keys (methods _upgrade)) shows all registered patchesThe tradeoff: you can't "unregister" a patch at runtime. But patches are definitional—they describe your version history. You wouldn't want to unregister them anyway.
Can you improve this documentation?Edit on GitHub
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 |