Larder: a made-up grocery service you can run in one command.
We built Larder as an example, to show doze on a realistic monorepo: it sells nothing and delivers nothing. Nine services in Go, Python and TypeScript, on Postgres, Redis, Kafka, S3, SQS and a mail catcher. The kind of backend that usually comes with a compose file, a wiki page and a lost afternoon. Here it is one doze.hcl, and no Docker.
- Services
- 7, a gateway and a storefront
- Config
- one file, 322 lines with comments
- Containers
- none
stack = "larder" defaults { sleep = "2m" names = var.names } module postgres "postgres" { version = 18 port = 5432 role "identity" { password = "identity" } schema "identity" { owner = "identity" } # … the same for the other four services } module valkey "redis" { version = 9 port = 6379 } module kafka "kafka" { version = 4 auto_create_topics = false topic "orders.placed" { partitions = 3 } # … users.signed-up, payments.settled, payments.failed }
Split by domain, the way real backends end up.
Each team picks the language it knows. What holds it together is the contracts: HTTP between services, JSON events on Kafka, and one schema per service in a shared Postgres.
users.signed-uporders.placedpayments.settledpayments.failedGo identity, catalog, orders, payments, gatewayPy fulfilment, recommendationsTS notifications, storefront
Three commands, then open the storefront.
The first doze up downloads the engines and compiles each service, so give it a minute. After that it takes seconds.
doze, Go, bun, Python and uv. The versions Larder is tested with are pinned in .prototools (Go 1.27.3, bun 1.4.4, Python 3.14.10, uv 0.13.7), and proto install sets them all up. doze runs uv sync and bun install for you.
$ git clone https://github.com/doze-dev/doze-examples $ cd doze-examples/larder $ doze up # everything, in dependency order $ doze task seed-catalog # once: 31 products in 5 categories
Then open the storefront at storefront.larder.doze:5173. Every service has a .larder.doze name, so two stacks can both use 5432 without a clash.
Sharing ports with another stack on a machine without names set up? doze up --var names=false binds everything on 127.0.0.1; swap localhost into the addresses below.
The whole backend, read in five pieces.
Real lines from larder/doze.hcl, and why each one is there.
# One Postgres server, one database, and a role and schema per service: # each service connects as itself and can only touch its own tables. module postgres "postgres" { version = 18 port = 5432 role "orders" { password = "orders" } role "payments" { password = "payments" } … schema "orders" { owner = "orders" } schema "payments" { owner = "payments" } }
A role and a schema per service.
Running five Postgres servers is wasteful, so teams share one, usually with one login. Then any service can read any table, and that habit follows you to production. Here each service connects as itself. The schema shares the role's name, so the default search path finds its tables unqualified.
$ psql "postgres://orders:orders@postgres.larder.doze:5432/postgres" \
-c 'select * from identity.users'
ERROR: permission denied for schema identity# The topics are the contracts between services, so they are declared here # rather than created by whichever client happens to write first. module kafka "kafka" { version = 4 port = 9092 auto_create_topics = false topic "users.signed-up" { partitions = 3 } topic "orders.placed" { partitions = 3 } topic "payments.settled" { partitions = 3 } topic "payments.failed" { partitions = 3 } }
Topics are declared, not discovered.
With auto-create on, a typo in a producer quietly makes a new topic and the consumer waits forever. With auto_create_topics = false, the four topics in this file are the only ones that exist, and a misspelt one fails loudly on the first write.
task "migrate-orders" { run = "go run ./platform/cmd/migrate services/orders/migrations" inputs = ["services/orders/migrations/*.sql"] env = { DATABASE_URL = "postgres://orders:orders@…" } } app "orders" { run = "go run ./services/orders" port = 8003 ready = http("/healthz") after = [task.migrate-orders] … }
Tasks run before the apps that need them.
"Did you run the migrations?" is the first question in every broken onboarding. A task runs before the app that lists it in after, and runs again only when its .sql files change. The app counts as ready when /healthz answers, not when its process happens to exist.
defaults { sleep = "2m" … } app "payments" { run = "go run ./services/payments" after = [task.migrate-payments] sleep = "never" … } app "orders" { … sleep = "never" # it consumes Kafka: no request would ever wake it }
Requests wake apps. Consumers never sleep.
doze wakes an app when a connection arrives at its port. A Kafka consumer gets its work by asking Kafka, so no connection would ever wake it, and the comment says exactly that. Orders, payments, fulfilment and recommendations are marked "never"; notifications has no port at all, so it runs until stopped.
# Dependencies follow the lockfiles: installed again only when one changes. task "install-fulfilment" { dir = "services/fulfilment" run = "uv sync --frozen" inputs = ["pyproject.toml", "uv.lock"] } task "install-storefront" { dir = "apps/storefront" run = "bun install --frozen-lockfile" inputs = ["package.json", "bun.lock"] } # Not in the registry: fetched once, pinned in doze.lock, run from the # archive. Catches every email; its web UI is the console. service "mailpit" { download = { mac = "…/mailpit/releases/download/v1.21.8/mailpit-darwin-arm64.tar.gz" … } port = 2525 console = "http://127.0.0.1:8025" }
Installs keyed by lockfiles. Tools pinned by hash.
A teammate pulls, the Python service fails on a missing package, and someone remembers you have to run uv sync after a pull. Here the install task re-runs when uv.lock or bun.lock changes, and not otherwise. Mailpit isn't a doze module, so it comes as a download pinned in doze.lock, with its web UI as the service's console.
Poke it. Every part has somewhere to look.
Place an order and watch it travel.
Create an account in the storefront, add a few things, check out with the test card that's filled in. Nothing is real: no money moves and nothing ships. The order page follows it through orders, payments and fulfilment, over Kafka and SQS, then links the receipt.
Pay with a card ending 0002.
Check out again with 4000 0000 0000 0002. Payments declines it and publishes payments.failed; orders puts the stock back on the shelf, and an email says so.
Open Mailpit.
Welcome, order and payment-failed emails all land in Mailpit instead of anyone's inbox. It is the mailpit service's console: open it from its row in the doze app.
Find the receipt in S3.
A packed order leaves a receipt in the receipts bucket, after its pick list went through SQS. The doze AWS console at aws.larder.doze:4566/_console shows both, and every call that put them there.
$ aws --endpoint-url \
http://aws.larder.doze:4566 \
s3 ls s3://receipts/receipts/Watch the events go by.
The doze Kafka console shows the four topics and each message as it lands. Any Kafka client works too.
$ kcat -b kafka.larder.doze:9092 \
-C -t orders.placed -eLeave it alone for two minutes.
With nothing connected, the storefront and the gateway go to sleep, then identity, which only the gateway uses. Reload the storefront: one request wakes them in order. Catalog stays awake, because orders and recommendations hold it.
$ doze statusThe storefront, the map and the bucket.
orders.placed and cached in Redis.Nothing in Larder's code knows doze is there.
Every service reads plain environment variables. In doze.hcl they are references like module.kafka.address and app.catalog.url; in a real deployment the same variables would point at the real thing.
- Three languages, wired the same way. Go, Python and TypeScript services all get their addresses from one file.
- References are edges. doze starts things in dependency order and draws the map from them; nobody maintains a diagram.
- Real engines, no containers. Postgres 18, Valkey 9, doze Kafka and doze AWS run as native processes you can attach a debugger to.
- Asleep where it's safe, awake where it isn't. Request-driven apps sleep after two minutes; consumers say
"never"and say why. - Setup that keeps itself current. Migrations and installs re-run when their inputs change, so a pull never needs a ritual.
- Edit and save. The Go apps have
watchset, so saving a.gofile restarts that service; Vite reloads the storefront itself. - Pinned, so it runs the same next month. Engines, modules and the mailpit download are all pinned in the committed doze.lock.
Larder is the only one today.
These are planned, not built. Want one sooner, or a different stack? Say so on GitHub.
Rails + Sidekiq
A Rails app with Sidekiq jobs on Valkey and Postgres: the worker that has no port, done right.
Django
Django on Postgres with Celery, S3 uploads through doze AWS, and email to Mailpit.
[A serverless stack]
[Lambda, DynamoDB and EventBridge on doze AWS, with no app server at all.]