backend-module-conventions
Where Metabase backend code goes and how it reaches the app DB. Covers module layout, the `<module>.db` rule, `[:auto/param]` value binding, module config, and pre-handoff checks. Use when adding or moving backend namespaces, writing app-DB queries, or touching module boundaries. Preloaded by every `*-backend-expert` agent.Backend module conventions
This skill decides where a line of backend code lives and how it reaches the app DB. Project CLAUDE.md
covers the module config keys, nested modules, test commands, and ratchets. When the two disagree, the
source tree wins. Read the linter or test that enforces a rule before you trust any prose about it,
including this file.
Split a module into standard namespaces
A module is metabase.<module> (OSS, src/) or metabase-enterprise.<module> (EE,
enterprise/backend/src/). Most modules split into these namespaces:
| Namespace | Holds |
|---|---|
<module>.core | The public API. Other modules call only this (plus anything else listed in the module's :api). Often a potemkin/import-vars facade. |
<module>.db | Every app-DB query the module makes. See the next section. |
<module>.models.* | Toucan 2 model definitions: methodical/defmethod t2/table-name, hooks, transforms. |
<module>.settings | defsettings. |
<module>.init | Requires the namespaces that register things as a side effect (settings, tasks, event handlers). metabase.core.init or metabase-enterprise.core.init requires it. |
<module>.api, or the nested <module>.rest module (:ns-prefix "metabase.<module>-rest", dir <module>_rest/) | HTTP endpoints. Domain logic stays in the base module. The .rest child calls the parent's .core. |
enterprise/<module> | The EE companion of an OSS module. It nests under the OSS module, which exports it automatically. |
If a setting, task, or event handler never takes effect, look for a missing link in the .init chain.
Keep app-DB access in <module>.db
Call Toucan 2 query functions (t2/select*, t2/insert!, t2/update!, t2/delete!, t2/count,
t2/exists?, t2/query, ...) and the metabase.app-db.core wrappers (mdb/query,
mdb/update-or-insert!, ...) only from metabase[-enterprise].<module>.db. Driver code uses
metabase.driver.<driver>.db. Every other namespace calls functions from its own module's db.
The kondo hook hooks.metabase.toucan.db-ns reports violations as :metabase/t2-query-namespace.
Test files are exempt.
Write db functions in the style of src/metabase/settings/db.clj:
- Make each function a thin
mu/defnthat wraps one query and adds no logic. Give it a docstring that says what it returns. - Name functions after the domain question (
setting-value,delete-setting!), not after SQL. - Keep branching, permission checks, and data shaping in the caller. A
dbfunction that grows anifbelongs somewhere else. - Get cross-module data through the other module's
.core, not itsdb. A few modules list theirdbin:apias legacy exceptions. Don't add more.
Bind values with [:auto/param v]
metabase.app-db.value-guard binds a value written as [:auto/param v] as a SQL parameter. The
:metabase/unsafe-app-db-query lint is off globally. .clj-kondo/config.edn turns it on per namespace
under :config-in-ns, one converted db.clj at a time. Follow these rules in every db.clj, whether or
not the lint covers it yet:
- Mark a value that comes from a variable when it sits in a value slot. Value slots are the right-hand side of
:=,:in,:like, ..., and kv-arg values used as filters. - Never mark a literal. A literal cannot carry a request value.
- Never mark a value in an identifier slot (
:select,:from, joins, aliases, and similar).value-guardthrows::marker-outside-value-slotat compile time. In:order-byand:group-bya marker compiles toORDER BY ?, which is useless but does not throw. Don't write one there either. - Leave a possibly-empty collection unmarked. Toucan rewrites
[:in col []]tofalsebefore the marker step. - Leave values that
t2/update!writes unmarked. Model hooks such as encryption must still see them. - Keep kv-arg style (
:engine engine) for a column with a Toucan type transform. If you move a keyword value into a raw{:where ...}map, the transform is skipped. HoneySQL then formats the keyword as an identifier, and the query returns wrong rows without failing.
The docstring of metabase.app-db.value-guard explains what the guard refuses without any marker.
Add app-DB migrations as one file per change
./bin/lint-migrations-file.sh enforces these rules. Run it after every change.
- Create the file with
./bin/mage new-migration <NN> <snake_name>(e.g../bin/mage new-migration 65 add_user_table). It writesresources/migrations/<NNN>/<yyyyMMdd>_<snake_name>.yamlwith a changeset stub. Fill in the TODOs. - Starting with v60, each change is its own file.
NNNis the earliest release the change ships in. New work goes in the newest directory. Don't add to001_update_migrations.yaml. Its footer describes the legacy format. - Changeset IDs are
v<NN>.<suffix>, whereNNmatches the directory and the suffix is a random 6-char lowercase alphanumeric string (e.g.v65.m2xncr).new-migrationgenerates one for the first changeset. For extra changesets in the same file, make up a new suffix and grepresources/migrationsto confirm it is unused. IDs are not ordered within a directory. - Don't edit a shipped changeset. Add a new one. (Liquibase does recompute checksums and some edits are technically harmless, but the line between "cosmetic" and "ran instances now diverge from fresh installs" is easy to misjudge, so just add a new changeset.) Never delete a shipped changeset either: instances keep the row in
DATABASECHANGELOGforever; removing the id makes Liquibase log "not rolled back" and any replacement that recreates the same object fails with a duplicate-object error. To retire one when you must, add a NEW changeset that doesDELETE FROM DATABASECHANGELOG WHERE id IN (...)the retired ids. Put it in the same file as the retired changeset when it ships in the same release (063/20260714_field_fk_target_field_id_fk.yaml::v63.2026-07-22T12:00:00), or in the current release's file when it retires an older release's changeset (059_update_migrations.yaml::v59.2026-01-09T11:30:00retiresv58.2026-01-09T12:00:00). - To backport, copy the changesets into the older directory with
v<older>IDs. Guard each copy withpreConditions: onFail: MARK_RANplusnot: changeSetExecutedthat points at the newer ID, so databases that already ran it skip it.resources/migrations/064/20260911_glossary_entity_id.yamlshows the pattern. - Give every
indexExists,primaryKeyExists, anduniqueConstraintExistsprecondition atableName, and everyforeignKeyConstraintExistsaforeignKeyTableName. Without it, Liquibase snapshots the whole schema, which takes seconds per check. - Prefer SQL changesets. A custom migration (
metabase.app-db.custom-migrations,define-migration/define-reversible-migration) must use table names, never:model/*or other application code. Application code changes later, and then the migration breaks. Decrypt encrypted columns such asmetabase_database.detailsandsetting.valuebefore reading them. - The whole
metabase.app-db.*module, custom migrations included, is exempt from the<module>.dbrule (theapp-db-namespacesgroup in.clj-kondo/config.edn). - Test with the
test-migrationsmacro (metabase.app-db.schema-migrations-test.impl) on H2 and Postgres. Add MySQL when the change touches DDL or JSON columns.
Cross module boundaries through :api
-
.clj-kondo/config/modules/config.edndeclares:api,:uses,:model-exports, and:model-importsper module. Run./bin/mage fix-modules-configafter you add or remove a namespace, a cross-module require, or a cross-module:model/Xreference. If nothing drifted, it does nothing. -
./bin/mage modules-treeprints the hierarchy. Thedev.deps-graphanddev.module-scorenamespaces show who uses a module externally. -
If a module needs something outside another module's
:api, choose the first fix that works:- Call something that is already public.
- Add the var to that module's
.core. - Invert the dependency with an event (
metabase.events).
Don't add a
:clj-kondo/ignore. -
If you change a suppression or a module escape hatch, run
./bin/mage kondo-ratchets.
Verify before handing work back
- Load each changed namespace in the REPL (
clojure-evalskill). If no REPL is available, run./bin/test-agent :only '[the.ns-test]'. - Run the narrowest tests that cover the change. Use
./bin/test-agent :module <module>to check blast radius. - Run
./bin/mage kondo-updatedon the changed files. - If a boundary moved, run
./bin/mage fix-modules-config.
Report which of these steps ran, and what each one showed.