Skip to content

Instantly share code, notes, and snippets.

@escherize
Created August 13, 2025 18:19
Show Gist options
  • Save escherize/33f613bb377ab450a36e44c854d3ad34 to your computer and use it in GitHub Desktop.
Save escherize/33f613bb377ab450a36e44c854d3ad34 to your computer and use it in GitHub Desktop.

Toucan2 Hooks: How They Work and Why They're Powerful

The Hook System

Toucan2 uses a derive-based hook system rather than explicit method implementations. You declare behavior by deriving your model from special hook keywords.

Common Hook Patterns in Metabase

1. Timestamps - Automatic created_at/updated_at

;; In your model namespace:
(doto :model/Workspace
  (derive :metabase/model)
  (derive :hook/timestamped?))  ; <-- This adds automatic timestamps!

;; What this does (from metabase.models.interface):
(t2/define-before-insert :hook/timestamped?
  [instance]
  (-> instance
      (assoc :created_at (now))
      (assoc :updated_at (now))))

(t2/define-before-update :hook/timestamped?
  [instance]
  (-> instance
      (assoc :updated_at (now))))

Why it's good: Never forget to set timestamps. It's declarative - you say WHAT you want, not HOW.

2. Entity IDs - Automatic unique identifiers

(doto :model/Card
  (derive :metabase/model)
  (derive :hook/entity-id))  ; <-- Auto-generates entity_id

;; Implementation:
(t2/define-before-insert :hook/entity-id
  [instance]
  (if (contains? instance :entity_id)
    instance
    (assoc instance :entity_id (u/generate-nano-id))))

Why it's good: Consistent ID generation across all models that need it.

3. Custom Before/After Hooks

;; Define hooks for a specific model:
(t2/define-before-insert :model/Workspace
  [workspace]
  (-> workspace
      (assoc :collection_id (create-collection-for-workspace workspace))
      (assoc-defaults workspace)))

(t2/define-after-select :model/Workspace
  [workspace]
  (sort-workspace workspace))

(t2/define-before-delete :model/Workspace
  [workspace]
  ;; Clean up related resources
  (delete-workspace-collection (:collection_id workspace))
  workspace)

Why Hooks Are Better Than Helper Functions

1. Impossible to Forget

;; BAD: Helper function approach
(defn create-workspace! [name desc]
  (let [ws (t2/insert! :model/Workspace {:name name :desc desc})]
    (sort-workspace ws)))  ; <-- Easy to forget this!

;; GOOD: Hook approach
(t2/define-after-select :model/Workspace
  [workspace]
  (sort-workspace workspace))

;; Now EVERY select automatically sorts:
(t2/select-one :model/Workspace 123)  ; Already sorted!

2. Single Source of Truth

;; BAD: Logic scattered everywhere
;; api.clj:
(create-workspace! ...)
;; test.clj:
(create-workspace-for-test! ...)  ; Oops, forgot collection creation!
;; repl.clj:
(t2/insert! :model/Workspace ...)  ; Oops, no defaults!

;; GOOD: Logic in one place
(t2/define-before-insert :model/Workspace ...)
;; Now ALL inserts go through the same logic

3. Composable Through Derivation

;; Mix and match behaviors:
(doto :model/Dashboard
  (derive :metabase/model)
  (derive :hook/timestamped?)     ; Get timestamps
  (derive :hook/entity-id)        ; Get entity IDs
  (derive :hook/search-index))    ; Get search indexing

;; Each hook is independent and reusable

4. Works with ALL Toucan Operations

;; These ALL trigger the hooks:
(t2/insert! :model/Workspace {...})
(t2/insert-returning-instance! :model/Workspace {...})
(t2/with-transaction [] (t2/insert! :model/Workspace {...}))
(t2/insert-multi! :model/Workspace [{...} {...}])

;; With helper functions, you'd need variants for each!

Real Example: How Models SHOULD Work

;; models/workspace.clj
(doto :model/Workspace
  (derive :metabase/model)
  (derive :hook/timestamped?)
  (derive :hook/entity-id))

;; Create collection when workspace is created
(t2/define-before-insert :model/Workspace
  [workspace]
  (if (:collection_id workspace)
    workspace
    (assoc workspace :collection_id 
           (create-workspace-collection workspace))))

;; Always return sorted workspace
(t2/define-after-select :model/Workspace
  [workspace]
  (sort-workspace workspace))

;; Clean up when deleted
(t2/define-after-delete :model/Workspace
  [workspace]
  (delete-collection (:collection_id workspace))
  workspace)

;; api.clj becomes SIMPLE:
(api.macros/defendpoint :post "/"
  [body]
  ;; That's it! Hooks handle everything else
  (t2/insert-returning-instance! :model/Workspace body))

(api.macros/defendpoint :get "/:id"
  [{:keys [id]}]
  ;; Already sorted thanks to after-select!
  (api/check-404 (t2/select-one :model/Workspace id)))

Hook Execution Order

Toucan2 uses Methodical's prefer-method! to control hook ordering:

;; From metabase.models.interface:
(methodical/prefer-method! #'t2.before-insert/before-insert 
                           :hook/timestamped? 
                           :hook/entity-id)
;; This ensures entity-id is set BEFORE timestamps

Common Hooks in Metabase

  • :hook/timestamped? - Adds created_at and updated_at
  • :hook/entity-id - Adds unique entity_id
  • :hook/search-index - Updates search index on changes
  • :hook/created-at-timestamped? - Just created_at
  • :hook/updated-at-timestamped? - Just updated_at

Testing with Hooks

Hooks make testing EASIER, not harder:

;; Test the hook logic in isolation:
(deftest workspace-sets-collection-test
  (testing "Workspace creates collection on insert"
    (mt/with-temp [:model/Workspace workspace {:name "Test"}]
      (is (some? (:collection_id workspace))))))

;; The hook fires automatically in tests too!

Summary: Why Toucan Hooks Are Good

  1. Declarative - Say what you want, not how
  2. Unforgettable - Logic always runs, can't bypass it
  3. Composable - Mix behaviors through derivation
  4. Testable - Hooks fire in tests automatically
  5. DRY - Write once, works everywhere
  6. Debuggable - Set breakpoints in hook methods
  7. Consistent - Same behavior whether called from API, REPL, or tests

The "hook hell" concern is overblown - hooks are namespaced and ordered explicitly when needed. The alternative (helper functions everywhere) leads to inconsistent behavior and forgotten business logic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment