Getting Started

Run the mako NB straight-through-processing demo in about five minutes: submit a UTILMD 55001 Anmeldung and receive the 55002 confirmation back.

On this page 15 sections

This guide runs the full NB STP demo stack locally and walks through the complete end-to-end flow: UTILMD 55001 → automatic NB decision → UTILMD 55002.

What you're running

ServicePortRole
postgres5432PostgreSQL — one database per service
webhook8000Demo ERP event receiver (Python, in-memory)
marktd8180Market Data Hub — MaLo/MeLo/NeLo/TR, VersorgungsStatus, durable fan-out, event_log replay
processd8580NB STP auto-responder — mako-pruefung (E_0622/G_0011), LF answers 55007/55010 inside their per-PID Frist
makod8080EDIFACT process engine — GPKE/WiM/GeLi Gas, in-memory
sequenceDiagram
    participant LF as LF 4012345000023
    participant makod as makod :8080
    participant marktd as marktd :8180
    participant processd as processd :8580
    participant webhook as webhook :8000

    LF->>makod: POST /edifact<br/>UTILMD 55001
    makod-->>webhook: APERAK BGM+312<br/>(45-min deadline, auto)
    makod->>marktd: de.mako.process.initiated<br/>HMAC POST /api/v1/events (demo override)
    marktd->>processd: de.mako.process.initiated<br/>HMAC POST /webhook
    marktd-->>webhook: de.mako.process.initiated<br/>(ERP subscription)
    processd->>marktd: GET /api/v1/versorgung/{malo_id}
    processd->>marktd: GET /api/v1/malos/{malo_id}/grid
    processd->>marktd: GET /api/v1/partners/{lf_mp_id}
    Note over processd: `mako-pruefung`: E_0622 → Accept (A51)
    processd->>makod: gpke.lieferbeginn.bestaetigen
    makod-->>webhook: UTILMD 55002 Bestätigung

Total time once the images are built: ~5 minutes. The first build takes longer — see Step 1, and rather longer again for the second demo, whose two services come out of the full builder stage.


Prerequisites

ToolVersionInstall
Docker24+ with Compose v2https://docs.docker.com/get-docker/
curlanyOS package manager
jqanyOS package manager

Step 1 — Clone and build

git clone https://github.com/hupe1980/mako.git
cd mako

just build-demo

just build-demo is the three builds the compose file needs, tagged the way it expects them:

docker build --target runtime          -t makod:dev     .
docker build --target marktd-runtime   -t marktd:dev    .
docker build --target processd-runtime -t processd:dev  .

Not docker buildx bake — docker-bake.hcl is the CI push path (push-by-digest), so it fails on the default docker driver and tags ghcr.io/hupe1980/mako-* rather than the :dev images the compose file starts.

The processd-runtime stage builds with --features integrated (includes both the NB mako-pruefung and the LF answer modules).

These three come from a builder stage carrying only their dependencies — no Iceberg/DataFusion — so a cold build is a few minutes and a warm one under a minute.


Step 2 — Start the demo stack

cd demos/nb-stp
docker compose up -d
docker compose ps   # wait until all containers are running

Expected:

NAME                 IMAGE              STATUS         PORTS
nb-stp-postgres-1    postgres:17-alpine Up (healthy)   5432/tcp
nb-stp-webhook-1     python:3.12-alpine Up             0.0.0.0:8000->8000/tcp
nb-stp-marktd-1      marktd:dev         Up             0.0.0.0:8180->8180/tcp
nb-stp-processd-1    processd:dev       Up             0.0.0.0:8580->8580/tcp
nb-stp-makod-1       makod:dev          Up             0.0.0.0:8080->8080/tcp

What happens at startup:
processd self-registers its fan-out subscription with marktd on startup — no manual subscription curl required. Both marktd and processd run SQLx migrations automatically on first boot (databases are created by init-db.sh).


Step 3 — Verify health

curl -s http://localhost:8080/health | jq .
# → {"status":"ok","instance_id":"..."}

curl -s http://localhost:8180/health | jq .
# → {"status":"ok"}

curl -s http://localhost:8580/health/ready
# → 200 OK

Step 4 — Seed master data

processd's mako-pruefung needs three items in marktd to reach an Accept decision. Every path below is relative to demos/nb-stp, the directory Step 2 changed into.

4a — Price sheet

curl -s -X PUT http://localhost:8180/api/v1/preisblaetter/9900357000004 \
  -H "Content-Type: application/json" \
  --data-binary @fixtures/preisblatt-nb.json \
  -w "\nHTTP %{http_code}\n"
# → HTTP 204

4b — MaLo + MaLo grid record

MALO_ID=51238696012

# MaLo (NB=9900357000004, no active LF)
curl -s -X PUT "http://localhost:8180/api/v1/malos/$MALO_ID" \
  -H "Content-Type: application/json" \
  --data-binary "$(jq --arg m "$MALO_ID" '.data.marktlokationsId=$m' fixtures/malo-nb.json)" \
  -w "\nHTTP %{http_code}\n"
# → HTTP 201

# MaLo grid record — without it the Anmeldung escalates instead of being answered
curl -s -X PUT "http://localhost:8180/api/v1/malos/$MALO_ID/grid" \
  -H "Content-Type: application/json" \
  -d '{"nb_mp_id":"9900357000004","bilanzierungsgebiet":"11YN0------0STXG","netzgebiet":"DEMO-NZ-001","sparte":"STROM","source":"manual"}' \
  -w "\nHTTP %{http_code}\n"
# → HTTP 204

4c — LF trading partner (E_0622 Prüfschritt 60)

# Register in marktd partner directory
curl -s -X PUT http://localhost:8180/api/v1/partners/4012345000023 \
  -H "Content-Type: application/json" \
  -d '{"display_name":"Demo LF","marktrolle":"LF","sparte":"STROM","makoadresse":["https://as4.example.com/as4/receive"]}' \
  -w "\nHTTP %{http_code}\n"
# → HTTP 200

# Register in makod for EDIFACT routing
curl -s -X PUT http://localhost:8080/admin/partners/4012345000023 \
  -H "Authorization: Bearer demo-secret-change-me" \
  -H "Content-Type: application/json" \
  --data-binary @fixtures/partner-lf.json | jq '.'

Two registries, two shapes. marktd holds the partner's BO4E Geschaeftspartner and its AS4 endpoints (makoadresse); makod holds the PARTIN record with its communication channels, roles and contacts. The bodies are not interchangeable, and both refuse a field they do not have — so a body sent to the wrong one is a 422 naming the field rather than a 200 with the values dropped.


Step 5 — Submit a UTILMD 55001

curl -s -X POST http://localhost:8080/edifact \
  -H "Authorization: Bearer demo-secret-change-me" \
  -H "Content-Type: text/plain; charset=utf-8" \
  --data-binary @fixtures/utilmd-55001.edi | jq .

Expected response:

{
  "accepted": 1,
  "rejected": 0,
  "messages": [{
    "message_type": "UTILMD",
    "pid": 55001,
    "workflow": "gpke-supplier-change",
    "status": "routed",
    "process_id": "...",
    "malo_id": "51238696012"
  }]
}

Step 6 — Automatic NB decision

processd receives the de.mako.process.initiated event from marktd's fan-out and walks the E_0622 Entscheidungsbaum (mako-pruefung) against the master data seeded in Step 4.

# Check the decision log
curl -s http://localhost:8580/api/v1/decisions | jq '.[] | {
  malo_id, decision, antwortcode, decided_at
}'
# → {"malo_id":"51238696012","decision":"Accept","antwortcode":"A51","decided_at":"..."}

With [nb] auto_accept = true (set in the demo's processd.toml), Accept automatically dispatches gpke.lieferbeginn.bestaetigen to makod, which enqueues the outbound UTILMD 55002 (Bestätigung Anmeldung verb. MaLo):

curl -s http://localhost:8000/events | jq '[.[] |
  select(.body.makomessagetype=="UTILMD")
  | {edifact: .body.data.edifact}
]'

Step 7 — Run the automated smoke test

smoke.sh runs all of the above and asserts every step:

bash smoke.sh

MARKTD_URL and WEBHOOK_URL default to what docker-compose.yml publishes. Set either to the empty string to exercise makod alone.

Output ends with:

✓ processd NB auto-responder dispatched bestaetigen → UTILMD 55002 already arrived
✓ POST /api/v1/commands → HTTP 409 (duplicate bestaetigen correctly rejected — AntwortGesendet guard confirmed)
✓ UTILMD 55002 was already verified in step 6c (auto-responder path)
All smoke tests passed.
  Wechselprozess auto-responder: ENABLED
  Flow: UTILMD 55001 → makod → marktd ingest → validate → bestaetigen → UTILMD 55002

The HTTP 409 is the workflow state guard, not idempotency: processd already moved the workflow to AntwortGesendet, so a second bestaetigen is an out-of-order command and is rejected as invalid_state.


Step 8 — Explore the APIs

InterfaceURL
makod Swagger UIhttp://localhost:8080/api/v1/docs/
makod MCP serverhttp://localhost:8080/mcp
marktd Swagger UIhttp://localhost:8180/api/v1/docs/
marktd DLQ adminhttp://localhost:8180/admin/fanout/dlq
marktd metricshttp://localhost:8180/metrics
processd decisionshttp://localhost:8580/api/v1/decisions
processd approval queuehttp://localhost:8580/api/v1/queue
ERP webhook event loghttp://localhost:8000/events

Stop and clean up

docker compose down      # keep database volumes
docker compose down -v   # destroy all volumes (full reset)

The second demo — EEG feed-in settlement

demos/eeg-billing runs the other half of the platform: no EDIFACT at all, but the settlement path an NB owes an Anlagenbetreiber. einsd holds the plant register and computes the § 21 EEG 2023 Einspeisevergütung; edmd holds the quarter-hour Einspeisemenge it settles against.

ServicePortRole
marktd8180Market Data Hub — the MaLo the plant feeds into
edmd8380Energy Data Management — quarter-hour readings, billing periods
einsd9180EEG/KWKG settlement — plant register, monthly Vergütung
# from the repo root
just build-demo-eeg   # marktd, edmd, einsd

cd demos/eeg-billing
docker compose up -d
bash smoke.sh

The recipe rebuilds marktd too, which is a cache hit after the first demo. edmd and einsd are the expensive half of this page: both copy from the full builder stage, which compiles every service in the workspace in one cargo build — Iceberg/DataFusion included. Budget 20–45 minutes cold, a few minutes on a warm BuildKit cache.

It registers the Anlagenbetreiber and a 9.8 kWp rooftop plant behind it, pushes a month of quarter-hour readings under OBIS 1-0:2.8.0, settles the month at EUR 233.568, and asserts the de.eeg.verguetung.berechnet CloudEvent the ERP receives. The amount alone is not a legal document: under the Gutschriftverfahren (§ 14 Abs. 2 Satz 2 UStG) the Netzbetreiber issues the Gutschrift, so einsd renders it as a BO4E Rechnung whose VAT follows the operator's declared ust_status — the fixture is a Kleinunternehmer (§ 19 UStG), so it carries 0 % USt.


The third demo — order-to-cash

demos/o2c runs the retail money path: what the supplier sells, who bought it, what they owe, the document that says so, and the receivable behind it. No EDIFACT at all — demos/nb-stp is where a market message goes out; this is what happens on the supplier's own books once supply is running.

ServicePortRole
productd9080Product and tariff catalogue — the only price source
vertragd9780Kunden, Verträge, Kündigungsfristen
billingd9280Multi-product billing engine (EN 16931)
outputd9880Document store and delivery
accountingd9380Massenkontokorrent — the double-entry ledger
webhook8001ERP CloudEvent receiver, so this stack runs beside demos/nb-stp

marktd, edmd and makod are deliberately absent — the smoke test names the Netzbetreiber and supplies the meter reading, so no lookup happens. Their URLs are configured to hostnames that do not resolve, so an override that is ever dropped fails loudly instead of quietly billing zero.

# from the repo root
just build-demo-o2c

cd demos/o2c
docker compose up -d
bash smoke.sh

It publishes a Tarifpreisblatt, creates a Kunde as a BO4E Geschaeftspartner and a Versorgungsvertrag on a Marktlokation, bills one month against that tariff, records the document in outputd for the § 147 AO eight years, watches the invoice land in accountingd as an Offener Posten, and closes it with a payment import.

§ 14 Abs. 4 Nr. 1 UStG makes the Leistungsempfänger's full name and address part of the invoice, so step 3 reads the recipient back off both the BO4E Rechnung and the EN 16931 model. A flat vorname/strasse/plz body is a 422 naming the field.

just test-demo-payloads deserialises every request body the demos post into the real request type, and just test-demo-configs loads every *.toml the stacks start with — no Docker, seconds.

The smoke test computes the expected amount itself: 20 ct/Tag × 31 Tage, 32 ct/kWh × 250 kWh, the § 3 StromStG Stromsteuer at 2.05 ct/kWh and an 8.00 ct/kWh Netznutzungsentgelt from the grid override, each line rounded to the cent — 111.33 netto, 132.48 gross at 19 % USt. A wrong tariff version or the other rounding direction fails the assertion.


From source — one service, no images

The demos run built images. To work on a service, run it from the checkout instead; this needs Rust (installed from rust-toolchain.toml by rustup), just and Docker for PostgreSQL:

just dev marktd
curl -s http://localhost:8180/health/ready

just dev starts PostgreSQL from dev/docker-compose.yml when it is not running and starts the service with dev/config/marktd.toml; every one of the seventeen services has such a file, on its port from the services index. makod needs no database — its dev event store is in memory — and agentd starts last, because it connects to every other service's MCP endpoint at startup.

A config problem is reported in one go — the file read, the environment prefix (MARKTD_, nested keys joined with __) and every missing, unknown or invalid key. Two flags help before a first start:

cargo run -p marktd -- --help          # usage and config conventions, reads no config
cargo run -p marktd -- --print-config  # the merged file + environment, secrets redacted

just smoke-dev boots every service this way and waits for /health/ready. CONTRIBUTING.md covers building, testing one crate and running the gate.


Next steps

TopicGuide
EDIFACT parsing and validationParsing guide
ERP integration — CloudEvents, HMACERP integration
makod operator referencemakod guide
marktd operator referencemarktd guide
processd — NB STP + LF answer automationprocessd guide
INVOIC plausibility, § 147 AO / GoBDinvoicd guide
Energy data, imbalance, billing periodsedmd guide
Process observability, §20 parityobsd guide
Full system architectureArchitecture
Process catalogue (GPKE, WiM, …)Processes
mako-service SDKmako-service README

Edit this page ↗