Service Discovery & API Gateway Patterns
How microservices find each other: client-side vs server-side discovery, service registries (Consul, Kubernetes DNS), API gateway responsibilities, YARP for .NET, the BFF pattern, and when to use an API gateway vs service mesh.
In a microservices system, services come and go. Instances scale up and down. IPs change. Kubernetes assigns new IP addresses on every pod restart.
How does Service A know where to find Service B?
That's the service discovery problem.
The Problem
In a monolith, function calls don't need routing β they're in-process. In microservices, every call is a network call to an address that can change.
Static configuration (naive, breaks quickly):
Order Service config: payment-service-url = "10.0.0.45:8080"
Problem: what happens when:
- Payment Service restarts (new IP)
- Payment Service scales to 3 instances
- Payment Service moves to a different node
Answer: Order Service breaks, and you're manually updating configsService discovery solves this: services register their location in a central registry. Clients look up locations dynamically.
Client-Side Discovery
The client queries the service registry directly and picks which instance to call.
Flow:
1. Payment Service starts β registers with registry
{"service": "payment-service", "ip": "10.0.0.45", "port": 8080}
2. Order Service wants to call Payment Service:
a. Query registry: "where are instances of payment-service?"
b. Registry returns: ["10.0.0.45:8080", "10.0.0.46:8080", "10.0.0.47:8080"]
c. Order Service picks one (round robin, random, etc.)
d. Makes HTTP call to chosen instanceββββββββββββββββ 1. register ββββββββββββββββ
β Payment Svc βββββββββββββββ β Registry β
β (3 instances)β β (Consul, β
ββββββββββββββββ β Eureka) β
ββββββββ¬ββββββββ
ββββββββββββββββ 2. lookup β
β Order Svc βββββββββββββββββββββββ β
β β 3. returns addresses β
β β βββββββββββββββββββββ β
β β
β β 4. direct call
β βββββββββββββββββββ Payment Svc instance
ββββββββββββββββNetflix Eureka is the classic example β used in Netflix's Spring Cloud ecosystem.
Pros: Client has full control over load balancing algorithm.
Cons: Every client must implement discovery logic. Every service SDK must embed the registry client. Couples clients to the registry implementation.
Server-Side Discovery
A load balancer or proxy sits in front of service instances. Clients call the load balancer, which queries the registry and routes to the correct instance.
Flow:
1. Payment Service registers with registry
2. Order Service calls: http://payment-service/ (no IP, just name)
3. Load balancer receives request
4. Load balancer queries registry: "where are instances of payment-service?"
5. Load balancer routes to a healthy instanceββββββββββββββββ ββββββββββββββββ query ββββββββββββββββ
β Order Svc βββββββββββ β Load Balancerββββββββββ β Registry β
β β β β β (Consul) β
ββββββββββββββββ ββββββββ¬ββββββββ ββββββββββββββββ
β
ββββββββββββ΄βββββββββββ
ββββββΌββββ βββββββΌβββ
βPay Svc1β βPay Svc2β
ββββββββββ ββββββββββPros: Client is simple β just call a service name. Load balancing logic is centralized.
Cons: Load balancer must be highly available (it's now a critical path component).
Kubernetes uses server-side discovery via kube-proxy and DNS. More on this below.
Service Registries
Consul (HashiCorp)
Consul is a service registry with health checking, key-value store, and service mesh capabilities.
# Register a service with Consul
curl -X PUT http://consul:8500/v1/agent/service/register \
-H "Content-Type: application/json" \
-d '{
"Name": "payment-service",
"ID": "payment-service-1",
"Address": "10.0.0.45",
"Port": 8080,
"Check": {
"HTTP": "http://10.0.0.45:8080/health",
"Interval": "10s",
"Timeout": "3s"
}
}'
# Discover service instances
curl http://consul:8500/v1/health/service/payment-service?passing=trueConsul removes unhealthy instances automatically via health check failures.
etcd
etcd is a distributed key-value store used by Kubernetes to store all cluster state β including service locations. Applications can watch etcd for real-time updates when service locations change.
Kubernetes Built-In Service Discovery
Kubernetes has service discovery built in. Every service gets a stable DNS name.
# Kubernetes Service β creates a stable endpoint
apiVersion: v1
kind: Service
metadata:
name: payment-service
namespace: ecommerce
spec:
selector:
app: payment-service # routes to pods with this label
ports:
- port: 8080
targetPort: 8080
type: ClusterIP # internal onlyDNS resolution inside the cluster:
http://payment-service/ β same namespace
http://payment-service.ecommerce/ β cross-namespace
http://payment-service.ecommerce.svc.cluster.local/ β fully qualified
kube-proxy handles routing to actual pod IPs transparently.
The service name never changes even when pods restart.In Kubernetes, you don't need Consul or Eureka β the platform provides service discovery via DNS and kube-proxy.
// In .NET, simply use the Kubernetes service name
services.AddHttpClient<IPaymentClient, PaymentClient>(client =>
{
client.BaseAddress = new Uri("http://payment-service/");
// Kubernetes DNS resolves this to actual pod IPs
});API Gateway β The Front Door
An API gateway is a reverse proxy that sits between clients and your services. It handles concerns that span all services.
Without API Gateway:
Mobile App β directly calls Order Service, User Service, Product Service
Each service implements auth, rate limiting, logging separately
CORS headers, TLS, versioning all duplicated
With API Gateway:
Mobile App β API Gateway β Order Service
β User Service
β Product Service
API Gateway handles:
β Authentication / Authorization (JWT validation)
β Rate limiting (100 req/min per user)
β TLS termination (HTTPS at edge)
β Request routing (path-based)
β Request/response transformation
β Logging and correlation IDs
β CORS headers
β API versioningYARP β Reverse Proxy for .NET
YARP (Yet Another Reverse Proxy) is Microsoft's official .NET reverse proxy library. It's what powers Azure App Service routing internally.
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
app.MapReverseProxy();
app.Run();// appsettings.json
{
"ReverseProxy": {
"Routes": {
"orders-route": {
"ClusterId": "order-cluster",
"Match": { "Path": "/api/orders/{**catch-all}" },
"Transforms": [
{ "PathPattern": "/api/orders/{**catch-all}" }
]
},
"users-route": {
"ClusterId": "user-cluster",
"Match": { "Path": "/api/users/{**catch-all}" }
}
},
"Clusters": {
"order-cluster": {
"Destinations": {
"order-svc-1": { "Address": "http://order-service:8080/" }
}
},
"user-cluster": {
"Destinations": {
"user-svc-1": { "Address": "http://user-service:8080/" }
}
}
}
}
}Adding JWT validation middleware to YARP:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://your-identity-server/";
options.Audience = "api";
});
app.UseAuthentication();
app.UseAuthorization();
app.MapReverseProxy();Kong β API Gateway for Larger Setups
Kong is an open-source API gateway built on NGINX. Used by enterprise teams that need plugin ecosystems.
# Kong declarative config (deck)
services:
- name: order-service
url: http://order-service:8080
routes:
- name: orders-route
paths: ["/api/orders"]
plugins:
- name: jwt
- name: rate-limiting
config:
minute: 100
policy: local
- name: correlation-id
config:
header_name: X-Correlation-ID
generator: uuidAWS API Gateway
For AWS workloads, API Gateway is the managed solution. Integrates natively with Lambda, ALB, and ECS.
Features:
- Route to Lambda functions, HTTP backends, or ALB
- JWT/Cognito authorization out of the box
- Built-in throttling and quotas
- WebSocket support
- Usage plans per API key
- CloudWatch logging and X-Ray tracingThe tradeoff: AWS API Gateway adds ~10-50ms latency per request. For low-latency services, consider ALB + Lambda authorizer instead.
The BFF Pattern β Backend for Frontend
A Backend for Frontend (BFF) is a specialized API gateway layer built for a specific client type.
Problem:
Mobile app needs: minimal data (limited bandwidth)
Web app needs: rich data (multiple joined resources)
Partner API needs: raw data (full access)
One API tries to serve all three β compromises everywhere
Solution β BFF pattern:
Mobile App β Mobile BFF β Services (returns minimal payload)
Web App β Web BFF β Services (returns rich/composed payload)
Partners β Partner API Gateway β Services (full access with API keys)// Web BFF β composes data for the web client's needs
[ApiController]
[Route("web/orders")]
public class WebOrderController : ControllerBase
{
// Returns full order with product details and user info
// (web client shows a rich order details page)
[HttpGet("{id}")]
public async Task<OrderDetailViewModel> GetOrderDetail(Guid id)
{
var order = await _orderClient.GetAsync(id);
var user = await _userClient.GetAsync(order.UserId);
var products = await Task.WhenAll(
order.Items.Select(i => _productClient.GetAsync(i.ProductId)));
return new OrderDetailViewModel(order, user, products);
}
}
// Mobile BFF β same data, different shape
[ApiController]
[Route("mobile/orders")]
public class MobileOrderController : ControllerBase
{
// Returns compact summary (mobile has limited bandwidth)
[HttpGet("{id}")]
public async Task<OrderSummaryViewModel> GetOrderSummary(Guid id)
{
var order = await _orderClient.GetAsync(id);
return new OrderSummaryViewModel
{
Status = order.Status,
Total = order.Total,
ItemCount = order.Items.Count
};
}
}API Gateway vs Service Mesh
These are often confused because both handle inter-service communication.
| | API Gateway | Service Mesh (Istio, Linkerd) | |---|---|---| | Position | North-south (client β services) | East-west (service β service) | | Manages | External traffic | Internal traffic | | Auth | User authentication (JWT, OAuth) | Service-to-service mTLS | | Features | Routing, rate limiting, transformation | Retries, circuit breaking, observability | | Complexity | Low-moderate | High | | Examples | YARP, Kong, AWS API Gateway | Istio, Linkerd, Consul Connect |
Use both when: You have an API gateway for external traffic AND want consistent retry/mTLS for all internal service calls.
Use only an API gateway when: Small system, internal services trust each other, not enough complexity to justify a service mesh.
Use only a service mesh when: All clients are internal (no public API) and you want consistent observability/security for internal calls.
Most teams don't need a service mesh. Add it when you have >10 services and are hitting real problems with retry logic, mutual TLS, or observability β not as a default.
Key Takeaways
- Service discovery solves the "where is this service?" problem as instances come and go.
- Client-side discovery (Eureka): client queries registry and picks an instance.
- Server-side discovery (Kubernetes): platform routes transparently via stable DNS names.
- Kubernetes DNS (
http://service-name/) is the default discovery mechanism in Kubernetes β no Consul needed. - API Gateway handles: auth validation, rate limiting, routing, TLS termination, correlation IDs, CORS.
- YARP is the .NET-native reverse proxy for building API gateways. Kong for enterprise plugin needs. AWS API Gateway for Lambda/AWS workloads.
- BFF pattern: build separate API gateway layers for different client types (mobile, web, partners).
- API Gateway = north-south (external traffic). Service Mesh = east-west (internal traffic).
- Most teams don't need a service mesh β add one when you have >10 services and real complexity.
Enjoyed this article?
Explore the System Design learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.