# Adding gRPC to a Go Ledger Service: Why Both REST and gRPC, and How to Share the Domain

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.

![](https://cdn.hashnode.com/uploads/covers/62d6bdce3060d03288d9e0ed/e25a5f19-9f7e-4515-9517-0174c331ce00.png align="center")

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:

```go
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:

```shell
$ 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](https://github.com/sohag-pro/go-ledger), and the REST side is live at `go.sohag.pro`.
