Why architecture matters here
Builds are programs whether tools admit it or not; sbt's architecture is what admitting it buys. The two-phase model — settings evaluated once at load, tasks evaluated per run — is the core discipline. Settings (a project's scalacOptions, its dependencies, its version) form a graph resolved to fixed values when the build loads: deterministic, inspectable (inspect, show), and cheap thereafter. Tasks (compile, test, publish) form a lazy graph where declaring foo.value inside a task creates a dependency edge; the engine derives execution order, deduplicates shared work (ten tasks needing compile trigger it once), and parallelizes everything the edges allow. Understanding which phase a problem lives in — 'my setting is wrong' is a load-time question; 'my task is slow/stale' is a run-time question — is half of sbt fluency.
The scope axes are what let one build description serve many contexts without duplication. The same compile key means different things in Compile and Test configurations; a plugin can set a default globally and a project override it locally; fallback (task → config → project → ThisBuild → Global) resolves the most specific value. This is also why sbt errors confuse: a value 'not working' is usually set in a different scope than the one being read, and the fix is inspect, not more assignments. Teams that internalize scope resolution stop cargo-culting settings and start writing small, comprehensible builds.
And incrementality is the economic core: Zinc's API-hashing means a large service's edit-compile-test loop is seconds, not the minutes a clean build costs — but only while invalidation stays sound. Macros that read files, code generation outside sbt's knowledge, and tasks with undeclared inputs all corrupt the model, and the team's response ('just clean') silently converts an incremental build back into a batch one. The operational discipline — declare inputs/outputs, keep side effects inside tasks, watch for under-compilation bugs after macro-heavy changes — is what keeps the seconds-not-minutes promise true for years.
The architecture: every piece explained
Top row: evaluation. The build.sbt DSL compiles (build files are Scala compiled by sbt's meta-build — project/ is itself an sbt project, recursively) into settings expressions: key := value, key += element, key := { (dep1.value, dep2.value) => ... }. At load, the settings graph is topologically evaluated into an immutable Settings map. Invoking a command evaluates the task graph: each task's .value references were lifted (by macro) into declared dependencies, so the engine sees a DAG, runs independent nodes in parallel on its execution pool, and caches per-invocation results (a task runs at most once per command execution). Scopes address everything: (project, configuration, task) triples with Zero as the wildcard, and delegation rules define fallback order — the inspect command prints exactly which scoped definition won and from where.
Middle row: the engines and extensions. Zinc keeps an analysis store per project: for every source file, the API hash of every class it defines and the set of names/classes it uses. On change, Zinc diffs APIs — a body-only change invalidates nothing downstream; a signature change invalidates precisely the users of that signature — then compiles the invalidated set, possibly iterating as new API changes cascade. Coursier resolves the dependency graph (eviction by version scheme, exclusions, scala-version cross-building) and fetches into a machine-wide cache. Auto-plugins are the extension model: a plugin declares requires (activation dependencies) and trigger, contributes projectSettings/buildSettings, and defines its own keys; ordering rules mean later plugins can see and amend earlier ones' settings — which is also why plugin conflicts are ordering problems. The shell and server: an interactive session keeps the JVM warm (JIT'd Zinc, loaded settings) so iteration is fast; the sbt server speaks BSP, letting IDEs (Metals, IntelliJ) request compilation from the same build definition instead of re-implementing it.
Bottom rows: structure and CI. Multi-project builds: lazy val core = project, lazy val api = project.dependsOn(core) wires classpaths (with configuration mappings like "compile->compile;test->test"); aggregate makes commands fan out; ThisBuild-scoped settings provide build-wide defaults. CI usage is its own discipline: cache the Coursier cache and Zinc analysis between runs, pin plugin and sbt versions (project/build.properties), consider dependency lockfiles for reproducibility, and run batch-mode with CI-appropriate memory flags. The ops strip: keep load time low (heavy computation belongs in tasks, not settings), respect task caching contracts (declare file inputs/outputs for custom tasks), and tune the one JVM (heap + metaspace) that hosts compiler, resolver, and your tests.
End-to-end flow
A day in a 40-module service monorepo shows the machinery. Morning: an engineer opens the sbt shell (12s load — the team keeps it under 15 by policy) and runs ~api/testQuick. They edit a method body in core: Zinc sees the API hash unchanged, recompiles one file in 800ms, and testQuick re-runs only tests transitively affected — the loop is under three seconds, and the watch mode re-fires it on every save. Then they change a public signature: Zinc's diff invalidates the 17 downstream files that referenced it across three modules, compiles them (6s), and two tests fail — the exact blast radius of the change, computed rather than guessed.
Midday, the build itself changes: a new module and a plugin bump. The settings-graph nature shows: lazy val billing = project.dependsOn(core % "compile->compile;test->test") wires both main and test classpaths; a ThisBuild / scalacOptions += "-Wunused:imports" lands once and every module inherits it; one legacy module opts out with a project-scoped remove. A teammate hits the classic scope confusion — their Test / envVars isn't visible in IntegrationTest — and inspect IntegrationTest / envVars shows the delegation chain skipping their definition; the fix is setting it in the right scope, not repeating it in five places.
CI tells the operational story. The pipeline restores three caches (Coursier, Zinc analysis per module, compiled build meta-project); a typical PR build compiles only changed modules' invalidated files and finishes in 4 minutes against the 22-minute clean-build baseline. Reproducibility is enforced: sbt version pinned in build.properties, plugins pinned, dependency versions via a single Dependencies.scala, and a weekly scheduled clean build guards against cache-rot illusions. The quarter's one build incident is instructive: a custom code-generation task wrote sources but declared no outputs, so Zinc sometimes missed regenerations — symptoms were 'works after clean' reports. The fix was making the task declare inputs/outputs (sbt's tracked-file API), restoring soundness — and the team added the review rule that any task touching files must declare them, because incremental correctness is a contract every custom task can break.