DEV Community

Cover image for API Gateway Patterns: AWS vs Azure vs GCP
Bry
Bry

Posted on Originally published at Medium

API Gateway Patterns: AWS vs Azure vs GCP

Key Points

  • AWS API Gateway comes in two flavors: HTTP API (cheaper, faster, 90% of use cases) and REST API (full feature set, higher cost). Use HTTP API unless you need REST API's request/response transformation or private integrations.
  • Azure API Management (APIM) is the only managed gateway with a built-in developer portal and a policy engine powerful enough to handle enterprise API governance without custom code.
  • GCP offers three overlapping products: API Gateway (serverless, OpenAPI-driven), Cloud Endpoints (gRPC and self-managed), and Apigee (enterprise-grade). Don't pick randomly — they are not interchangeable.
  • Common patterns — BFF (Backend for Frontend), API aggregation, and protocol translation — are cloud-agnostic in design but differ significantly in implementation cost across providers.
  • Cold starts on Lambda-backed gateways, APIM's XML policy syntax, and GCP's per-project quota model are the three gotchas that will cost you time in production.

Introduction

Picking an API gateway is a three-to-five year decision. Migrating between providers requires rewriting auth flows, rate-limiting config, and deployment pipelines — not just updating a URL. I've evaluated all three in production environments — migrating a client from AWS REST API to HTTP API, standing up APIM for an enterprise API program, and routing Cloud Run services through GCP API Gateway — and the differences that matter are rarely the ones in the marketing comparison tables. Most teams pick the gateway that matches their existing cloud provider without comparing what they're getting.

That's often the right call. But it helps to know what you're trading. AWS, Azure, and GCP have different design philosophies: AWS optimizes for serverless-native integration, Azure optimizes for enterprise API governance, and GCP gives you a choice between a lightweight managed service and a full API platform.

This article explains what each gateway does architecturally, compares them on the features that matter in production, covers the three patterns you'll implement on any of them, and flags the gotchas that each provider's documentation glosses over.


What an API Gateway Does

Before comparing providers, align on the job a gateway performs. An API gateway sits between clients and backend services and handles cross-cutting concerns so your services don't have to.

What an API Gateway Does

Diagram: Client traffic enters the gateway, which handles auth and routing before forwarding to backend services.

The five jobs a gateway performs:

  1. Routing — match request paths to backend services, including path rewriting and load balancing.
  2. Authentication and authorization — validate JWT tokens, API keys, or OAuth flows before traffic reaches your service.
  3. Rate limiting — enforce per-client or per-route request quotas; protect backends from traffic spikes.
  4. Request/response transformation — rewrite headers, translate between protocols (REST ↔ gRPC), strip or inject fields.
  5. Observability — emit access logs, request metrics, and distributed traces without instrumenting each service.

Every provider covers these five jobs. How they expose them — and at what cost — is what differentiates them.


Request Flow Through a Gateway

Before choosing a provider, understand the sequence every request traverses. The auth, rate-limit, and routing steps happen in this order regardless of which gateway you use.

Request Flow Through a Gateway

Diagram: Request flow through a gateway — auth and rate limiting happen before the backend ever receives the request.


AWS API Gateway

AWS offers three API products under the API Gateway brand: HTTP API, REST API, and WebSocket API. HTTP API and REST API are the ones backend engineers choose between daily.

HTTP API vs REST API

Feature HTTP API REST API
Price $1.00 / million requests $3.50 / million requests
Latency overhead ~6 ms ~11 ms
JWT authorizer (native) Yes No — Lambda authorizer required
Request/response transforms No Yes (mapping templates)
Usage plans / API keys No Yes
Private integrations (VPC Link) Yes Yes
WebSocket No — separate product No
AWS WAF integration Yes Yes

Use HTTP API for Lambda-backed services, JWT-authenticated endpoints, and any new API where you don't need request/response mapping templates. It costs 71% less than REST API and imposes half the latency overhead. I default to HTTP API for every new AWS project — the only time I've reached for REST API in the last two years was when a client needed usage plans for billing third-party API consumers.

Use REST API when you need usage plans and API keys for third-party developer access, request/response transformation via mapping templates, or Cognito user pool authorizers without a Lambda function.

Lambda Integration

The typical AWS pattern connects API Gateway directly to Lambda. The gateway acts as the event source; Lambda handles the business logic.

// AWS Lambda handler — receives API Gateway proxy event
import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';

export async function handler(
  event: APIGatewayProxyEventV2,
): Promise<APIGatewayProxyResultV2> {
  const userId = event.pathParameters?.userId;

  if (!userId) {
    return {
      statusCode: 400,
      headers: { 'Content-Type': 'application/problem+json' },
      body: JSON.stringify({
        type: 'https://api.example.com/errors/400',
        title: 'Bad Request',
        status: 400,
        detail: 'userId path parameter is required',
      }),
    };
  }

  // Business logic here
  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ userId, status: 'active' }),
  };
}
Enter fullscreen mode Exit fullscreen mode
# HTTP API route configuration (AWS SAM / CloudFormation)
Resources:
  GetUserFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: dist/handler.handler
      Runtime: nodejs22.x
      Events:
        GetUser:
          Type: HttpApi
          Properties:
            Path: /users/{userId}
            Method: GET
            Auth:
              Authorizer: JWTAuthorizer

  MyHttpApi:
    Type: AWS::Serverless::HttpApi
    Properties:
      Auth:
        Authorizers:
          JWTAuthorizer:
            JwtConfiguration:
              Audience: ["https://api.example.com"]
              Issuer: "https://auth.example.com/"
            IdentitySource: "$request.header.Authorization"
Enter fullscreen mode Exit fullscreen mode

The Cold Start Gotcha

Lambda-backed HTTP APIs incur cold start latency when a function instance is not warm. For a Node.js 22.x Lambda with a small bundle, cold starts add 200–800 ms. For a Java or .NET Lambda with a heavy runtime, expect 1–5 seconds.

Mitigations:

  • Enable Provisioned Concurrency on the Lambda — eliminates cold starts for pre-warmed instances, billed hourly even when idle.
  • Keep Lambda bundle size under 5 MB (smaller = faster cold start).
  • Use SnapStart for Java Lambda functions (zero cold start after initial activation).

Cold starts do not affect AWS ECS or EKS backends connected via VPC Link — they only affect Lambda integrations. I've seen this burn teams who built their entire p99 latency budget around gateway benchmarks, then went live and got paged at 3 AM because a burst of new connections spiked p99 to 2.8 seconds — the Lambda was a .NET 6 function with a 40 MB bundle and no Provisioned Concurrency.


Azure API Management (APIM)

Azure APIM is architecturally different from AWS API Gateway. It is not a simple proxy — it is a full API management platform with three components:

Azure API Management (APIM)

Diagram: APIM's three-component architecture — the gateway, the management plane, and the developer portal are separate concerns.

The Policy Engine

APIM's differentiating feature is its XML-based policy engine. Policies apply at four scopes: global, product, API, and operation. They execute in order: inbound → backend → outbound → on-error.

<!-- APIM policy: validate JWT, rate-limit, inject backend header -->
<policies>
  <inbound>
    <base />

    <!-- Validate JWT from Authorization header -->
    <validate-jwt header-name="Authorization" failed-validation-httpcode="401"
                  failed-validation-error-message="Unauthorized">
      <openid-config url="https://auth.example.com/.well-known/openid-configuration" />
      <audiences>
        <audience>https://api.example.com</audience>
      </audiences>
    </validate-jwt>

    <!-- Rate limit: 100 calls per 60 seconds per subscription key -->
    <rate-limit-by-key calls="100" renewal-period="60"
                       counter-key="@(context.Subscription.Id)" />

    <!-- Inject caller identity into backend header -->
    <set-header name="X-User-Id" exists-action="override">
      <value>@(context.Request.Headers.GetValueOrDefault("Authorization","")
               .Replace("Bearer ",""))</value>
    </set-header>
  </inbound>
  <backend>
    <base />
  </backend>
  <outbound>
    <base />
    <!-- Strip internal headers before returning to client -->
    <set-header name="X-Internal-Trace-Id" exists-action="delete" />
  </outbound>
  <on-error>
    <base />
  </on-error>
</policies>
Enter fullscreen mode Exit fullscreen mode

The policy engine handles authentication, rate limiting, caching, transformation, and error handling — without custom code. For an enterprise that manages 50+ APIs across multiple teams, that centralization is the primary value proposition.

Developer Portal

APIM includes a fully customizable developer portal out of the box. Developers browse APIs, subscribe to products, generate API keys, and test endpoints — all without involving the API team. AWS and GCP's native gateways require third-party tooling (e.g., Backstage, Stoplight) to achieve the same.

Pricing Tiers

Tier Price (approx.) Notes
Consumption $3.50 / million calls Pay-per-use. No SLA for developer portal.
Developer ~$50/month Dev/test only. No production SLA.
Basic ~$150/month Up to 1M calls/month included.
Standard ~$750/month Full features, 99.9% SLA.
Premium ~$3,800+/month Multi-region, VNET integration, 99.95% SLA.

The Consumption tier looks attractive but lacks the developer portal SLA and several advanced policy features. Standard is the minimum for production workloads with enterprise requirements. In practice, I recommend clients skip Consumption entirely for anything customer-facing — the cost delta between Consumption and Standard is irrelevant next to the engineering hours you'll spend working around its limitations.


GCP: API Gateway, Cloud Endpoints, and Apigee

GCP's three API products have overlapping names and confusingly similar descriptions. Choose the wrong one and you'll hit feature walls or pay for enterprise features you don't need.

Product Best for OpenAPI gRPC Developer portal Pricing model
API Gateway Serverless backends (Cloud Run, Functions) Yes (OAS v2) No No Per-call
Cloud Endpoints gRPC services, self-managed gateway Yes Yes No Per-call
Apigee Enterprise, partner APIs, monetization Yes Yes Yes Subscription

Use API Gateway when your backend runs on Cloud Run, Cloud Functions, or App Engine and you want a fully managed gateway with no infrastructure to operate. Import your OpenAPI spec and Google provisions the gateway.

Use Cloud Endpoints when your services speak gRPC, or when you need to host the gateway proxy (ESPv2) on your own runtime for private networking. Cloud Endpoints is also the right choice for local development with gRPC tooling.

Use Apigee when you need a developer portal, API monetization, advanced bot detection, or multi-cloud/hybrid deployment. Apigee is the only GCP option that competes directly with Azure APIM.

GCP API Gateway — OpenAPI-Driven Configuration

# openapi.yaml — GCP API Gateway configuration
swagger: "2.0"
info:
  title: User Service API
  version: "1.0"
host: api.example.com
schemes:
  - https
produces:
  - application/json

x-google-backend:
  address: https://user-service-xyz-uc.a.run.app
  deadline: 30.0

securityDefinitions:
  firebase:
    authorizationUrl: ""
    flow: implicit
    type: oauth2
    x-google-issuer: "https://securetoken.google.com/my-project"
    x-google-jwks_uri: "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com"
    x-google-audiences: "my-project"

paths:
  /users/{userId}:
    get:
      summary: Get user by ID
      operationId: getUser
      parameters:
        - name: userId
          in: path
          required: true
          type: string
      security:
        - firebase: []
      responses:
        "200":
          description: User found
        "401":
          description: Unauthorized
        "404":
          description: Not found
Enter fullscreen mode Exit fullscreen mode
# Deploy to GCP API Gateway
gcloud api-gateway api-configs create user-service-v1 \
  --api=user-service \
  --openapi-spec=openapi.yaml \
  --project=my-project \
  --backend-auth-service-account=api-gateway-sa@my-project.iam.gserviceaccount.com

gcloud api-gateway gateways create user-service-gateway \
  --api=user-service \
  --api-config=user-service-v1 \
  --location=us-central1 \
  --project=my-project
Enter fullscreen mode Exit fullscreen mode

The GCP Quota Gotcha

GCP API Gateway enforces quotas at the project level. If you run multiple APIs or environments in the same GCP project, they share quota limits. A traffic spike on one API eats into the quota headroom for every other API in that project. I've seen this catch teams when a batch job saturated API quotas during an off-hours data migration, which cascaded into 429s on the customer-facing API living in the same project — a production incident that had nothing to do with the customer-facing service itself.

Structure your GCP projects to isolate production APIs. One project per environment (dev, staging, prod) is the minimum. One project per service in production is safer for high-traffic APIs.


Feature Comparison

Feature AWS HTTP API AWS REST API Azure APIM (Standard) GCP API Gateway GCP Apigee
Auth: JWT Native Lambda authorizer Policy (validate-jwt) OpenAPI extension Policy
Auth: API keys No Yes Yes Yes Yes
Auth: OAuth 2.0 Partial (JWT) Lambda authorizer Policy No Yes
Rate limiting Yes Yes (usage plans) Yes (per subscription) Yes (per consumer) Yes (advanced)
Request transform No Yes (mapping templates) Yes (policy engine) No Yes
Response transform No Yes Yes No Yes
Protocol translation No No Yes (REST ↔ SOAP) No Yes
Developer portal No No Yes (built-in) No Yes
gRPC support No No No Via Endpoints Yes
WebSocket Separate product No No No No
Multi-region active-active No (regional) No Premium tier No Yes
Pricing model Per-call Per-call Tier (monthly) Per-call Subscription
Cold starts Lambda-dependent Lambda-dependent None None None

Common Patterns

Backend for Frontend (BFF)

The BFF pattern uses a dedicated gateway layer per client type — one for the web app, one for the mobile app — to avoid forcing different clients to negotiate a single general-purpose API.

Backend for Frontend (BFF)

Diagram: BFF pattern — each client type gets a dedicated aggregation layer tailored to its data needs.

A BFF does data aggregation and transformation — it is not where business logic lives. Business rules belong in the downstream services.

On AWS, implement each BFF as a separate Lambda function or ECS service behind its own HTTP API route. On Azure, use APIM products to route mobile vs web clients to different backends. On GCP, deploy separate Cloud Run services and separate API Gateway configurations.

API Aggregation

Aggregation collapses multiple downstream service calls into a single response for the client. The gateway (or a BFF) fans out the requests in parallel and merges the results.

// API aggregation pattern — call multiple services in parallel
import express, { Request, Response } from 'express';

const app = express();

interface OrderSummary {
  order: unknown;
  inventory: unknown;
  shipping: unknown;
}

/**
 * GET /orders/:id/summary — returns a merged view of order, inventory, and shipping data.
 *
 * @remarks
 * Fans out to three downstream services in parallel. If any service fails,
 * the endpoint returns 502 with the name of the failing service.
 *
 * @returns Merged order summary or 502 with failing service name.
 */
app.get('/orders/:id/summary', async (req: Request, res: Response) => {
  const { id } = req.params;

  const [orderRes, inventoryRes, shippingRes] = await Promise.allSettled([
    fetch(`http://order-service/orders/${id}`),
    fetch(`http://inventory-service/stock/${id}`),
    fetch(`http://shipping-service/shipments/${id}`),
  ]);

  for (const [name, result] of [
    ['order', orderRes],
    ['inventory', inventoryRes],
    ['shipping', shippingRes],
  ] as const) {
    if (result.status === 'rejected') {
      res.status(502).json({ error: `upstream_failure`, service: name });
      return;
    }
  }

  const summary: OrderSummary = {
    order: await (orderRes as PromiseFulfilledResult<Response>).value.json(),
    inventory: await (inventoryRes as PromiseFulfilledResult<Response>).value.json(),
    shipping: await (shippingRes as PromiseFulfilledResult<Response>).value.json(),
  };

  res.json(summary);
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Protocol Translation

Protocol translation converts between API protocols at the gateway layer. The most common case in enterprise environments is REST-to-SOAP, where a new REST API sits in front of a legacy SOAP backend.

Azure APIM handles this natively with the soap-to-rest policy. On AWS or GCP, you write a Lambda or Cloud Run adapter that performs the translation.

<!-- Azure APIM: expose a REST endpoint backed by a SOAP service -->
<policies>
  <inbound>
    <base />
    <!-- Convert REST JSON body to SOAP XML envelope -->
    <set-body>
      @{
        var body = context.Request.Body.As<JObject>();
        return string.Format(
          @"<soapenv:Envelope xmlns:soapenv='http://schemas.xmlsoap.org/soap/envelope/'
                              xmlns:usr='http://legacy.example.com/user'>
              <soapenv:Body>
                <usr:GetUser>
                  <usr:UserId>{0}</usr:UserId>
                </usr:GetUser>
              </soapenv:Body>
            </soapenv:Envelope>",
          body["userId"]
        );
      }
    </set-body>
    <set-header name="Content-Type" exists-action="override">
      <value>text/xml; charset=utf-8</value>
    </set-header>
    <set-header name="SOAPAction" exists-action="override">
      <value>http://legacy.example.com/user/GetUser</value>
    </set-header>
  </inbound>
  <backend>
    <base />
  </backend>
  <outbound>
    <base />
    <!-- Parse SOAP response and return JSON -->
    <xml-to-json kind="direct" apply="always" consider-accept-header="false" />
  </outbound>
</policies>
Enter fullscreen mode Exit fullscreen mode

Protocol translation is one of the strongest arguments for Azure APIM. Implementing the same pattern on AWS requires a Lambda adapter and custom XML parsing logic. If your organization has legacy SOAP services that need a REST facade, I would pick Azure APIM over any other provider specifically for this — the AWS alternative is a Lambda that becomes a maintenance liability the moment the original SOAP developer leaves.


API Lifecycle Management

Every gateway manages API versions moving through environments. The state diagram below represents the lifecycle that applies regardless of provider.

API Lifecycle Management

Diagram: API lifecycle from development through retirement. Breaking changes are only safe before the API reaches Production.

On AWS, implement environment promotion by deploying to separate API Gateway stages (dev, staging, prod). On Azure, use APIM's revision system for non-breaking changes and a new API version for breaking changes. On GCP API Gateway, deploy to separate gateway instances per environment.


Choosing the Right Gateway

Choosing the Right Gateway

Diagram: Decision flowchart for selecting a gateway.


Common Mistakes

Mistake 1: Using AWS REST API when HTTP API covers your needs
REST API costs 3.5× more per million requests and adds ~5 ms of latency over HTTP API. The only reasons to choose REST API over HTTP API are mapping templates for request/response transformation, usage plans with API keys, or Cognito user pool authorizers. If none of those apply, HTTP API is the correct choice.

Mistake 2: Ignoring Lambda cold starts in latency budgets
Teams measure gateway latency and see 6 ms. They assume their API responds in under 100 ms. Then they deploy to production and discover p99 latency is 700 ms because the Lambda behind the gateway cold-starts on every new burst. Cold starts are not a gateway problem — they are a Lambda problem — but they manifest as gateway latency and are often debugged at the wrong layer.

Mistake 3: Choosing Azure APIM Consumption for production without reading the tier limitations
The Consumption tier has no SLA for the developer portal, limits on policy features, and no built-in cache. Teams select it for cost reasons and hit limitations mid-project. Standard is the correct production tier for most workloads.

Mistake 4: Mixing multiple APIs in a single GCP project
GCP API Gateway enforces quotas at the project level. Multiple APIs in one project share quota. A traffic spike on one API reduces the available quota for all others in the same project. Separate projects per environment (and per service in production for high-traffic APIs) is the safe default.

Mistake 5: Building complex API aggregation in gateway mapping templates
AWS REST API mapping templates (Velocity Template Language) and Azure APIM policies can both perform data transformation. Use them for simple field renaming or header injection. Do not use them to aggregate multiple backend calls, implement business logic, or transform complex nested structures — that complexity belongs in a BFF service where it is testable and maintainable.


Production Considerations

Performance

  • AWS HTTP API adds ~6 ms of median gateway overhead. REST API adds ~11 ms. Set your latency budget accordingly.
  • Azure APIM Standard tier median overhead is 5–15 ms depending on policy complexity. A policy chain with JWT validation, rate limiting, and transformation adds up.
  • GCP API Gateway overhead is 5–20 ms. Apigee overhead is higher due to the full policy engine.

Security

  • On AWS, attach a WAF WebACL to your HTTP API or REST API. The gateway itself validates JWT signatures but does not inspect payloads for SQL injection or XSS patterns.
  • On Azure APIM, use the ip-filter policy to block known-bad IP ranges and the validate-content policy for schema validation. APIM integrates with Azure DDoS Protection at the Premium tier.
  • On GCP, pair API Gateway with Cloud Armor for DDoS protection and request filtering. Apigee has built-in bot detection and threat protection.

Cost

  • AWS HTTP API at 1 billion requests/month: ~$1,000. Lambda execution costs are separate.
  • Azure APIM Standard: ~$750/month flat plus overage. Standard includes 1M calls/month; additional calls are $3.50/million.
  • GCP API Gateway at 1 billion requests/month: ~$2,000 (calls over the free tier at $3.00/million above 2M).

Monitoring

  • On AWS, enable CloudWatch detailed metrics for your API Gateway and set alarms on 4XXError, 5XXError, Latency, and IntegrationLatency. IntegrationLatency isolates backend latency from gateway latency — critical for cold start diagnosis.
  • On Azure, use APIM's built-in Application Insights integration. Track Backend Duration separately from Gateway Duration to identify whether slowness is in the gateway policies or the backend.
  • On GCP, Cloud Logging and Cloud Trace integrate automatically. Set a budget alert on API call costs in the GCP Console — GCP quotas can run up quickly on traffic spikes.

Full Example: API Aggregation Service (TypeScript)

This aggregation service sits behind an API Gateway (on any provider) and fans out to three downstream services in parallel. It returns a merged response or reports which upstream failed.

import express, { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';

const app = express();
app.use(express.json());

// ─── Types ───────────────────────────────────────────────────────────────────

/** Represents the shape of a downstream service response for an order. */
interface OrderData {
  id: string;
  status: string;
  total: number;
  lineItems: Array<{ productId: string; qty: number }>;
}

/** Inventory availability per product. */
interface InventoryData {
  productId: string;
  available: number;
  reserved: number;
}

/** Shipment tracking details. */
interface ShipmentData {
  trackingNumber: string;
  carrier: string;
  estimatedDelivery: string;
  status: string;
}

/** Merged summary returned to the client. */
interface OrderSummaryResponse {
  requestId: string;
  order: OrderData;
  inventory: InventoryData[];
  shipment: ShipmentData | null;
}

/** RFC 7807 problem detail shape for error responses. */
interface ProblemDetail {
  type: string;
  title: string;
  status: number;
  detail: string;
  instance?: string;
}

// ─── Helpers ─────────────────────────────────────────────────────────────────

function problem(
  res: Response,
  status: number,
  title: string,
  detail: string,
  instance?: string,
): void {
  const body: ProblemDetail = {
    type: `https://api.example.com/errors/${status}`,
    title,
    status,
    detail,
    ...(instance ? { instance } : {}),
  };
  res.status(status).contentType('application/problem+json').json(body);
}

/**
 * Fetch a downstream service and parse the JSON response.
 *
 * @remarks Throws with a structured error if the upstream returns non-2xx.
 * Caller is responsible for catching via Promise.allSettled.
 *
 * @param url - Full URL of the downstream service endpoint.
 * @returns Parsed JSON response body.
 * @throws {Error} when upstream returns a non-2xx status.
 */
async function fetchUpstream<T>(url: string): Promise<T> {
  const res = await fetch(url, {
    headers: { 'X-Request-Id': randomUUID() },
    signal: AbortSignal.timeout(5000), // 5 s timeout per upstream call
  });

  if (!res.ok) {
    throw new Error(`Upstream ${url} returned ${res.status}`);
  }

  return res.json() as Promise<T>;
}

// ─── Routes ──────────────────────────────────────────────────────────────────

/**
 * GET /orders/:id/summary — fan-out aggregation across order, inventory, and shipping services.
 *
 * @remarks
 * **Path parameters:**
 * - `id` — Order UUID. Must exist in the order service.
 *
 * **Responses:**
 * - `200 OK` — All three upstreams responded successfully.
 * - `404 Not Found` — Order service returned 404.
 * - `502 Bad Gateway` — One or more upstreams failed or timed out; body identifies which.
 */
app.get('/orders/:id/summary', async (req: Request, res: Response) => {
  const { id } = req.params;
  const requestId = randomUUID();

  const ORDER_SVC = process.env.ORDER_SERVICE_URL ?? 'http://order-service';
  const INV_SVC = process.env.INVENTORY_SERVICE_URL ?? 'http://inventory-service';
  const SHIP_SVC = process.env.SHIPPING_SERVICE_URL ?? 'http://shipping-service';

  const [orderResult, shipmentResult] = await Promise.allSettled([
    fetchUpstream<OrderData>(`${ORDER_SVC}/orders/${id}`),
    fetchUpstream<ShipmentData>(`${SHIP_SVC}/shipments/${id}`),
  ]);

  if (orderResult.status === 'rejected') {
    problem(res, 502, 'Upstream Failure', `Order service failed: ${orderResult.reason}`, req.path);
    return;
  }

  const order = orderResult.value;

  // Fetch inventory for each line item in parallel
  const inventoryResults = await Promise.allSettled(
    order.lineItems.map(item =>
      fetchUpstream<InventoryData>(`${INV_SVC}/stock/${item.productId}`),
    ),
  );

  const inventoryFailure = inventoryResults.find(r => r.status === 'rejected');
  if (inventoryFailure) {
    problem(res, 502, 'Upstream Failure', 'Inventory service failed for one or more products', req.path);
    return;
  }

  const summary: OrderSummaryResponse = {
    requestId,
    order,
    inventory: inventoryResults.map(r => (r as PromiseFulfilledResult<InventoryData>).value),
    shipment: shipmentResult.status === 'fulfilled' ? shipmentResult.value : null,
  };

  res.json(summary);
});

// ─── Error handler ───────────────────────────────────────────────────────────

app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
  console.error('Unhandled error', { path: req.path, error: err.message });
  problem(res, 500, 'Internal Server Error', 'An unexpected error occurred.', req.path);
});

// ─── Start ───────────────────────────────────────────────────────────────────

const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
app.listen(PORT, () => {
  console.log(`Aggregation service running on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Run the service:

npm install
ORDER_SERVICE_URL=http://localhost:3001 \
INVENTORY_SERVICE_URL=http://localhost:3002 \
SHIPPING_SERVICE_URL=http://localhost:3003 \
npm run dev
Enter fullscreen mode Exit fullscreen mode

Test the aggregation endpoint:

# Aggregated response from three services in one call
curl -s http://localhost:3000/orders/order-abc123/summary | jq

# Expected shape:
# {
#   "requestId": "uuid",
#   "order": { "id": "order-abc123", "status": "confirmed", ... },
#   "inventory": [{ "productId": "...", "available": 10 }, ...],
#   "shipment": { "trackingNumber": "...", "status": "in_transit" }
# }
Enter fullscreen mode Exit fullscreen mode

Full source with tests: GitHub linkcloud-apis/api-gateway-patterns/


Conclusion

AWS HTTP API is the right default for Lambda-backed services — it is faster and costs less than REST API, and covers 90% of use cases. Azure APIM wins when your organization needs a developer portal, enterprise API governance, or REST-to-SOAP translation without custom code. GCP's three options serve different tiers: API Gateway for serverless, Cloud Endpoints for gRPC, and Apigee for enterprise. The BFF pattern, API aggregation, and protocol translation are cloud-agnostic in concept but differ significantly in implementation cost — Azure APIM handles protocol translation natively, while AWS and GCP require adapter services. The decision you should walk away from this article having made: stop treating gateway selection as a checkbox in your cloud provider's onboarding wizard. The wrong choice costs you months of workarounds; the right one disappears into your infrastructure and you stop thinking about it entirely — which is exactly what a good gateway should do.


Further Reading


If this helped, a like and a follow are appreciated — and if you've solved this differently, drop a comment, I'd like to hear it.

Bry Writes Code — cloud and API infrastructure specialist. Evaluating or designing your API gateway architecture? Get in touch.

Top comments (0)