Back to blog
System Designintermediate

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.

LearnixoApril 15, 20269 min read
MicroservicesSystem DesignService DiscoveryAPI GatewayKubernetesYARPBFF
Share:𝕏

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 configs

Service 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.

Bash
# 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=true

Consul 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.

YAML
# 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 only
DNS 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.

C#
// 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 versioning

YARP β€” 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.

C#
// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

var app = builder.Build();
app.MapReverseProxy();
app.Run();
JSON
// 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:

C#
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.

YAML
# 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: uuid

AWS 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 tracing

The 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)
C#
// 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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.