Skip to content
Development
Skill

/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

BOOST
From plugin
metabase
50k34 skills11 agents23 commands
Install
$ npx -y skills add metabase/metabase --skill backend-module-conventions --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/backend-module-conventions

Context preview

The summary Claude sees to decide when to auto-load this skill.

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

SKILL.md

backend-module-conventions.SKILL.md
name: backend-module-conventions
description: 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` | `defsetting`s. | | `<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/defn` that 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 `db` function that grows an `if` belongs somewhere else.
  • Get cross-module data through the other module's `.core`, not its `db`. A few modules list their `db` in `:api` as 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:

1. 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. 2. Never mark a literal. A literal cannot carry a request value. 3. Never mark a value in an **identifier slot** (`:select`, `:from`, joins, aliases, and similar). `value-guard` throws `::marker-outside-value-slot` at compile time. In `:order-by` and `:group-by` a marker compiles to `ORDER BY ?`, which is useless but does not throw. Don't write one there either. 4. Leave a possibly-empty collection unmarked. Toucan rewrites `[:in col []]` to `false` before the marker step. 5. Leave values that `t2/update!` *writes* unmarked. Model hooks such as encryption must still see them. 6. 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.

  • Starting with v60, each change is its own file at `resources/migrations/<NNN>/<yyyyMMdd>_<snake_name>.yaml`. `NNN` is the earliest release the change ships in. New work goes in the newest directory. Don't add to `001_update_migrations.yaml`; its footer describes the legacy format.
  • Changeset IDs are `v<NN>.<unique suffix>`, where `NN` matches the directory. The suffix can be a timestamp from `(dev/migration-timestamp)` or any unique string. 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 `DATABASECHANGELOG` forever; 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 does `DELETE 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
Read more
Ships withmetabase

Metabase is the easy, open-source way for everyone in your company to ask questions and learn from data.

Get the whole plugin

Other skills on metabase.