DEV Community

Cover image for Shipping the Chaos: One Contract, Three Codegens, and a Docker Gotcha Hiding in Plain Sight
Gouranga Das Samrat
Gouranga Das Samrat

Posted on

Shipping the Chaos: One Contract, Three Codegens, and a Docker Gotcha Hiding in Plain Sight

This is the final post in "The Muse of Microservices," a series on the system design behind Polyhymnia. Parts 1–3 covered the architecture, the Go gateway, and the Rust/C++ backends. This post covers what holds it all together and how it ships — plus one bug hunt you can go do yourself.

How Polyhymnia's single protobuf contract turns into three different generated codebases, how the project ships to Docker, and a loopback-address gotcha worth hunting down yourself.

One file, three languages, zero hand-written serialization

Everything about how these services talk to each other traces back to a single 40-line file: proto/quote.proto. It defines two services, five messages, and three RPCs, and it is the only place any of that is defined. Nobody hand-writes a JSON schema for the Rust service and a separate struct for the Go client and hopes they stay in sync — that's exactly the kind of manual synchronization that silently rots the moment someone forgets to update one side.

Instead, each language generates its own stubs from the same source of truth, through three genuinely different pipelines:

  • Rust does it automatically, on every cargo build, via a build.rs script that calls tonic_build::compile_protos(...). You never run a separate codegen step — it's baked into the normal build.
  • C++ does it through a CMake add_custom_command that shells out to protoc with the gRPC C++ plugin, generating .pb.cc/.pb.h files into the build directory before the actual source is even compiled.
  • Go is the odd one out — its stubs are generated explicitly via a just proto-go recipe that invokes protoc-gen-go and protoc-gen-go-grpc, rather than as an automatic build step.

Three different mechanisms, one contract, zero drift possible between them — because there's nothing to drift. Change a field in the .proto file and every language's next build simply reflects it, or fails to compile until you update the code that used the old shape. That failure mode, "won't compile" instead of "silently breaks in production," is the entire value proposition of contract-first API design in one sentence.

A small but telling asymmetry: reflection

The C++ service turns on gRPC server reflection:

grpc::reflection::InitProtoReflectionServerBuilderPlugin();
Enter fullscreen mode Exit fullscreen mode

which means a tool like grpcurl can introspect the Randomizer service's methods and message shapes at runtime, with no .proto file in hand. The Rust service doesn't do this — tonic-reflection isn't even in its dependency list. Neither choice is wrong; reflection is a debugging convenience, not a requirement, and it's genuinely useful to notice that two services in the same codebase made different calls about it. It's a good habit to carry into your own projects: reflection on your internal services costs you almost nothing and saves a .proto file scavenger hunt the next time you're debugging with grpcurl at 2am.

justfile: one command runner for three build systems

Cargo, CMake, and the Go toolchain each have their own build model, their own flags, and their own idea of what "clean" means. Polyhymnia flattens all three behind a single justfile:

just build        # build-rust + build-cpp + build-go
just run           # build everything, then launch all four processes
just proto-go       # regenerate Go stubs
just clean          # wipe every language's build artifacts
Enter fullscreen mode Exit fullscreen mode

This is a small but real answer to a real problem: polyglot repositories have an onboarding cost that monoglot repositories simply don't, because every contributor has to context-switch between build systems just to get the thing running. A single command runner doesn't eliminate that cost, but it hides it behind one consistent vocabulary — just build, just run, just clean — regardless of which language is underneath. It's the kind of unglamorous tooling decision that never shows up in an architecture diagram and saves everyone real time anyway.

Packaging: four Dockerfiles, three of them multi-stage

Each service ships with its own Dockerfile, and three of the four follow the same shape: a fat builder stage with the full compiler toolchain, followed by a slim runtime stage that copies out only the finished binary.

The Rust image, for instance, compiles inside rust:latest (with protobuf-compiler installed for tonic-build), then copies just the resulting binary into a bare debian:bookworm-slim image. The Go image does the same trick even more aggressively — it builds with CGO_ENABLED=0 and ships the result into alpine:3.18, ending up with a runtime image that has no build tooling, no source code, and a dramatically smaller attack surface than the image that built it. The frontend, having nothing to compile, skips all of this and is just static files dropped straight into nginx:stable-alpine.

Multi-stage builds like this are standard practice for a reason: your production image should contain exactly what's needed to run the service and nothing that was needed to build it. Every extra tool left in a runtime image is one more thing that can have a vulnerability, and one more reason the image is bigger than it needs to be.

Two compose files, two audiences

The repo ships two separate docker-compose files, and the distinction between them is worth calling out because it maps to two genuinely different users:

  • docker-compose.yml builds every image locally from source — the right choice for a contributor who's actively changing code.
  • docker-compose.images.yml instead points at pre-built images on Docker Hub (with a <VERSION> placeholder to fill in) — the right choice for someone who just wants to run the finished thing without owning a full multi-language toolchain.

Same four services, same ports, same topology — just a different answer to "where does the image come from." It's a small piece of design that quietly respects that "contributor" and "user" are different personas with different needs.

The gotcha: what "127.0.0.1" means depends on where you're standing

Here's where it gets genuinely interesting, and it connects directly back to something we flagged in Part 2.

Recall that the Go gateway dials its two backends at hardcoded addresses:

rustDbAddr    = "127.0.0.1:50051"
cppEngineAddr = "127.0.0.1:50052"
Enter fullscreen mode Exit fullscreen mode

and the Rust service binds to:

const LISTEN_ADDR: &str = "127.0.0.1:50051";
Enter fullscreen mode Exit fullscreen mode

Both of those are entirely correct for the workflow the docs lead with first — just run, where every process launches directly on your machine and shares one network stack. On localhost, 127.0.0.1 unambiguously means "this machine," and every service can reach every other service through it without issue.

Docker changes the ground under that assumption. Each container in a Compose stack gets its own network namespace — its own private, isolated 127.0.0.1 that refers only to itself. When the go-gateway container dials 127.0.0.1:50051, it is not reaching the rust-db container at all; it's trying to reach port 50051 on itself, where nothing is listening. Cross-container traffic in Compose is meant to travel over the service name instead — rust-db:50051, resolved through Docker's built-in DNS on the shared Compose network — which is precisely what depends_on in the compose file gestures at but doesn't, on its own, wire up.

Interestingly, the codebase already contains a working example of the container-friendly pattern, sitting right next to the problem: the C++ engine binds to

constexpr char kListenAddress[] = "0.0.0.0:50052";
Enter fullscreen mode Exit fullscreen mode

0.0.0.0 means "listen on every network interface," which is reachable from sibling containers, unlike Rust's loopback-only bind. So the two backend services in this project actually demonstrate both the wrong pattern and the right one for containerized binding, and the fix on the Go side would follow the same idea: make the dial addresses configurable — an environment variable like RUST_DB_ADDR, defaulting to 127.0.0.1:50051 for local runs but overridable to rust-db:50051 inside Compose — rather than baking one deployment mode's assumption into a compile-time constant.

This is genuinely worth doing yourself as an exercise if you clone the repo: spin up docker-compose up, watch what happens, and then go fix the addressing to make it actually work across containers. It's a small, self-contained, extremely realistic bug — the exact category of "worked perfectly on my machine, broke the second it left it" issue that shows up constantly in real infrastructure work, just shrunk down to a size you can fully understand and fix in one sitting.

(And it's a nice full-circle moment for the series: the fixed-interval retry loop we covered in Part 2 is precisely the mechanism that would cover the timing half of a multi-container startup — Compose's depends_on only waits for a container to start, not for the service inside it to actually be ready, and the gateway's own retry-and-block dial logic is what papers over that gap. It just needs to be dialing the right address to begin with.)

What you'd change to make it "real"

None of this needs fixing to serve its actual purpose — Polyhymnia is upfront about being satire with genuinely good bones, not a production template. But it's worth closing the series by naming, plainly, what separates "excellent teaching example" from "thing you'd actually run at scale," because that list is itself a useful mental checklist:

  • Externalize configuration. Addresses, ports, and timeouts as environment variables instead of constants, so the same binary behaves correctly across just run, Compose, and a real orchestrator without a recompile.
  • Add a circuit breaker. Right now a slow or failing backend just eats into the gateway's fixed timeout budget on every request; a breaker would stop hammering a backend that's already down instead of retrying it into the ground.
  • Move from fixed-interval to backoff-with-jitter retries once more than one caller could plausibly be retrying against the same dependency at once.
  • Pool database connections instead of guarding a single one behind a mutex, once concurrent load is real rather than theoretical.
  • Encrypt service-to-service traffic. Every gRPC channel here uses insecure.NewCredentials() — completely fine on a trusted loopback interface, and the first thing to change the moment these services cross a network you don't fully control.

That list isn't a criticism of the project — it's the reward for having read it closely enough to write it. A system small enough to fully understand is also a system small enough to see clearly what growing it up would actually require.

Closing the loop

Four services, one contract, five hops, and one very committed random number generator — all in service of a button that returns a sentence and a name. That gap between the size of the problem and the size of the solution was always the point, and if you've followed this series through, you now know exactly what's happening at every one of those hops and why.

Go clone it, click the button a few times, and then go fix that Docker networking issue yourself: GourangaDasSamrat/Polyhymnia.

Top comments (0)