Layer boundaries
The first decision on a blank project is how to organise the layers. Each layer needs a clear job, and models should only know about the layer directly below them. Without that constraint, models start doing too much and the project becomes hard to reason about as it grows.
Schema isolation: one macro, three environments
The most useful thing in the repo is a single Jinja macro that controls schema naming across all three environments. In development, each engineer gets their own isolated namespace, so nobody steps on anyone else's work. In CI, schemas are scoped to the PR number. In production, schemas are clean with no prefix at all.
dev_tanay_staging
dev_tanay_marts
pr_123_staging
pr_123_marts
staging
marts
The useful part is what it means in practice. A new engineer doesn't configure anything: they run dbt against their dev target and their schemas appear namespaced correctly. CI gets its own isolated space per PR, so concurrent CI runs never conflict, and the cleanup macro drops everything scoped to a PR number once the run finishes, keeping the CI database clean.
A CI pipeline that only runs what changed
Running everything on every PR is what makes CI slow enough that engineers stop waiting for it. The first thing our pipeline does is check whether any dbt files actually changed. If nothing changed, every downstream job is skipped: a Terraform-only PR doesn't wait for a dbt build, and the branch protection check still passes.
Branch protection requires a passing check, but if dbt didn't change, that check should still pass. A sentinel job handles this: it passes whether the build ran or was skipped, so non-dbt PRs are never blocked waiting on a check that won't run.
Deployment: merge to ship
Deployment triggers automatically on merge to main when dbt files changed. There's one non-obvious design decision: full-refresh runs are controlled by the PR title. If the title contains a specific flag, the deploy runs with full-refresh; otherwise it's incremental. There's no separate workflow and no manual trigger, just a naming convention the pipeline reads before deciding how to build.
This matters because full refreshes on large incremental models are expensive and occasionally necessary. Having it controlled by the PR title means the decision is visible in the git history, reviewable in the PR, and doesn't require anyone to remember to flip a configuration somewhere.
Code style: lowercase everything
The linting config enforces a small set of rules that apply to every SQL file in the repo. The most important one is also the simplest: everything lowercase. Keywords, identifiers, functions, literals, all of it. Not because lowercase is objectively correct, but because consistency removes a whole category of review comment. A PR that touches SQL should be reviewed for its logic, not its formatting.
What a PR has to prove
Every PR uses a shared template with the same sections in the same order: what changed, why, how it was tested, and a security check confirming no credentials were hardcoded. The template is short enough that it doesn't slow anyone down, but specific enough to catch what usually gets missed, particularly the testing evidence.
The combination of isolated schemas, diff-based CI, and a consistent PR template means review conversations stay focused on modelling decisions. Nobody's arguing about formatting, chasing missing tests, or asking "did you check this against prod?" in review comments, because all of that is handled structurally before the review even starts.