API Gateway Aggregation Pattern for Microservices

API Gateway Aggregation Pattern for Microservices

The API Gateway Aggregation Pattern addresses a critical challenge in microservices architectures: reducing client-side complexity when data must be fetched from multiple services. By introducing an aggregation layer between clients and backend services, this pattern minimizes network round trips, simplifies client logic, and provides a unified interface for complex multi-service operations.

The Problem: Client-Side Complexity and Network Overhead

In distributed microservices systems, rendering a single user interface often requires data from 5-10+ backend services. Consider a typical e-commerce product page:

Without aggregation, clients must:

  1. Make 6+ sequential or parallel HTTP requests
  2. Handle partial failures independently
  3. Implement client-side stitching logic
  4. Manage multiple authentication tokens
  5. Deal with varying response formats

This creates tight coupling between clients and backend service topology, increases mobile battery consumption, and makes UI code brittle.

The Solution: Aggregation at the Gateway

The API Gateway Aggregation Pattern centralizes multi-service orchestration behind a unified API. Instead of clients calling services directly, they invoke aggregate endpoints that:

When to Use This Pattern

Use API Gateway Aggregation when:

Avoid this pattern when:

Implementation in Go

Here’s a production-ready aggregation gateway in Go using concurrent fetching:

package gateway

import (
    "context"
    "encoding/json"
    "net/http"
    "sync"
    "time"
)

// AggregatedProductResponse combines data from multiple services
type AggregatedProductResponse struct {
    Product       *Product       `json:"product"`
    Inventory     *Inventory     `json:"inventory"`
    Pricing       *Pricing       `json:"pricing"`
    Reviews       *ReviewSummary `json:"reviews"`
    Errors        []string       `json:"errors,omitempty"`
}

// ProductAggregator coordinates multi-service calls
type ProductAggregator struct {
    productClient       *http.Client
    inventoryClient     *http.Client
    pricingClient       *http.Client
    reviewClient        *http.Client
    aggregationTimeout  time.Duration
}

// GetAggregatedProduct fetches data from 4 services concurrently
func (a *ProductAggregator) GetAggregatedProduct(
    ctx context.Context, 
    productID string,
) (*AggregatedProductResponse, error) {
    
    // Create timeout context for entire aggregation
    ctx, cancel := context.WithTimeout(ctx, a.aggregationTimeout)
    defer cancel()
    
    // Use errgroup for concurrent execution with error handling
    var (
        product   *Product
        inventory *Inventory
        pricing   *Pricing
        reviews   *ReviewSummary
        mu        sync.Mutex
        errors    []string
    )
    
    var wg sync.WaitGroup
    wg.Add(4)
    
    // Fetch product info
    go func() {
        defer wg.Done()
        p, err := a.fetchProduct(ctx, productID)
        if err != nil {
            mu.Lock()
            errors = append(errors, "product: "+err.Error())
            mu.Unlock()
            return
        }
        product = p
    }()
    
    // Fetch inventory
    go func() {
        defer wg.Done()
        i, err := a.fetchInventory(ctx, productID)
        if err != nil {
            mu.Lock()
            errors = append(errors, "inventory: "+err.Error())
            mu.Unlock()
            return
        }
        inventory = i
    }()
    
    // Fetch pricing
    go func() {
        defer wg.Done()
        pr, err := a.fetchPricing(ctx, productID)
        if err != nil {
            mu.Lock()
            errors = append(errors, "pricing: "+err.Error())
            mu.Unlock()
            return
        }
        pricing = pr
    }()
    
    // Fetch reviews
    go func() {
        defer wg.Done()
        r, err := a.fetchReviews(ctx, productID)
        if err != nil {
            // Reviews are non-critical; log but don't fail the request
            mu.Lock()
            errors = append(errors, "reviews: "+err.Error())
            mu.Unlock()
            return
        }
        reviews = r
    }()
    
    wg.Wait()
    
    // Fail request if critical data is missing
    if product == nil {
        return nil, fmt.Errorf("failed to fetch critical product data")
    }
    
    return &AggregatedProductResponse{
        Product:   product,
        Inventory: inventory,
        Pricing:   pricing,
        Reviews:   reviews,
        Errors:    errors,
    }, nil
}

// HTTP handler exposing the aggregated endpoint
func (a *ProductAggregator) HandleProductRequest(w http.ResponseWriter, r *http.Request) {
    productID := r.URL.Query().Get("id")
    if productID == "" {
        http.Error(w, "missing product id", http.StatusBadRequest)
        return
    }
    
    response, err := a.GetAggregatedProduct(r.Context(), productID)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(response)
}

Key Design Decisions:

  1. Concurrent Fetching: Uses goroutines to parallelize service calls, reducing total latency to max(service_latencies) instead of sum(service_latencies)

  2. Timeout Management: Context deadline ensures entire aggregation completes within SLA even if individual services are slow

  3. Partial Failure Handling: Non-critical services (reviews) can fail without breaking the request; errors are reported but don’t halt execution

  4. Critical Data Gating: Product data is marked critical—if it fails, the entire request fails

ReactJS Client Integration

The aggregation pattern dramatically simplifies client-side code:

// Before: Multiple service calls with complex state management
const ProductPage = ({ productId }) => {
  const [product, setProduct] = useState(null);
  const [inventory, setInventory] = useState(null);
  const [pricing, setPricing] = useState(null);
  const [reviews, setReviews] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    Promise.all([
      fetch(`/api/products/${productId}`).then(r => r.json()),
      fetch(`/api/inventory/${productId}`).then(r => r.json()),
      fetch(`/api/pricing/${productId}`).then(r => r.json()),
      fetch(`/api/reviews/${productId}`).then(r => r.json()),
    ])
    .then(([p, i, pr, r]) => {
      setProduct(p);
      setInventory(i);
      setPricing(pr);
      setReviews(r);
    })
    .finally(() => setLoading(false));
  }, [productId]);
  
  // Render logic...
};

// After: Single aggregated call
const ProductPage = ({ productId }) => {
  const { data, loading, error } = useFetch(
    `/api/aggregated/product?id=${productId}`
  );
  
  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  
  return (
    <div>
      <ProductInfo product={data.product} />
      <PricingDisplay pricing={data.pricing} />
      <InventoryStatus inventory={data.inventory} />
      <ReviewsSummary reviews={data.reviews} />
      {data.errors && <PartialErrors errors={data.errors} />}
    </div>
  );
};

Benefits:

Trade-offs and Considerations

Advantages

Reduced Client Complexity: Clients make 1 request instead of N
Improved Performance: Parallel backend fetching + reduced network hops
Centralized Cross-Cutting Concerns: Auth, logging, rate limiting in one place
Backend Flexibility: Change service topology without breaking clients
Optimized Payloads: Gateway can filter/transform data for specific client types

Disadvantages

Single Point of Failure: Gateway outage impacts all clients
Operational Complexity: Another service to deploy, monitor, scale
Latency Introduction: Additional hop adds 5-15ms per request
Coupling Risk: Gateway can become a “distributed monolith” if poorly designed
Cache Invalidation Complexity: Aggregated responses harder to cache effectively

Mitigation Strategies

For Single Point of Failure:

For Operational Complexity:

For Latency:

For Coupling:

Advanced Patterns

1. Selective Field Fetching (GraphQL-style)

Allow clients to specify which fields they need:

// Client specifies: ?fields=product,pricing
func (a *ProductAggregator) GetAggregatedProduct(
    ctx context.Context, 
    productID string,
    fields []string,
) (*AggregatedProductResponse, error) {
    // Only fetch requested fields
    if contains(fields, "product") {
        // fetch product
    }
    // etc.
}

2. Caching with Composite Keys

Cache partial responses to reduce backend load:

cacheKey := fmt.Sprintf("product:%s:fields:%s", productID, strings.Join(fields, ","))
if cached := cache.Get(cacheKey); cached != nil {
    return cached
}

3. Progressive Rendering

Return critical data immediately, stream remaining data:

// Return product+pricing immediately (critical for render)
// Stream inventory+reviews as they become available (SSE or WebSocket)

Conclusion

The API Gateway Aggregation Pattern is essential for microservices architectures serving mobile and web clients. By centralizing multi-service orchestration, it reduces client complexity, improves performance, and provides flexibility to evolve backend architectures independently. The pattern works best for read-heavy operations where data from 3+ services must be combined, and when implemented correctly with concurrent fetching, circuit breakers, and graceful degradation, it can dramatically improve both developer experience and end-user performance.

For Principal Engineers, the key decision is when to aggregate. Start with direct service calls for simple operations, introduce aggregation when clients routinely need data from 3+ services, and consider Backend-for-Frontend variants when different client types (mobile vs. web) have significantly different data needs.