dozeGitHubGet doze
Example project · Larder

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
larder/doze.hclthe top, shortened
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
}
How it fits together

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.

Requestsbrowser → storefront → gateway → the service that owns the answer
browserstorefront.larder.doze:5173
storefrontTS
Vite + React; proxies /api to the gateway
gatewayGo
sessions, rate limits in Redis, routing
identityGo
users, sign-in, sessions
catalogGo
products, stock, search; images in S3
ordersGo
basket, checkout; reserves stock with catalog
fulfilmentPy
pick lists on SQS, receipts in S3
recommendationsPy
"goes well with", cached in Redis
Events, on Kafkawho publishes · topic · who listens
identity Go
users.signed-up
notifications TSwelcome email
orders Go
orders.placed
payments Gocharges the cardrecommendations Pycounts what's bought togethernotifications TS
payments Go
payments.settled
orders Gomarks it paidfulfilment Pypick list on SQS, then packed, receipt in S3
payments Go
payments.failed
orders Goputs the stock backnotifications TS
Shared, declared oncenative processes, asleep until something connects
postgresPostgres 18 on :5432, a role and schema per service
redisValkey 9 on :6379: rate limits, idempotency keys, cached suggestions
kafkadoze Kafka on :9092, four declared topics
awsdoze AWS on :4566: buckets product-images and receipts, queue pick-lists with a DLQ
mailpitSMTP on :2525, catches every email; web UI as its console

Go identity, catalog, orders, payments, gatewayPy fulfilment, recommendationsTS notifications, storefront

Clone and run

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.

You need

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.

terminal
$ 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.

Inside doze.hcl

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" }
}
01 · Ownership

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 }
}
02 · Contracts

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]
  …
}
03 · Order

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
}
04 · Sleep

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"
}
05 · Setup, done once

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.

Things to try

Poke it. Every part has somewhere to look.

01 · The happy path

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.

placedpaidpackedout for delivery
02 · The sad path

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.

03 · Email

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.

04 · doze AWS

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/
05 · doze Kafka

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 -e
06 · The point of doze

Leave 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 status
What you'll see

The storefront, the map and the bucket.

The storefront's basket. "Goes well with" comes from recommendations, counted from orders.placed and cached in Redis.
The doze app draws the map from the references in doze.hcl.
The doze AWS console: the receipts fulfilment wrote.
What it shows about doze

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 watch set, so saving a .go file 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.
More examples coming

Larder is the only one today.

These are planned, not built. Want one sooner, or a different stack? Say so on GitHub.

Suggest an example
[planned]

Rails + Sidekiq

A Rails app with Sidekiq jobs on Valkey and Postgres: the worker that has no port, done right.

[planned]

Django

Django on Postgres with Celery, S3 uploads through doze AWS, and email to Mailpit.

[planned]

[A serverless stack]

[Lambda, DynamoDB and EventBridge on doze AWS, with no app server at all.]