Dev workflow
The README’s Quick Start gets you to a running app. This is everything past that — how to stay productive while writing code.
The whole loop is driven from sbt. Two ways to drive it.
sbt shell vs sbt --client
Section titled “sbt shell vs sbt --client”Long-lived shell — open sbt, leave it running:
$ sbt> ~reStartThis is the default for active development. Compilation is incremental, the JVM stays warm, classpath resolution is cached. First command takes 20–30 seconds; everything afterwards is fast.
sbt --client — connect to the same running server from another terminal:
sbt --client compilesbt --client "testOnly madrileno.auction.services.AuctionServiceSpec"sbt --client scalafmtAllUse this when you have an sbt shell open in one terminal and want to run a one-shot command in another. Falls back to starting a server if none exists, so the first invocation is slow; everything after is sub-second. AGENTS.md tells the assistant to use --client so it doesn’t spin up a new sbt every time.
Plain sbt <command> — starts sbt, runs the command, exits. Slow (full classpath resolution on every invocation). Reserve for shell scripts and CI.
Watch loops
Section titled “Watch loops”~<task> runs the task whenever a watched source changes:
| Command | What it does |
|---|---|
~reStart |
Recompile, kill the old app, start a new one. The default dev loop. |
~test |
Recompile, run the full test suite. Useful when refactoring across modules. |
~testOnly Foo |
Like ~test but scoped to one spec (or a glob: *AuctionService*). |
~compile |
Just type-check. Fast feedback when you don’t care about test outcomes yet. |
Watch is on Scala/SBT/HOCON files only — not .env, not docker-compose state. Restart sbt for those.
reStart (no ~) is a one-shot run. Use it when you want to launch the app once for a manual smoke test without the rebuild-on-save behavior.
Running the app without a watch loop
Section titled “Running the app without a watch loop”Watch loops, sbt run, and a bare sbt shell never return, so a script, a CI smoke test, or anything else that needs its shell back starts the app through the launcher instead:
./scripts/app.scala start # sbt --client compile, apply pending migrations, run from the cached classpath, return once /v1/health-check answers./scripts/app.scala restart # after a code change, or a .env change./scripts/app.scala status # exit 0 when it is up./scripts/app.scala logs # the end of the application log./scripts/app.scala stopstart compiles through the sbt client (a no-op well under a second when nothing changed), applies pending migrations (about a second when there are none), runs Main from the classpath that compile caches in target/console-classpath, and waits up to 60 seconds for the health check. It exits 1 with the last log lines when the app dies or never answers, and in the second case stops the half-started process, so a failed start leaves nothing running. The app reads .env at every start, unlike the sbt server, which caches it. The pid lives in target/app/app.pid; --help lists the options, scripts.md the details.
start refuses when something answers the health check that it did not start, typically a ~reStart loop in a terminal, because only one app can bind the port. Use that app, or stop it where it was started.
The dev setup leaves three kinds of background processes. Each has its own stop command:
| Process | Started by | Stopped by |
|---|---|---|
| App JVM | ./scripts/app.scala start, or a ~reStart loop |
./scripts/app.scala stop, or reStop in that sbt shell |
| sbt server | the first sbt --client call |
sbt --client shutdown (also stops an app started by reStart, not one started by the launcher) |
| Postgres | docker compose up -d |
docker compose stop (docker compose down -v also wipes its data) |
| Mailpit, OpenObserve, Silo (optional) | docker compose --profile full up -d |
docker compose stop mailpit openobserve silo |
sbt --client reStart and reStop still work in an sbt shell. How a coding assistant is expected to use it is in ai-assisted-dev.md.
Running tests
Section titled “Running tests”> test // full suite (~7s on a warm JVM)> testOnly madrileno.auction.services.AuctionServiceSpec> testOnly *Auction* // glob matches multiple specs> testOnly *AuctionServiceSpec -- -z "closes auction" // one test by name substring> testOnly *AuctionServiceSpec -- -t "exact test name" // exact match> testQuick // re-run only failed/never-run since last `test`The -- separates sbt args from scalatest args. -z is substring match on the test name; -t is exact match. Useful when you’ve broken one test and don’t want to wait for the whole suite.
Baklava generation is a side effect of the test run — the OpenAPI spec at /swagger and the oRPC contract are regenerated from *RouterSpec files. Under sbt 2, test is incremental (testQuick-like) and its results are cached in a global store (~/.cache/sbt, so it survives a target/ wipe and a fresh sbt server); on a warm cache it skips every suite whose code and dependencies haven’t changed. That is fine for the contract: each run merges the calls of the suites that ran into a persistent store (target/baklava/store/, one directory per suite) and regenerates the output from the whole store, so test, testOnly <pattern> and the incremental run after a one-line change all leave the spec complete. Two things to know: a filtered run (testOnly *AuctionRouterSpec -- -z DELETE) replaces that suite’s entries with the subset that ran, so run the suite unfiltered afterwards; and when the store is empty — a fresh checkout on a machine with a warm ~/.cache/sbt, or right after baklavaClean — test may run nothing and generate nothing (Baklava prints a message), so run testFull once. testFull is the uncached, run-everything task and stays the CI gate.
Tests use Testcontainers for Postgres / Mailpit / Silo — they spin up their own containers, so the docker-compose dev stack and the test run don’t share state. Telemetry is stubbed with noop tracer/meter in tests; there’s no OpenObserve container.
Code quality
Section titled “Code quality”Run before every commit (the assistant does this automatically per AGENTS.md):
> scalafmtAll // format every Scala file> scalafixAll // run scalafix rules (organize imports, etc.)Both are idempotent and fast. scalafmtCheckAll is the read-only version — fails if anything is unformatted. CI runs the check variant.
The build defines verifyAll as a CI proxy:
> verifyAllThis runs scalafmtSbtCheck + scalafmtCheckAll + compile + testFull + Test/verifyErrorCodes in sequence — the same gates CI runs. If verifyAll passes locally, your push will pass CI (modulo flaky tests and the coverage floor below).
CI runs the suite under coverage instrumentation and fails when statement coverage drops below coverageMinimumStmtTotal in build.sbt. The floor is set from a freshly initialised project, not from the template: init-project removes the auction demo and its specs, which costs a few points, and the init job in the script-tests workflow runs the instrumented suite so a floor the fresh project cannot clear fails CI here first. verifyAll leaves that out because an instrumented build recompiles everything, and so does the next plain build. The local equivalent is:
> coverageAllThis runs testFull on an instrumented build, writes HTML and XML reports to target/out/jvm/<scala>/<project>/scoverage-report/, checks the floor, and turns instrumentation back off. coverage flips a session-wide setting on the shared sbt server, so if you run the steps by hand, finish with coverageOff or a ~reStart loop keeps building instrumented classes. Raise the floor as coverage grows.
Compiler strictness
Section titled “Compiler strictness”The build uses sbt-tpolecat. Default mode is ci: -Werror on, full warnings. Local and CI run the same flags, so warnings that fail CI also fail your local ~reStart. This catches issues early — the alternative (“relaxed local, strict CI”) regularly produced PRs that compiled on the author’s machine and failed in CI on warnings they never saw.
If the constant -Werror interruptions during exploratory work outweigh catching issues early, opt in to relaxed mode by uncommenting SBT_TPOLECAT_DEV=true in your .env. Remember to bounce sbt — sbt-dotenv reads .env only at startup, not on ~reStart. And keep an eye on CI: anything you missed locally still fails there.
To temporarily reproduce CI strictness when you’re in relaxed mode and want a final sanity-check before pushing:
SBT_TPOLECAT_DEV= sbt --client compileDatabase tasks
Section titled “Database tasks”Apply pending migrations:
> runMain madrileno.main.MigrateMainThat’s the app’s own IOApp — the same one the Docker image ships as bin/migrate-main for production deploys (deployment.md) — so it reads application.conf with .env already injected (PG_HOST / PG_PORT / PG_DATABASE / PG_USER / PG_PASSWORD). It takes a subcommand; migrate is the default:
> runMain madrileno.main.MigrateMain info // list applied / pending migrations> runMain madrileno.main.MigrateMain validate // verify checksums match the files> runMain madrileno.main.MigrateMain clean // DROP every table; only on a dev DBA single env-correct entry point — there’s no flyway-sbt plugin to fall back to (its tasks read sys.env at build-load time, before sbt-dotenv injects .env, so they couldn’t see your PG_* anyway).
Seed dev data:
> runMain madrileno.main.SeedMainSeedMain is an IOApp mirroring MigrateMain’s shape. Refuses to run unless app.environment == Environment.Dev. Ships with 3 demo users (demo@, alice@, bob@ at example.com) each with a matching UserAuth row for Provider.Dev, so POST /v1/auth/dev with body {"email": "demo@example.com"} logs you in as the seeded UUID immediately — no auto-creation needed. The seed body is at the bottom of SeedMain.scala — idempotent (find-or-create on stable UUIDs, with reconciliation for pre-existing UserAuth rows pointing at different user_ids). Edit it to add your own records.
Run a migration after:
- Adding a migration under
src/main/resources/db/migration/. docker compose down -v(which wipes the volume).
The app does not auto-migrate on startup. Migrations are an explicit deploy step; running them by accident on prod has bitten people.
IDE setup
Section titled “IDE setup”- Metals (VS Code) —
Build: Import buildonce after cloning. Metals reads the bloop config sbt generates. After adding a dependency inbuild.sbt, runBuild: Import buildagain (or use theimport-buildMCP tool if you’re driving via the assistant). - IntelliJ — File → Open → select the project root → “Open as sbt project”. Let it sync. Re-sync after dependency changes.
Either way, sbt itself doesn’t need to be running for the IDE to work — they read the build separately.
Common stuck states
Section titled “Common stuck states”~reStart doesn’t see a file change.
Sbt’s file watcher occasionally drops events on case-only renames or rapid saves. Hit Enter in the sbt shell to force a re-trigger; if that fails, exit (exit or Ctrl-D) and re-launch.
Compile errors that don’t go away after fixing the source.
Stale incremental compile state. Run clean then compile. If it still fails, kill any running sbt server (pkill -f sbt-launch or remove ~/.sbt/1.0/server/) and start fresh.
.env change not picked up.
The dotenv plugin reads .env once at JVM startup. Restart sbt.
sbt or compilation dies with OutOfMemoryError: Java heap space.
The sbt JVM’s heap lives in .jvmopts (committed) — bump -Xmx there and restart sbt. Setting -Xmx / SBT_OPTS in .env does nothing: sbt-dotenv injects .env into the already-running JVM, long after -Xmx is fixed at launch.
A test that passes locally fails in CI on a warning.
You’ve opted into SBT_TPOLECAT_DEV=true and the local build is in relaxed mode. Reproduce CI strictness with SBT_TPOLECAT_DEV= sbt --client compile, or comment out SBT_TPOLECAT_DEV in your .env to revert to the strict default.
A migration fails with a checksum mismatch.
You edited a migration that’s already been applied. Either roll back the file change or runMain madrileno.main.MigrateMain clean your dev DB and re-migrate (only on dev — never on shared environments).
OpenObserve traces tab is empty.
Streams are created on first ingest; hit the app a few times, refresh, give it a moment. If it’s still empty, check that .env has OTEL_SDK_DISABLED=false (the stock value is true, for the Postgres-only stack) and the OTEL_EXPORTER_OTLP_* endpoints.
/v1/auth/firebase returns 503 (provider-unavailable).
Firebase auth is only enabled when FIREBASE_PROJECT_ID is set; the stock .env leaves it blank, so the app boots but Firebase login is off. Set it to your Firebase project id — or, in dev, use POST /v1/auth/dev with {"email":"you@example.com"} (see below), which logs you in (creating the user) with no provider config at all.
/v1/auth/dev returns 404 (unknown-provider).
Dev login is explicit opt-in, gated on DEV_AUTH_ENABLED=true (default false in application.conf; the stock .env sets it). Set it to true in your local .env if you need the route active. Never enable in deployments that face real users — DevAuthVerifier mints a session for any email-shaped string, no credential check.
One-shot recipes
Section titled “One-shot recipes”# full clean validation — `clean` wipes target/ but NOT sbt 2's global cache (~/.cache/sbt),# so use testFull (not test) to actually run everythingsbt --client clean compile testFull
# run only auction-related specssbt --client "testOnly *Auction*"
# run a single test by substringsbt --client "testOnly *AuctionServiceSpec -- -z 'closes auction'"
# regenerate OpenAPI without running every testsbt --client "testOnly *RouterSpec"
# wipe dev DB and start overdocker compose down -v && docker compose up -d && sleep 2 && sbt --client "runMain madrileno.main.MigrateMain"Adding a dependency
Section titled “Adding a dependency”- Edit
build.sbt. - In Metals:
import-build. In IntelliJ: re-sync. In sbt: the running shell picks upbuild.sbtchanges on the next command (it’ll print[info] build.sbt has been changed; loading new project ...). - Compile to confirm resolution succeeds.
- The
find-depandinspectMCP tools (if you’re driving via the assistant) are quicker than browsing Maven Central manually.
Where to look next
Section titled “Where to look next”- configuration.md —
.envmechanics, why restarting sbt picks up changes. - database.md — what migrations look like, how to add one.
- observability.md — OpenObserve URLs, what each tab shows.
- The root
README.md— first-touch Quick Start, dev-stack ports, gotchas.