Over-Engineered on Purpose — Part 4: Service Discovery, gRPC Routing, and the Layer Between Your Frontend and Your Microservices
This is Part 4 of a series where I'm building a microservice platform from scratch. Part 1 covers the architecture, Part 2 the gRPC contracts, Part 3 the Catalog Service. Full codebase on GitHub.
I kept calling it the API Gateway. That's what the architecture diagram said. That's what I named the module. API Gateway — it routes requests to services. Simple.
Then I started building it and realized that's not what I was making at all.
A traditional API gateway is mostly a pass-through. It receives a request, maybe validates a token or applies rate limiting, and forwards it to the right service. The request format going in is roughly the same format going out. Spring Cloud Gateway does this well — you configure routes, add filters, and the gateway proxies requests to your microservices.
But my setup is different. My clients speak REST and JSON. My services speak gRPC and Protobuf. Someone has to translate. The gateway isn't just routing — it's receiving a JSON POST request, building a Protobuf message, making a gRPC call to the right service, getting a Protobuf response back, converting it to JSON, and returning it to the client.
That's not a gateway. That's a Backend for Frontend — a BFF.
Once I understood that distinction, the design decisions got clearer. This isn't a thin routing layer. It's a service with controllers, mappers, and its own logic for how to present data to clients. It has REST endpoints. It knows about gRPC stubs. It's the bridge between two worlds.
The First Problem: How Does the BFF Find the Services?
The Catalog Service is running on my machine. I know it's on port 9091 for gRPC. I could hardcode that:
grpc:
client:
catalog-service:
address: static://localhost:9091This works. It also breaks the moment anything changes. New port? Edit the config. Second instance? Edit the config. Deploying to a different environment? Edit the config. There are a few ways to solve this. Docker Compose gives you DNS automatically — services reference each other by name. Kubernetes has its own DNS resolution. For my setup, I went with Spring Eureka, because I want to understand the service registration lifecycle and because it's the pattern I'll encounter most in the Spring ecosystem. The idea behind Eureka is self-registration. When a service starts up, it sends a POST to the Eureka server saying "I exist, here's where I am." It keeps sending heartbeats to say "I'm still alive." If the heartbeats stop, Eureka assumes the service is down and removes it from the registry. Other services query Eureka to find out where things are running. Setting up the Eureka server itself is a new module with minimal configuration:
spring:
application:
name: service-registry
server:
port: 8081
eureka:
client:
register-with-eureka: false
fetch-registry: falseThe register-with-eureka: false is important — without it, the registry tries to register itself with itself. Classic.
Then each microservice becomes a Eureka client:
eureka:
client:
service-url:
defaultZone: ${EUREKA_URL:http://localhost:8081/eureka}
fetch-registry: true
register-with-eureka: trueStart the registry, start the Catalog Service, check the Eureka dashboard, and there it is — CATALOG-SERVICE registered and showing status UP. Satisfying.
Quick aside on a debugging rabbit hole: after adding the Eureka client dependency through my buildSrc convention plugin, the Catalog Service wouldn't start. Spring was throwing UnsatisfiedDependencyException errors about the Eureka auto-registration. I spent a good chunk of time cleaning Gradle caches, suspecting a version mismatch. Started a fresh project with the same dependencies — worked perfectly. Went back to my project — still broken.
Then I tried running it from the terminal instead of IntelliJ's play button:
./gradlew :catalog-service:bootRunWorked immediately. IntelliJ was using cached dependency state that didn't include the Eureka client. Invalidated the cache, reimported the project, and it was fine. Not a profound lesson, but a reminder that when your code looks right and still doesn't work, the build tool might be lying to you.
The Second Problem: Eureka Registered the Wrong Port
This is the one that tripped me up. Eureka is built for the HTTP world. When the Catalog Service registers itself, Eureka records its HTTP port — 8082 in my case, the Spring Web / actuator port. Makes sense. That's the port Spring Boot exposes by default. But I don't want to talk to the Catalog Service over HTTP. I want to talk to it over gRPC, which runs on port 9091 on a completely separate Netty server. Eureka has no idea about this second port. It registered the service correctly from its perspective — it just registered the wrong port from mine. The solution is metadata. When the Catalog Service registers with Eureka, it includes its gRPC port as custom metadata:
eureka:
instance:
metadata-map:
grpc-port: 9091Now when any service looks up CATALOG-SERVICE in Eureka, the registration includes both the standard HTTP port and, buried in the metadata, the gRPC port. You just have to know to look for it.
This felt like a hack at first. Then I realized this is just how you extend a system that was designed before your use case existed. Eureka doesn't know what gRPC is, and it doesn't need to. The metadata map is the extension point.
Wiring Up the BFF
With Eureka handling discovery, the BFF needs to do three things: look up a service, create a gRPC channel to it, and make calls through a stub.
I built a GrpcChannelFactory that uses the Eureka client to resolve service instances and extract the gRPC port from the metadata:
@Component
@RequiredArgsConstructor
public class GrpcChannelFactory {
private final EurekaClient eurekaClient;
private final Map<String, ManagedChannel> channels = new ConcurrentHashMap<>();
public ManagedChannel getChannel(String serviceName) {
return channels.computeIfAbsent(serviceName, this::createChannel);
}
private ManagedChannel createChannel(String serviceName) {
InstanceInfo instance = eurekaClient
.getNextServerFromEureka(serviceName, false);
String grpcPortStr = instance.getMetadata().get("grpc-port");
int grpcPort = grpcPortStr != null
? Integer.parseInt(grpcPortStr) : 9000;
String host = instance.getHostName();
return ManagedChannelBuilder
.forAddress(host, grpcPort)
.usePlaintext()
.build();
}
}Quick detour on channels and stubs, because I found this confusing at first. A channel is a long-lived connection to a gRPC server — think of it as a managed HTTP/2 connection pool to a specific host and port. A stub is the typed client generated from your .proto file — it's the thing with methods like createCategory() and searchMachines(). The relationship: the stub uses the channel to send data, and one channel can have multiple stubs. You create the channel once and reuse it.
On top of that, a GrpcStubFactory creates the typed stubs:
@Component
@RequiredArgsConstructor
public class GrpcStubFactory {
private final GrpcChannelFactory channelFactory;
private static final String CATALOG_SERVICE = "CATALOG-SERVICE";
public CatalogServiceGrpc.CatalogServiceBlockingStub getCatalogStub() {
ManagedChannel channel = channelFactory.getChannel(CATALOG_SERVICE);
return CatalogServiceGrpc.newBlockingStub(channel);
}
}Now the BFF controllers can accept REST requests and translate them into gRPC calls:
@RestController
@RequestMapping("/api/v1/categories")
@RequiredArgsConstructor
public class CategoryController {
private final GrpcStubFactory grpcStubFactory;
private final GatewayMapper gatewayMapper;
@PostMapping
public ResponseEntity<?> createCategory(
@Valid @RequestBody CreateCategoryRequestDto requestDto) {
CreateCategoryRequest grpcRequest =
gatewayMapper.toCreateCategoryRequest(requestDto);
CategoryResponse grpcResponse = grpcStubFactory
.getCatalogStub()
.createCategory(grpcRequest);
CategoryDto response =
gatewayMapper.toCategoryDto(grpcResponse.getResponse());
return ResponseBuilder.created(
"Category created successfully", response);
}
}JSON comes in, gets mapped to a Protobuf request, travels over gRPC to the Catalog Service, comes back as a Protobuf response, gets mapped to a JSON-friendly DTO, goes out to the client. The full loop.
And yeah — more mapping. The BFF has its own mapper converting between DTOs and Protobuf messages. That mapping tax from Part 3 isn't going away. It's actually getting worse because now there's conversion at two layers — the BFF and the service.
I later found a way to reduce this. By adding protobuf-java-util as a dependency, Jackson can serialize Protobuf objects directly to JSON. That let me drop the DTOs in the BFF and pass Protobuf objects straight through the controllers:
@PostMapping
public ResponseEntity<?> createMachine(
@Valid @RequestBody CreateMachineRequest request) {
MachineResponse response = grpcStubFactory
.getCatalogStub()
.createMachine(request);
return ResponseBuilder.created(
"Machine created successfully", response.getMachine());
}Two fewer conversions per request. Not a small win.
What's Missing
This setup works, but there's a gap I could already see forming.
The GrpcChannelFactory calls getNextServerFromEureka() — which gives me one instance. If I had three instances of the Catalog Service running, this code would pick one and stick with it. There's no load balancing. There's no automatic failover if that instance goes down.
The reason is that I'm manually doing what gRPC's infrastructure is supposed to handle. I'm looking up a service, picking an instance, and building a channel to that specific address. gRPC actually has built-in support for load balancing — round_robin, pick_first — but it needs a list of addresses to balance across, and it has no idea what Eureka is.
Teaching gRPC about Eureka is the next problem to solve. It involves writing a custom NameResolverProvider — essentially inventing a new URI scheme that gRPC understands — and it turned into one of the more interesting detours in this entire project.
But that's Part 5.
Resources That Helped
I do most of my learning through videos, I don’t know why, but this was really helpful in getting started and even understanding building channels, the idea of a name resolver, load balacing.
- •
