Toucan2 uses a derive-based hook system rather than explicit method implementations. You declare behavior by deriving your model from special hook keywords.
;; 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.
(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.
;; 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)
;; 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!
;; 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
;; 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
;; 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!
;; 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)))
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
: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
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!
- Declarative - Say what you want, not how
- Unforgettable - Logic always runs, can't bypass it
- Composable - Mix behaviors through derivation
- Testable - Hooks fire in tests automatically
- DRY - Write once, works everywhere
- Debuggable - Set breakpoints in hook methods
- 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.