Adding gRPC to a Go Ledger Service: Why Both REST and gRPC, and How to Share the Domain
Bolting a second API onto the ledger sounded like double the work, until I realized the interesting part was making sure it was zero new business logic.

WhoAmI => notes.sohag.pro/author
Picture two services inside a bank that need to talk to each other a few thousand times a second: a payments service telling the ledger "move this money." You could have them speak JSON over HTTP, the same way your phone talks to the ledger. It works. It is also a bit wasteful: text parsing, no shared schema, no generated client, every field name a string agreement that nobody checks until it breaks in production. For machine-to-machine traffic on the hot path, teams reach for gRPC instead: a binary protocol with a typed contract both sides generate their code from. This week I gave go-ledger a gRPC front door, right next to the REST one it already had.
This is post 7 of 12. Last week was idempotency and the audit log. This week is a second way to talk to the same ledger, and the whole trick is that it is the same ledger.
Why both, instead of picking one
REST is the friendly public front door: curlable, cacheable, understood by everyone, great for a phone app or a dashboard. gRPC is the service-to-service door: a strict .proto contract, generated clients in many languages, HTTP/2 under the hood, and no argument about field names because the schema is the source of truth. A real payment platform usually wants both, and go-ledger is meant to look like a real one.
The danger with two APIs is obvious the moment you say it out loud: two front doors, two chances to implement "post a transaction" slightly differently, and now one of them has a bug the other does not. That is how a system slowly grows two personalities.
The way out is to make sure neither front door contains any actual logic. Both are thin translators that speak to one brain.
The REST handler already worked this way from Week 5: it decodes JSON, calls TransactionService.Post, and turns the result back into JSON. So the gRPC handler is the same shape with a different translator: decode protobuf, call the same TransactionService.Post, turn the result into protobuf. The exactly-once idempotency, the atomic audit write, the postings-sum-to-zero rule: none of that is reimplemented, because it lives in the service both doors call. The best part of this whole week was a diff that added a new package and changed zero lines of domain code.
The contract comes first
With gRPC you write the schema before the code. Here is the shape of posting a transaction, in protobuf:
message Posting {
string account_id = 1;
int64 amount = 2; // minor units, signed: debit positive, credit negative
string description = 3;
}
message PostTransactionRequest {
string currency = 1;
repeated Posting postings = 2;
}
message PostTransactionResponse {
Transaction transaction = 1;
bool replayed = 2; // true if an idempotency key matched an earlier post
}
Notice money is still a signed int64 plus a currency string, exactly like the REST API. Same decision, same reason as post 5: no floats anywhere near money. The .proto is just a stricter way to write down the contract the REST API already had.
I used buf to manage and generate from the protos, which is the modern, boring choice: buf lint keeps the schema tidy, and buf generate produces the Go server and client stubs using remote plugins, so there is no protoc toolchain to install on every machine. One make proto and the generated code is regenerated, byte for byte identical, every time.
A quick honest note: the plan called for generating clients in Go, Node, and Python. I shipped Go only. Verifying three language toolchains actually work is a lot of surface for a week already marked "heavy," and the goal this week was one working client, not a polyglot demo. Node and Python are a config change I can add later. Cutting scope you said out loud is not failure, it is the plan working.
Translating errors, not inventing them
REST speaks HTTP status codes; gRPC speaks its own status codes. The domain, sensibly, speaks neither: it returns typed errors like "this transaction does not balance" or "that account does not exist." So each front door has exactly one small function that translates domain errors into its own dialect, and they translate the same errors the same way:
| Domain error | REST | gRPC |
|---|---|---|
| account not found | 404 | NotFound |
| idempotency key reused with a different body | 409 | AlreadyExists |
| postings do not sum to zero | 422 | InvalidArgument |
| write conflict, retries exhausted | 503 | Unavailable |
| anything unexpected | 500 | Internal |
Because both mappers read from the same list of domain errors, the two protocols cannot quietly disagree about what "that is not allowed" means. And the unexpected case is deliberately vague on both sides: a client gets "internal error," never a stack trace or a database detail.
Idempotency, the gRPC way
Last week's Idempotency-Key header lets a client safely retry a payment. gRPC has no headers, but it has metadata, which is the same idea: key-value pairs that ride alongside the call. So the gRPC PostTransaction reads an idempotency-key from the request metadata and hands it to the very same Post method the REST handler uses. Retry the same call with the same key, and the second one comes back with replayed = true and the original transaction, because the exactly-once logic is not in the gRPC layer at all. It is in the service, and the service does not care which door you came through.
Proving it runs
The definition of done was concrete: post a transaction with grpcurl, and have a generated Go client work in an example. The server turns on gRPC reflection, which lets grpcurl discover the API with no .proto file in hand:
$ grpcurl -plaintext localhost:9091 list
ledger.v1.LedgerService
$ grpcurl -plaintext -d '{"currency":"USD","postings":[
{"account_id":"...cash...","amount":10000},
{"account_id":"...revenue...","amount":-10000}]}' \
localhost:9091 ledger.v1.LedgerService/PostTransaction
And the automated proof: an integration test that stands up the real gRPC server over an in-memory connection, backed by a real Postgres in a container, drives it with the generated Go client (create two accounts, post between them, read the balance back), and checks the ledger nets to zero. Same test I would write for REST, one door over. The gRPC server runs on its own port next to REST and the metrics endpoint, and shuts down gracefully with them on a stop signal.
My AI companion this week
The split was sharp this week. I owned the decisions that shape the system: standard grpc-go over the fancier one-port alternative, a separate port instead of multiplexing, Go-only clients to respect the scope warning, and the rule that no domain code may change. Claude did most of the typing inside those rails: the proto, the buf setup, the handlers, the interceptors, the bufconn test. The interesting moments were, as usual, in review. A reviewer pass caught that the gRPC endpoints had no upper bound on page size while REST capped them, a real divergence and a denial-of-service foot-gun, and that a panic inside a handler was being swallowed with no log at all. Both got fixed before merge. That is the pattern I keep coming back to: the machine is fast and tireless, and my job is to know what "correct" means and to keep looking until I am sure it holds.
Where this leaves us
go-ledger now has two front doors over one brain. A phone can curl it, a service can call it over gRPC, and both get the identical rules, the identical errors, and the identical idempotency guarantees, because there is only one implementation of any of that. Next week the ledger learns to explain itself under load: OpenTelemetry tracing, structured logs tied to a trace id, and one unbroken trace from an incoming call all the way down to the SQL query.
The repo is open source at github.com/sohag-pro/go-ledger, and the REST side is live at go.sohag.pro.



