The bug a schema would have caught at build time
It started with a field that changed type. An upstream service that owned user records shipped a release where account_id went from a number to a string, because a new partner used non-numeric IDs. JSON serialized it without complaint. It parsed fine on the other side too. Three downstream services kept running and quietly started writing the wrong thing to their own tables, and we found out two days later from a support ticket, not an alert.
Nothing in the pipeline could have caught it, because there was nothing to catch it with. JSON over HTTP has no opinion about what a field is supposed to be. The contract between those services lived in a wiki page and in the memory of whoever wrote the integration. That is the moment I stopped defending REST for internal traffic. The bug was free to happen because we had picked a protocol that does not know what our own data looks like.
REST was a decision you made for outsiders
Everything REST is good at is aimed at a stranger. It is human-readable, so a developer who has never seen your API can poke at it with curl. Discoverability lets clients you do not control navigate it on their own. Loose typing means a dozen unknown consumers can each ignore the fields they do not care about. And it rides plain HTTP, so any caching layer on earth already understands it.
Those are real virtues at a public boundary, where you do not own the other end and have to be generous about what you accept. Between two services that live in the same repo, deploy from the same pipeline, and get read by the same team, every one of those virtues turns into a cost you pay for nobody. You are being forgiving toward a consumer that is also you, and you are paying in bytes, in latency, and in bugs that wait until production to introduce themselves.
What a typed contract buys inside the walls
A protobuf schema is a real artifact that both sides compile against. The field that changed type becomes a build error in the service that consumed it, the morning the producer tries to ship the change, instead of a support ticket two days later. That schema is the source of truth, the generated stubs make the call look like a local function, and a breaking change is something your CI can refuse before it ever reaches an environment.
The wire format pays you back too. Protobuf encodes that same payload in a fraction of the bytes, because it drops the repeated field names and quotes and whitespace that make JSON pleasant to read and expensive to move. Running on HTTP/2, gRPC multiplexes many calls over one connection and streams in both directions without you hand-rolling any of it. For chatty service-to-service traffic, the difference in payload size and tail latency stops being a rounding error.
The through-line with my last post
I wrote recently that running REST and GraphQL together is two problems wearing one truce, and that inside your own walls a typed contract beats GraphQL's flexibility. This is the other half of that thought. If the reason you keep service-to-service calls off GraphQL is that you want a strict, typed contract, then the honest endpoint of that logic is not REST either. REST is a typed contract by convention and good intentions only. gRPC is one the compiler enforces. The typed contract I was reaching for in that post has a name, and the name is protobuf.
The costs gRPC actually adds
This is a trade, not a free upgrade, and the trade has a real other side. Browsers do not speak gRPC natively, so anything a frontend touches needs grpc-web or a JSON gateway in front, which is exactly why the public edge stays REST. You cannot curl a gRPC endpoint and read the answer with your eyes, so casual debugging gains a tool you did not need before. There is a proto build step, a code-generation toolchain, and a learning curve for a team that has only ever shipped JSON. None of that arrives for free.
Where internal REST is still fine
I am not telling you to put protobuf between two services and call it architecture. If your whole backend is three services and a dozen endpoints, the proto toolchain costs more than the typing saves, and plain JSON over HTTP is the right amount of machine for the job. You do not gRPC three services any more than you stand up Kafka for three topics. The win shows up when the service count climbs, the call volume is real, and the same handful of teams keep breaking each other across an untyped seam.
The signal is not a number you read off a chart. It is the second or third time a renamed field causes an outage, or the first time payload size shows up inside a latency budget. That is the system telling you the contract needs teeth.
The migration is cheaper than it looks
You do not rewrite anything in a weekend, and you do not have to choose all at once. Write protobuf definitions for the calls two services already make, stand the gRPC server up alongside the existing REST handlers in the same process, and move one consumer over. A service can speak both for as long as it needs to. The edge keeps a JSON gateway so browsers and outside callers never notice a thing.
Because the change is per-call instead of per-system, you get to spend the cost exactly where the pain is. The chattiest, most type-sensitive path moves first and earns the toolchain its keep, and the rest follows only if it turns out to be worth it.
What I actually reach for
At the public edge I reach for REST, because the audience is strangers and the whole point is being easy on people I will never meet. Between services, once there are more than a few of them and the calls carry real structure, I reach for gRPC and let the compiler hold the contract instead of a wiki page. The boundary decides the protocol, not habit and not whatever the last service happened to use.
The field that changed type is the whole argument in one bug. At a public boundary, accepting it gracefully is a feature. Between your own services, accepting it silently is a two-day incident with your name on the commit. Pick the protocol that knows what your data is supposed to be, and pick it based on who is standing on the other end.
Comments (0)