A double-entry accounting service in front of a TigerBeetle cluster. It exposes the same eight ledger operations over two transports (a JSON HTTP API and a gRPC API) on top of one shared service layer, so both speak to the ledger through identical validation, batching and event publishing.
HTTP :8080 --+
|
+-- account/transfer services -- TigerBeetle cluster
| |
gRPC :9090 --+ +-- events (noop | Kafka | RabbitMQ)
cp config.example.yaml config.yaml
make setup # format the three TigerBeetle replica files into ./data (once)
make up # build and run the service and the 3-replica clusterThen:
- HTTP API on
http://localhost:8080, Swagger UI at http://localhost:8080/docs/ - gRPC API on
localhost:9090, with server reflection and the standard health service make downstops the stack;make restartcycles it
make setup is only needed once: it refuses to overwrite replica files that already exist, so starting from an empty
ledger means removing ./data first.
The directory is gitignored.
Docker Desktop: enable host networking (Settings > Resources > Network > Enable host networking).
The compose stack runs every container with network_mode: host as instructed by
TigerBeetle.
The binary reads config.yaml from its working directory (mounted into the container from the repo root).
Every key is optional and falls back to the default.
config.example.yaml lists them all with their defaults:
| section | what it sets |
|---|---|
tigerbeetle |
cluster ID and replica addresses |
http |
listen address and the read/write/idle/shutdown timeouts |
grpc |
listen address, shutdown timeout, max receive message size |
logging |
level (debug/info/warn/error) and format (json/text) |
event |
driver (noop/kafka/rabbitmq), its connection details and drain timeout |
config.yaml is gitignored - it is the local copy, not the committed one.
Both transports cover the same operations:
| operation | HTTP | gRPC |
|---|---|---|
| create accounts | POST /account |
AccountService.CreateAccounts |
| look up accounts by ID | GET /account?id=1,2,3 |
AccountService.ListAccounts |
| query accounts by filter | GET /account/query |
AccountService.QueryAccounts |
| an account's balance history | GET /account/{id}/balance |
AccountService.ListAccountBalances |
| create transfers | POST /transfer |
TransferService.CreateTransfers |
| look up transfers by ID | GET /transfer?id=1,2,3 |
TransferService.ListTransfers |
| query transfers by filter | GET /transfer/query |
TransferService.QueryTransfers |
| an account's transfers | GET /account/{id}/transfer |
TransferService.ListAccountTransfers |
Plus GET /health on HTTP and the standard gRPC health service.
Both report not serving while the process drains, so a load balancer takes the instance out of rotation
from the health check itself rather than from the error every other route starts answering with.
Both servers run concurrently and share a lifetime: the first to return cancels the context the other runs under, so one failing shuts the process down cleanly rather than leaving half a service listening.
A few conventions the whole API follows:
- 128-bit values are decimal strings.
IDs, amounts and
user_data_128exceed what JSON numbers and protobuf integers carry safely, so they cross the wire as strings on both transports. Timestamps (nanoseconds since the Unix epoch) likewise. - Creates are partial.
Every request index comes back in exactly one of
createdorfailed: one rejected item fails alone instead of failing the call. A201/OKtherefore does not mean everything landed - checkfailed. Items that already existed with exactly the fields submitted count as created;existingpairs each of their request indices with its position increated, which is what a recognised retry looks like. - Supplying an ID is what makes a retry safe.
Resubmitting an identical create is recognised by its ID and reported as
existingrather than duplicated. Omitting the ID has the server mint one, which is fine for fire-and-forget work but leaves nothing to retry against. Self-generated IDs should follow TigerBeetle's time-based scheme rather than being random. - Validation reports every field at fault, keyed (
invalid_user_data_128) so a client can switch on the reason without parsing prose, and indexed by batch position. - Batches larger than TigerBeetle's 8189-event request cap are split server-side (
src/chunk), without ever cutting a linked chain across two requests. A single chain longer than the cap is rejected rather than folded.
The HTTP API is described by an OpenAPI 3.1 document served at /openapi.yaml, with a Swagger UI at
/docs/.
Both are served from the binary itself - the UI's assets are embedded, so the docs work without outbound internet
access.
The document is generated from the handler DTOs and the route table in src/httpsrv/openapi_doc.go, and committed to
src/httpsrv/openapidoc/openapi.yaml because the image build has no generate step.
Regenerate it after changing a DTO or a route:
make openapimake test fails while the committed document and the generator disagree.
The gRPC API is defined in src/grpcsrv/proto/ledger/v1/, and generates the Go package
ledger-beetle/grpcsrv/gen/ledger/v1 into src/grpcsrv/gen/ledger/v1/.
The generated code is committed, so a .proto change is only complete once the bindings have been regenerated and
committed with it:
make proto # regenerate src/grpcsrv/gen/ledger/v1/*.pb.go
make proto-lint # check the .proto files against buf's standard rulessrc/grpcsrv/gen/ belongs to the generator: make proto prunes anything in it that the current .proto files no
longer produce, so nothing handwritten should live there.
Nothing outside the Go toolchain has to be installed.
make proto compilesbuf from src/grpcsrv/tools into a gitignored src/grpcsrv/bin/ on first use, and the protoc
plugins are tool dependencies of src/go.mod, so both are pinned and every machine generates byte-identical output.
Successful creates are published as JSON, one message per account or transfer, from a background goroutine so publishing
never sits in the request path.
The driver is chosen by event.driver: noop (the default) drops them, kafkaand rabbitmq publish for real.
Shutdown drains the in-flight publishes within event.publish_shutdown_timeout - the items are already committed to the
ledger, so they are still published after the request that produced them returns.
make test # unit tests, race detector on
make test-e2e # brings the compose stack up, runs ./e2e against it, tears it downThe e2e suite is behind the e2e build tag and drives both transports against a live server; it points at
E2E_BASE_URL / E2E_GRPC_ADDR (defaulted by the Makefile) so it can be aimed at an already-running deployment.
cmd/loadtest drives a weighted mix of all eight operations over one transport at a time and reports throughput,
latency percentiles and error buckets:
make test-load # http, default flags
make test-load TRANSPORT=grpc ARGS="-duration 10s"
make test-load-docker TRANSPORT=grpc ARGS="-concurrency 5000"test-load-docker runs the generator as a sibling container on the stack's network, avoiding a host-to-container
port-forwarding hop that skews high-concurrency numbers.
See src/cmd/loadtest/README.md for the flags and the report format.
postman/ holds two mirror collections in Postman's multi-file format 3.0 (Ledger Beetle HTTP and Ledger Beetle
gRPC), each split into Endpoints (one request per operation, carrying every documented parameter, the optional ones
shipped disabled) and Scenarios (the flows the ledger is actually used through, including the recipes from the
TigerBeetle docs).
Every folder is self-contained: it creates the accounts it needs and stores their IDs in collection variables, so
folders run in any order, repeatedly, against a cluster that already holds data.
Import the directory into Postman and point the Dev environment at a running stack.
Everything in both collections is a request meant to succeed, and the tests check data.failed as well as the status
code, since a 201 from a create is not the same as every item landing.
This is a general implementation, not a deployment-ready one. Read this before running it anywhere real.
It is a starting point, meant to be extended.
Nothing here is specialised to a particular domain or deployment, and the obvious extension points are left open on
purpose: mappings between the ledger's generic fields (user_data_*, code, ledger) and whatever they mean to you,
traffic optimization (caching, rate limiting, connection and batch tuning), security(there is no authentication or
authorization on any endpoint, and no TLS termination), and HTTP headers.
Add what your deployment needs; none of it is assumed here.
The event publishers are fire-and-forget. A create commits to the ledger first and publishes afterwards, from a background goroutine, with no durable record linking the two. The publish is retried in process with backoff, but once the attempts are exhausted the event is logged and dropped, and a crash between the commit and the publish loses it silently. There is no ordering guarantee, no dead-lettering and no way to replay. Production event handling wants something like a transactional outbox, or at minimum a durable queue and a reconciliation path against the ledger.
Tests share the development data directory.
make setup formats the replicas in ./data, and make test-e2e and the load generator then run against that same
cluster, so test rows and load-generated rows accumulate in the same files as everything else.
TigerBeetle never deletes, so this only grows - the three replica files reach gigabytes quickly under load testing.
A separate data directory (or a throwaway cluster) for tests is a possible fix; make setup in a scratch directory and
a tigerbeetle.addresses override is enough to get one.
There is no CI.
Nothing runs on push: make test, make test-e2e and make proto-lint only run manually. make test does catch a
stale OpenAPI document, but nothing catches stale protobuf bindings - a .proto edited without make proto still
compiles against the previously generated code, so the wire contract and its definition drift apart quietly.
src/
main.go wiring: config, logger, TigerBeetle client, event driver, both servers
config/ YAML config and its defaults
account/ account service + TigerBeetle repository
transfer/ transfer service + TigerBeetle repository
chunk/ splitting oversized batches without cutting linked chains
event/ publishers: noop, Kafka, RabbitMQ, and the drain-aware Group
validation/ field-level validation errors shared by both transports
logging/ request-scoped logger in the context
httpsrv/ HTTP handlers, DTOs, OpenAPI generation and the embedded docs
grpcsrv/ gRPC servers, protos, generated bindings, interceptors
e2e/ build-tagged end-to-end suite, both transports
cmd/loadtest/ load generator
cmd/openapi/ OpenAPI document generator
postman/ Postman collections (format 3.0)