Response Aggregation in API Gateway

Response Aggregation in API Gateway

Response Aggregation allows the API Gateway to call multiple microservices internally and send a single, combined response to the client, improving performance, reducing complexity, and simplifying frontend code.

What is Response Aggregation?

In a microservices architecture, we break the application into smaller, independent services. Each service is responsible for a specific business area. For example:

  • The Order Service handles customer orders.
  • The User Service manages customer profiles.
  • The Product Service stores product details.
  • The Payment Service manages payment tracking and invoices.

This separation helps make systems scalable and independent. But it also creates a new problem, Data Fragmentation.

Let’s say a user opens your shopping app to view their Order Summary. That single page needs:

  • User details (from the User Service),
  • Order details (from the Order Service),
  • Product details (from the Product Service), and
  • Payment information (from the Payment Service).

If your frontend has to call all four services separately, it will increase:

  • The number of API requests,
  • The total load time, and
  • The complexity of handling errors, retries, and timing issues.

This creates unnecessary complexity and performance issues on the client side. This is where Response Aggregation solves the problem.

Response Aggregation means the API Gateway collects responses from multiple microservices, combines them, and sends one unified response back to the client.

So now, instead of making four separate API calls, your frontend makes just 1 to the Gateway. The Gateway calls all required services behind the scenes, merges their data, and returns a single structured response. The client’s workload becomes extremely simple: one request, one response.

The system becomes faster because the Gateway can call multiple services in parallel, while the frontend would normally call them one after the other. So, response aggregation is not just about merging responses; it’s about making the client experience effortless.

This approach makes the system faster, simpler, and more efficient, especially for data-intensive screens such as dashboards, summaries, and reports.

Why Do We Need Response Aggregation?

In real-world applications, most screens require data from multiple services. Consider an e-commerce application. When the customer opens their Order Summary Screen, the system needs user info from User Service, products from Product Service, payment from Payment Service, and order history from Order Service.

Without aggregation, the frontend (React / Angular / Mobile App) has to:

  1. Make multiple API calls.
  2. Wait for all calls to finish.
  3. Handle errors for each service.
  4. Then merge the results manually.
  5. This increases UI complexity and maintenance cost.

That slows down the page, increases code complexity, and causes duplicate logic across multiple clients (Web App, Mobile App, Admin Portal). Response Aggregation moves that entire headache to the API Gateway.

The Gateway becomes a smart layer:

  • It knows which services to call.
  • It combines everything into a single response.
  • It hides the internal microservices structure from the client.

This means:

  • Faster Page Loading → Fewer client-to-server round-trips.
  • Cleaner Frontend Code → Only one API call is required.
  • Less Bandwidth → Response is smaller and optimized.
  • No Duplication → All clients get the same aggregated result.

Instead of the frontend doing the heavy lifting, the Gateway does it. This keeps clients thin and microservices independent. The API Gateway hides all backend complexity and provides a single, clean, optimized response to the UI or external consumers. This not only speeds up the experience but also ensures consistency; every client receives data in the same structure.

How Does Response Aggregation Work?

Let’s go step by step using our E-Commerce example.

Step 1: Client Request

The client (maybe React, Angular, or a mobile app) makes a single request:

GET /api/order-summary/1001

The client doesn’t know:

  • Where Order Service is hosted,
  • That User Service exists,
  • The Payment Service provides that payment status.

To the client, Gateway = one backend. The frontend doesn’t care where the data comes from; it just wants complete order details.

Step 2: Gateway Coordination

Inside the API Gateway, this happens behind the scenes:

  1. The Gateway first calls the Order Service to fetch order details. This response contains the OrderId, UserId, and a list of ProductIds.
  2. Next, using the UserId, the Gateway calls the User Service to retrieve customer information such as name, email, and address.
  3. Using ProductIds, it then calls the Product Service for each product in the order to retrieve product name, description, and image.
  4. Finally, using OrderId, it calls the Payment Service to fetch payment status, method, and transaction ID.

All these requests can be executed asynchronously and in parallel to save time.

Please Note:

  • The client only made one call, but the Gateway made multiple internal calls.
  • The frontend is completely unaware of internal microservice calls.
Step 3: Response Merging

After receiving all responses, the Gateway merges them into one unified model, for example, an OrderSummaryResponseDTO:

{
    "Success": true,
    "Data": {
        "OrderId": "5cfe4eea-b26a-4fee-803d-0edca3399213",
        "Order": {
            "OrderNumber": "ORD20251023A3399213",
            "OrderDate": "2025-10-23T02:32:11.3717013",
            "Status": "Confirmed",
            "SubTotalAmount": 1099.97,
            "DiscountAmount": 135.00,
            "ShippingCharges": 100.00,
            "TaxAmount": 173.69,
            "TotalAmount": 1238.66,
            "Currency": "INR",
            "PaymentMethod": "COD"
        },
        "Customer": {
            "UserId": "40112cde-de93-487e-b2c1-6b6ba8e5fe62",
            "FullName": "Pranaya Rout",
            "Email": "pranayakumar777@gmail.com",
            "Mobile": "9853977973",
            "ProfilePhotoUrl": null
        },
        "Products": [
            {
                "ProductId": "a4b5c6d7-9c0d-4d45-af10-5e6789abcdef",
                "Name": "Super Smartphone X1",
                "SKU": "MOB-SUP-2025-BCDE",
                "ImageUrl": null,
                "Quantity": 1,
                "UnitPrice": 649.99,
                "LineTotal": 649.99
            },
            {
                "ProductId": "e2f3d4c5-7a8b-4b23-8d9e-3c456789abcd",
                "Name": "Wireless Headphones Pro",
                "SKU": "ELE-WIR-2025-BCDE",
                "ImageUrl": null,
                "Quantity": 2,
                "UnitPrice": 169.99,
                "LineTotal": 339.98
            }
        ],
        "Payment": {
            "PaymentId": "11111111-1111-1111-1111-111111111111",
            "Status": "Paid",
            "Method": "Online",
            "PaidOn": "2025-11-08T12:56:44.1041074Z",
            "TransactionReference": "DEMO-TXN-PLACEHOLDER"
        },
        "IsPartial": false,
        "Warnings": []
    },
    "Message": null,
    "Errors": null
}

This final JSON is returned to the frontend. From the client’s perspective, it received everything in one go, without making multiple backend calls.

Example to Understand Response Aggregation in Microservices:

Let’s now go step by step to implement a real-time Response Aggregation example in API Gateway based on an Order ID that fetches and combines:

  • Order Details (from Order Service)
  • Customer Details (from User Service)
  • Product Details (from Product Service)
  • Payment Details (from Payment Service)

First, add project references to OrderService.Application, ProductService.Application, PaymentService.Application, UserService.Application from the APIGateway project. These projects contain the DTOs, which we will use in API Gateway.

DTOs for the aggregated response

First, create a folder named DTOs inside the APIGateway project. Then, inside the DTOs folder, create the following subfolders:

  • OrderSummary
  • Common
OrderSummaryResponseDTO

Create a class file named OrderSummaryResponseDTO.cs within the DTOs/OrderSummary folder, then copy-paste the following code. This class combines all information about an order into a single response model. It includes order details, customer details, product list, and payment info. It also shows if any part of the data could not be loaded by using the IsPartial and Warnings fields.

namespace APIGateway.DTOs.OrderSummary
{
    public class OrderSummaryResponseDTO
    {
        public Guid OrderId { get; set; }
        public OrderInfoDTO Order { get; set; } = null!;
        public CustomerInfoDTO? Customer { get; set; }
        public List<OrderProductInfoDTO> Products { get; set; } = new();
        public PaymentInfoDTO? Payment { get; set; }
        public bool IsPartial { get; set; }
        public List<string> Warnings { get; set; } = new();
    }
}
OrderInfoDTO

Create a class file named OrderInfoDTO.cs within the DTOs/OrderSummary folder, then copy-paste the following code. This class stores the main order details, such as order number, date, status, subtotal, discounts, and total amount. It’s used to represent only the important order data that should be shown to users.

namespace APIGateway.DTOs.OrderSummary
{
    public class OrderInfoDTO
    {
        public string OrderNumber { get; set; } = null!;
        public DateTime OrderDate { get; set; }
        public string Status { get; set; } = null!;
        public decimal SubTotalAmount { get; set; }
        public decimal DiscountAmount { get; set; }
        public decimal ShippingCharges { get; set; }
        public decimal TaxAmount { get; set; }
        public decimal TotalAmount { get; set; }
        public string Currency { get; set; } = "INR";
        public string PaymentMethod { get; set; } = null!;
    }
}
CustomerInfoDTO

Create a class file named CustomerInfoDTO.cs within the DTOs/OrderSummary folder, then copy-paste the following code. This class contains basic customer details, including name, email, mobile number, and profile photo. It represents customer information from the User Service.

namespace APIGateway.DTOs.OrderSummary
{
    public class CustomerInfoDTO
    {
        public Guid UserId { get; set; }
        public string? FullName { get; set; }
        public string? Email { get; set; }
        public string? Mobile { get; set; }
        public string? ProfilePhotoUrl { get; set; }
    }
}
OrderProductInfoDTO

Create a class file named OrderProductInfoDTO.cs within the DTOs/OrderSummary folder, then copy-paste the following code. This class stores details of each product in the order. It includes product name, image, price, quantity, and SKU. It helps display the list of purchased products clearly in the response.

namespace APIGateway.DTOs.OrderSummary
{
    public class OrderProductInfoDTO
    {
        public Guid ProductId { get; set; }
        public string Name { get; set; } = null!;
        public string? SKU { get; set; }
        public string? ImageUrl { get; set; }
        public int Quantity { get; set; }
        public decimal UnitPrice { get; set; }
        public decimal LineTotal => UnitPrice * Quantity;
    }
}
PaymentInfoDTO

Create a class file named PaymentInfoDTO.cs within the DTOs/OrderSummary folder, then copy-paste the following code. This class represents payment-related details, such as payment ID, method, status, transaction reference, and payment date. It helps show how and when the payment was made for an order.

namespace APIGateway.DTOs.OrderSummary
{
    public class PaymentInfoDTO
    {
        public Guid? PaymentId { get; set; }
        public string Status { get; set; } = "Unknown";
        public string Method { get; set; } = "Unknown";
        public DateTime? PaidOn { get; set; }
        public string? TransactionReference { get; set; }
    }
}
Common ApiResponse

Create a class file named ApiResponse.cs within the DTOs/Common folder, then copy-paste the following code. This is the common response format used across all services. It wraps the actual data and adds additional information, such as success status, a message, and errors. It keeps all responses consistent.

namespace APIGateway.DTOs.Common
{
    public class ApiResponse<T>
    {
        public bool Success { get; set; }
        public T? Data { get; set; }
        public string? Message { get; set; }
        public List<string>? Errors { get; set; }

        public static ApiResponse<T> SuccessResponse(T data, string? message = null)
        {
            return new ApiResponse<T>
            {
                Success = true,
                Data = data,
                Message = message
            };
        }

        public static ApiResponse<T> FailResponse(string message, List<string>? errors = null, T? data = default)
        {
            return new ApiResponse<T>
            {
                Success = false,
                Data = data,
                Message = message,
                Errors = errors
            };
        }
    }
}

Aggregation Service:

First, create a folder named Services inside the APIGateway project.

IOrderSummaryAggregator

Create an interface named IOrderSummaryAggregator.cs within the Services folder, then copy-paste the following code. This interface defines the contract for the aggregator service. It declares one method, GetOrderSummaryAsync, which must be implemented to fetch and combine data from multiple microservices.

using APIGateway.DTOs.OrderSummary;
namespace APIGateway.Services
{
    public interface IOrderSummaryAggregator
    {
        Task<OrderSummaryResponseDTO?> GetOrderSummaryAsync(Guid orderId);
    }
}
OrderSummaryAggregator

Create an interface named OrderSummaryAggregator.cs within the Services folder, then copy-paste the following code. This is the main class that performs the response aggregation. It calls different microservices like Order, User, Product, and Payment, gathers their data, and merges it into one combined response.

using APIGateway.DTOs.Common;
using APIGateway.DTOs.OrderSummary;
using OrderService.Application.DTOs.Order;
using ProductService.Application.DTOs;
using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using UserService.Application.DTOs;

namespace APIGateway.Services
{
    // Aggregates order details from multiple microservices (Order, User, Product, Payment)
    // to produce a unified Order Summary response for the client.
    public class OrderSummaryAggregator : IOrderSummaryAggregator
    {
        private readonly IHttpClientFactory _httpClientFactory;
        private readonly ILogger<OrderSummaryAggregator> _logger;
        private readonly IHttpContextAccessor _httpContextAccessor;

        // Global JSON deserialization options for all downstream calls.
        //  - Case-insensitive property matching
        //  - Preserve original property naming
        //  - Enum values as strings
        private static readonly JsonSerializerOptions JsonOptions = new()
        {
            PropertyNameCaseInsensitive = true,
            PropertyNamingPolicy = null,
            Converters = { new JsonStringEnumConverter() }
        };

        public OrderSummaryAggregator(
            IHttpClientFactory httpClientFactory,
            ILogger<OrderSummaryAggregator> logger,
            IHttpContextAccessor httpContextAccessor)
        {
            _httpClientFactory = httpClientFactory;
            _logger = logger;
            _httpContextAccessor = httpContextAccessor;
        }

        // Entry point that aggregates order, customer, product, and payment information
        // from their respective microservices.
        public async Task<OrderSummaryResponseDTO?> GetOrderSummaryAsync(Guid orderId)
        {
            // 1. Fetch Order first — this is the root entity that ties all others.
            var order = await FetchOrderAsync(orderId);
            if (order == null)
                return null;

            // Prepare the aggregate response container.
            var result = new OrderSummaryResponseDTO
            {
                OrderId = order.OrderId,
                Order = new OrderInfoDTO
                {
                    OrderNumber = order.OrderNumber,
                    OrderDate = order.OrderDate,
                    Status = order.OrderStatus.ToString(),
                    SubTotalAmount = order.SubTotalAmount,
                    DiscountAmount = order.DiscountAmount,
                    ShippingCharges = order.ShippingCharges,
                    TaxAmount = order.TaxAmount,
                    TotalAmount = order.TotalAmount,
                    PaymentMethod = order.PaymentMethod.ToString()
                }
            };

            var userId = order.UserId;
            var items = order.Items ?? new List<OrderItemResponseDTO>();

            // 2️. Call other dependent microservices concurrently
            // to minimize response latency.
            var customerTask = FetchCustomerAsync(userId);
            var productsTask = FetchProductsAsync(items);
            var paymentTask = FetchPaymentAsync(orderId);

            // Run all API calls in parallel (non-blocking)
            await Task.WhenAll(customerTask, productsTask, paymentTask);

            // 3️. Aggregate responses and track partial failures.

            // Customer
            if (customerTask.Result != null)
            {
                result.Customer = customerTask.Result;
            }
            else
            {
                result.IsPartial = true;
                result.Warnings.Add("Customer details could not be loaded.");
            }

            // Products
            if (productsTask.Result.Any())
            {
                result.Products = productsTask.Result;
            }
            else
            {
                result.IsPartial = true;
                result.Warnings.Add("Product details could not be fully loaded.");
            }

            // Payment
            if (paymentTask.Result != null)
            {
                result.Payment = paymentTask.Result;
            }
            else
            {
                result.IsPartial = true;
                result.Warnings.Add("Payment details not available.");
            }

            // Return a unified object even if some data sources failed.
            return result;
        }

        // ---------------------- Downstream Call Helpers ----------------------

        // Fetches order details from the Order microservice.
        private async Task<OrderResponseDTO?> FetchOrderAsync(Guid orderId)
        {
            try
            {
                var client = _httpClientFactory.CreateClient("OrderService");
                var httpResponse = await client.GetAsync($"/api/Order/{orderId}");

                if (httpResponse.StatusCode == HttpStatusCode.NotFound)
                    return null;

                httpResponse.EnsureSuccessStatusCode();

                // Deserialize wrapped ApiResponse<OrderResponseDTO>
                var apiResponse =
                    await httpResponse.Content.ReadFromJsonAsync<ApiResponse<OrderResponseDTO>>(JsonOptions);

                if (apiResponse?.Success != true || apiResponse.Data is null)
                {
                    _logger.LogWarning("OrderService returned invalid response for {OrderId}", orderId);
                    return null;
                }

                return apiResponse.Data;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to fetch Order for orderId: {OrderId}", orderId);
                return null;
            }
        }

        // Fetches user profile information for the customer who placed the order.
        private async Task<CustomerInfoDTO?> FetchCustomerAsync(Guid userId)
        {
            try
            {
                var client = _httpClientFactory.CreateClient("UserService");

                // Call User microservice to get user profile
                var httpResponse = await client.GetAsync($"/api/User/profile/{userId}/");

                if (httpResponse.StatusCode == HttpStatusCode.NotFound)
                    return null;

                httpResponse.EnsureSuccessStatusCode();

                var apiResponse =
                    await httpResponse.Content.ReadFromJsonAsync<ApiResponse<ProfileDTO>>(JsonOptions);

                if (apiResponse?.Success != true || apiResponse.Data is null)
                    return null;

                // Map user profile into a lightweight DTO for aggregation
                return new CustomerInfoDTO
                {
                    UserId = apiResponse.Data.UserId,
                    FullName = apiResponse.Data.FullName,
                    Email = apiResponse.Data.Email,
                    Mobile = apiResponse.Data.PhoneNumber,
                    ProfilePhotoUrl = apiResponse.Data.ProfilePhotoUrl
                };
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to fetch user profile for {UserId}", userId);
                return null;
            }
        }

        // Fetches detailed product information for all items in the order.
        // Uses a bulk endpoint to reduce round trips.
        private async Task<List<OrderProductInfoDTO>> FetchProductsAsync(IEnumerable<OrderItemResponseDTO> items)
        {
            var result = new List<OrderProductInfoDTO>();

            // Extract distinct product IDs to avoid redundant requests.
            var productIds = items
                .Select(i => i.ProductId)
                .Where(id => id != Guid.Empty)
                .Distinct()
                .ToList();

            if (!productIds.Any())
                return result;

            var client = _httpClientFactory.CreateClient("ProductService");

            // Forward the same Authorization header from the incoming request
            var authHeader = _httpContextAccessor.HttpContext?.Request.Headers["Authorization"].ToString();
            if (!string.IsNullOrWhiteSpace(authHeader))
            {
                client.DefaultRequestHeaders.Authorization =
                    System.Net.Http.Headers.AuthenticationHeaderValue.Parse(authHeader);
            }

            HttpResponseMessage httpResponse;

            try
            {
                // POST: /api/products/GetByIds  (Bulk fetch)
                httpResponse = await client.PostAsJsonAsync("/api/products/GetByIds", productIds);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex,
                    "Error calling ProductService GetProductByIds for products: {ProductIds}",
                    string.Join(", ", productIds));
                return result;
            }

            if (httpResponse.StatusCode == HttpStatusCode.NotFound)
            {
                _logger.LogWarning("No products found for IDs: {ProductIds}", string.Join(", ", productIds));
                return result;
            }

            if (!httpResponse.IsSuccessStatusCode)
            {
                var body = await httpResponse.Content.ReadAsStringAsync();
                _logger.LogError("ProductService GetProductByIds returned {StatusCode}. Payload: {Body}",
                    httpResponse.StatusCode, body);
                return result;
            }

            ApiResponse<List<ProductDTO>>? apiResponse;

            try
            {
                apiResponse = await httpResponse.Content
                    .ReadFromJsonAsync<ApiResponse<List<ProductDTO>>>(JsonOptions);
            }
            catch (Exception ex)
            {
                var body = await httpResponse.Content.ReadAsStringAsync();
                _logger.LogError(ex,
                    "Failed to deserialize ProductService GetProductByIds response. Payload: {Body}",
                    body);
                return result;
            }

            if (apiResponse?.Success != true || apiResponse.Data is null || !apiResponse.Data.Any())
            {
                _logger.LogWarning("ProductService GetProductByIds returned empty/invalid data.");
                return result;
            }

            // Build a lookup dictionary for fast matching between order lines and product data
            var productLookup = apiResponse.Data
                .ToDictionary(x => x.Id, x => x);

            // Map each order item with its corresponding product details
            foreach (var item in items)
            {
                if (!productLookup.TryGetValue(item.ProductId, out var p))
                {
                    _logger.LogWarning("Product {ProductId} from order not returned by ProductService.", item.ProductId);
                    continue;
                }

                result.Add(new OrderProductInfoDTO
                {
                    ProductId = p.Id,
                    Name = p.Name,
                    SKU = p.SKU,
                    ImageUrl = p.PrimaryImageUrl,
                    Quantity = item.Quantity,
                    UnitPrice = item.DiscountedPrice // Use order price as the true source of value
                });
            }

            return result;
        }

        // Fetches payment details for the given order.
        // Currently stubbed; to be replaced when PaymentService exposes a GET-by-OrderId API.
        private async Task<PaymentInfoDTO?> FetchPaymentAsync(Guid orderId)
        {
            // NOTE:
            // Currently, there is NO endpoint in PaymentService to get payment details by OrderId.
            // This method returns hardcoded data for demo purposes.

            _logger.LogInformation(
                "Payment details for OrderId {OrderId} are currently stubbed. Integration pending.", orderId);

            await Task.CompletedTask;

            // Hardcoded sample payment (used until PaymentService endpoint is ready)
            return new PaymentInfoDTO
            {
                PaymentId = Guid.Parse("11111111-1111-1111-1111-111111111111"),
                Status = "Paid",
                Method = "Online",
                PaidOn = DateTime.UtcNow.AddMinutes(-5),
                TransactionReference = "DEMO-TXN-PLACEHOLDER"
            };
        }
    }
}
Aggregation Controller

Create an API Empty controller named OrderSummaryController within the Controllers folder of the APIGateway project, then copy-paste the following code. This controller exposes an API endpoint /gateway/order-summary/{orderId}. It handles client requests, calls the aggregator service, manages errors, and returns the final combined response to the user.

using APIGateway.DTOs.Common;
using APIGateway.DTOs.OrderSummary;
using APIGateway.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace APIGateway.Controllers
{
    // API Gateway controller that exposes an aggregated Order Summary endpoint.
    // Combines data from multiple microservices (Order, User, Product, Payment)
    // into a single unified response for client consumption.
    [ApiController]
    [Route("gateway/order-summary")]
    public class OrderSummaryController : ControllerBase
    {
        private readonly IOrderSummaryAggregator _aggregator;
        private readonly ILogger<OrderSummaryController> _logger;

        // Constructor injection for dependency management.
        public OrderSummaryController(
            IOrderSummaryAggregator aggregator,
            ILogger<OrderSummaryController> logger)
        {
            _aggregator = aggregator;
            _logger = logger;
        }

        // Fetches a fully aggregated order summary for the given <paramref name="orderId"/>.
        // This endpoint is protected by JWT authentication and requires a valid token.
        [Authorize] // Ensures only authenticated users can access this resource
        [HttpGet("{orderId:guid}")]
        public async Task<ActionResult<ApiResponse<OrderSummaryResponseDTO>>> GetOrderSummary(Guid orderId)
        {
            try
            {
                // Log the start of aggregation with contextual metadata for observability.
                _logger.LogInformation("Aggregating response for OrderId {OrderId}", orderId);

                // Delegate the core orchestration logic to the aggregator service.
                var summary = await _aggregator.GetOrderSummaryAsync(orderId);

                // CASE 1: Order not found in downstream service.
                if (summary is null)
                {
                    // Return a standardized 404 API response using a common response wrapper.
                    return NotFound(
                        ApiResponse<OrderSummaryResponseDTO>.FailResponse(
                            $"Order with id {orderId} not found."));
                }

                // CASE 2: Successful aggregation — return unified summary data.
                return Ok(
                    ApiResponse<OrderSummaryResponseDTO>.SuccessResponse(summary));
            }
            catch (Exception ex)
            {
                // CASE 3: Unexpected runtime failure (e.g., downstream service unavailable).
                // Log error with contextual information for distributed tracing.
                _logger.LogError(ex,
                    "Error while aggregating order summary for OrderId {OrderId}", orderId);

                // Return structured 500 response with user-friendly message.
                return StatusCode(
                    StatusCodes.Status500InternalServerError,
                    ApiResponse<OrderSummaryResponseDTO>.FailResponse(
                        "An unexpected error occurred while retrieving the order summary."));
            }
        }
    }
}
appsettings.json: Adding Downstream URLs

Please modify the appsettings.json file as follows. Here, we are adding the base URLs for the downstream microservices.

{
  "AllowedHosts": "*",

  "JwtSettings": {
    "Issuer": "UserService.API",
    "SecretKey": "fPXxcJw8TW5sA+S4rl4tIPcKk+oXAqoRBo+1s2yjUS4="
  },

  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Error",
        "System": "Error",
        "Ocelot": "Error"
      }
    },

    "Properties": {
      "Application": "APIGateway",
      "Environment": "Development"
    },

    "WriteTo": [
      {
        "Name": "Async",
        "Args": {
          "configure": [
            {
              "Name": "Console",
              "Args": {
                "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss} [{Level:u3}] [{Application}/{Environment}] CorrelationId={CorrelationId} {Message:lj}{NewLine}{Exception}"
              }
            }
          ]
        }
      },
      {
        "Name": "Async",
        "Args": {
          "configure": [
            {
              "Name": "File",
              "Args": {
                "path": "logs/UserService-.log",
                "rollingInterval": "Day",
                "retainedFileCountLimit": 30,
                "shared": true,
                "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss} [{Level:u3}] [{Application}/{Environment}] CorrelationId={CorrelationId} {Message:lj}{NewLine}{Exception}"
              }
            }
          ]
        }
      }
    ]
  },

  "ServiceUrls": {
    "OrderService": "https://localhost:7082",
    "UserService": "https://localhost:7269",
    "ProductService": "https://localhost:7234",
    "PaymentService": "https://localhost:7154"
  }
}
Register HttpClients + Aggregator in Program Class:

Please modify the Program class as follows. This is the entry point of the API Gateway application. It configures controllers, logging, JWT authentication, Ocelot routes, and dependency injection. It ensures both normal gateway routes and Ocelot proxy routes work together smoothly.

using APIGateway.Middlewares;
using APIGateway.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json.Serialization;
using Ocelot.DependencyInjection;
using Ocelot.Middleware;
using Serilog;
using System.Text;

namespace APIGateway
{
    public class Program
    {
        public static async Task Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // MVC Controllers + Newtonsoft JSON Configuration
            builder.Services
                .AddControllers()
                .AddNewtonsoftJson(options =>
                {
                    // Newtonsoft.Json used instead of System.Text.Json
                    // because it provides finer control over property naming
                    // and serialization behavior.
                    options.SerializerSettings.ContractResolver = new DefaultContractResolver
                    {
                        // Preserve property names as defined in the DTOs.
                        // No camelCasing or snake_casing transformation.
                        NamingStrategy = new DefaultNamingStrategy()
                    };

                    // Optional: Uncomment if you want Enum values serialized as strings
                    // options.SerializerSettings.Converters.Add(new Newtonsoft.Json.Converters.StringEnumConverter());
                });

            // Ocelot Configuration (API Gateway Routing Layer)
            // Ocelot routes all requests (except /gateway/*) to downstream microservices.
            // Configuration is loaded from ocelot.json at startup, with hot reload support.
            builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true);
            builder.Services.AddOcelot(builder.Configuration);

            // Structured Logging Setup (Serilog)
            // Serilog provides rich structured logging that integrates well
            // with centralized monitoring systems (Seq, Kibana, etc.)
            Log.Logger = new LoggerConfiguration()
                .ReadFrom.Configuration(builder.Configuration)
                .Enrich.FromLogContext()
                .CreateLogger();

            builder.Host.UseSerilog(); // Replace default .NET logger with Serilog.

            builder.Services.AddEndpointsApiExplorer();
            builder.Services.AddSwaggerGen();

            // JWT Authentication (Bearer Token Validation)
            // The gateway validates JWTs before forwarding or processing requests.
            // It acts as a first line of security across all microservices.
            builder.Services
                .AddAuthentication(options =>
                {
                    // Define the default authentication scheme as Bearer
                    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
                })
                .AddJwtBearer(options =>
                {
                    // Token validation configuration
                    options.TokenValidationParameters = new TokenValidationParameters
                    {
                        ValidateIssuer = true,
                        ValidIssuer = builder.Configuration["JwtSettings:Issuer"],

                        // We’re not validating audience because microservices share same gateway.
                        ValidateAudience = false,

                        // Enforce token expiry check
                        ValidateLifetime = true,

                        // Ensure token signature integrity using secret key
                        ValidateIssuerSigningKey = true,
                        IssuerSigningKey = new SymmetricSecurityKey(
                            Encoding.UTF8.GetBytes(builder.Configuration["JwtSettings:SecretKey"]!)
                        ),

                        // No extra grace period for expired tokens
                        ClockSkew = TimeSpan.Zero
                    };
                });

            builder.Services.AddAuthorization(); // Enables [Authorize] attributes.

            // Downstream Microservice Clients (typed HttpClientFactory)
            // Each downstream service (Order, User, Product, Payment)
            // is registered with a named HttpClient. This allows
            // resilience, pooling, and reuse within DI-based consumers.
            var urls = builder.Configuration.GetSection("ServiceUrls");

            builder.Services.AddHttpClient("OrderService", c =>
            {
                c.BaseAddress = new Uri(urls["OrderService"]!);
            });

            builder.Services.AddHttpClient("UserService", c =>
            {
                c.BaseAddress = new Uri(urls["UserService"]!);
            });

            builder.Services.AddHttpClient("ProductService", c =>
            {
                c.BaseAddress = new Uri(urls["ProductService"]!);
            });

            builder.Services.AddHttpClient("PaymentService", c =>
            {
                c.BaseAddress = new Uri(urls["PaymentService"]!);
            });

            // Custom Aggregation Service Registration
            // The aggregator composes multiple downstream responses (Order, User,
            // Product, Payment) into a single unified payload.
            builder.Services.AddScoped<IOrderSummaryAggregator, OrderSummaryAggregator>();

            // Build WebApplication instance
            var app = builder.Build();

            // Swagger (API Explorer for development/debugging)
            if (app.Environment.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI();
            }

            app.UseHttpsRedirection();

            // Global Cross-Cutting Middleware
            // Applied to ALL requests — both custom /gateway endpoints and
            // proxied Ocelot routes.
            app.UseCorrelationId();        // Assigns a unique ID to every request for traceability.
            app.UseRequestResponseLogging(); // Logs request and response details for diagnostics.

            // BRANCH 1: Custom Aggregated Endpoints (/gateway/*)
            // Any route starting with /gateway (e.g. /gateway/order-summary)
            // is handled directly by ASP.NET controllers — not Ocelot.
            app.MapWhen(
                ctx => ctx.Request.Path.StartsWithSegments("/gateway", StringComparison.OrdinalIgnoreCase),
                gatewayApp =>
                {
                    // Enable endpoint routing for this sub-pipeline
                    gatewayApp.UseRouting();

                    // Apply authentication & authorization
                    gatewayApp.UseAuthentication();
                    gatewayApp.UseAuthorization();

                    // Register controller actions under this branch
                    gatewayApp.UseEndpoints(endpoints =>
                    {
                        endpoints.MapControllers();
                    });
                });

            // BRANCH 2: Ocelot Reverse Proxy
            // All requests NOT starting with /gateway are handled by Ocelot.
            // These requests are routed to the correct microservice defined in ocelot.json.
            app.UseAuthentication(); // Needed so Ocelot can read HttpContext.User

            // NOTE: Do NOT call UseAuthorization().

            // Middleware for pre-validation of Bearer tokens (optional)
            app.UseGatewayBearerValidation();

            // Ocelot middleware handles routing, transformation, and load-balancing
            await app.UseOcelot();

            // Start the Application
            app.Run();
        }
    }
}
Testing the Endpoint:

Method: GET

URL: https://localhost:7204/gateway/order-summary/5CFE4EEA-B26A-4FEE-803D-0EDCA3399213

Header: Authorization: Bearer<JWT Token>

When Should You Use Response Aggregation?

Response Aggregation isn’t mandatory for all endpoints. It’s most useful when the frontend or API consumer needs combined data from multiple microservices. You should use it when:

  • The UI Needs Combined Data: For example, an “Order Summary” or “User Dashboard” screen that requires data from multiple services. Aggregation simplifies this process.
  • You want to Reduce Client-Side Complexity: Mobile apps, especially, should not be doing complex merging or managing multiple API calls. The Gateway gives them one ready-to-use response.
  • You want to minimize API round-trips: Instead of the frontend making four separate calls, the Gateway makes internal calls in parallel, reducing waiting time.
  • You Are Building Dashboards or Summary Views: Dashboards are a classic example of systems where different microservices provide small pieces of data.
When to Avoid Response Aggregation

While aggregation is powerful, it’s not always the best choice. Avoid it when:

  • The required data comes from too many microservices, making the Gateway call-heavy and slow.
  • The aggregation logic becomes complex business logic that belongs inside a microservice, not the Gateway.
  • If the client does not need the response immediately, or if it can be loaded lazily, aggregation might not be necessary.

The rule is: Gateway should aggregate data, not perform calculations or business decisions.

Real-Time Scenarios in E-Commerce Microservices
Order Summary Page

When a customer opens the “My Orders” page:

  • Order Service gives order details.
  • Product Service provides the product name, image, and quantity.
  • Payment Service returns the transaction status.

The API Gateway aggregates all of this and sends a single, clean response to the front-end. This improves speed and makes the user experience seamless.

User Dashboard

On the user dashboard:

  • The User Service provides profile details.
  • The Order Service provides the last five orders.
  • The Wishlist Service provides saved items.
  • The Notification Service provides unread messages.

The Gateway aggregates all these services and sends a single combined response, which the dashboard can display instantly.

Admin Analytics or Reports

For an admin dashboard:

  • The Order Service gives the total orders and revenue.
  • The Product Service gives top-selling items.
  • The Refund Service provides refund statistics.
  • The Payment Service provides daily transaction data.

Instead of running four separate calls, the Gateway aggregates all analytics data into a single API response.

Checkout Review Page

Before placing an order:

  • The Product Service confirms stock availability.
  • The User Service provides saved addresses.
  • The Promotion Service gives applicable offers.
  • The Payment Service provides a list of available payment methods.

The Gateway merges all responses and returns a single unified “Checkout Review” API response to the UI.

Conclusion

Response Aggregation is one of the most important patterns in a microservices architecture, which makes our system user-friendly, fast, and reliable. It’s ideal for dashboards, summaries, and combined data views. It allows our API Gateway to:

  • Combine data from multiple services
  • Deliver a single, optimized response
  • Simplify client interaction
  • Improve performance and maintainability

In our E-Commerce Microservices Project, this means the Gateway can easily combine data from the Order, Product, Payment, and Notification Services, providing a smoother, faster experience for both web and mobile clients.

Leave a Reply

Your email address will not be published. Required fields are marked *