Manikandan — Manikandan
Microservices

Day 10: API Composition

ManikandanManikandan
13 min read·Updated Aug 19, 2022

API Composition solves the "no more SQL JOIN" problem that appears once each microservice owns its own database.

Intro

API Composition solves the “no more SQL JOIN” problem that appears once each microservice owns its own database. Instead of joining tables, an aggregator (the composer) calls the owning services through their APIs, then joins the results in memory and returns one response. In our insurance claims system, a “Claim Details” screen needs data from Claims, Policy, Customer and Payments services; the composer fetches each piece and stitches them together.

Why we need this

  • Database per Service (Day 5) removes cross-service JOINs. Once ClaimsDb, PolicyDb, CustomerDb and PaymentsDb are separate, no single SQL statement can span them.
  • Screens and APIs are shaped around user tasks, not services. A claims adjuster wants one “Claim 360” view, not four separate calls to reason about.
  • It keeps ownership clean. Services stay the single owner of their data; the composer only consumes public contracts.
  • It is the simplest query pattern. No new infrastructure (no broker, no read store) is needed, which makes it the default first choice before CQRS (Day 8).

What problem it solves

Problem: Distributed databases prevent SQL JOINs across service boundaries.

Without it, teams fall back to one of these bad options:

  • Re-sharing a database (breaks Day 5) so a JOIN is possible.
  • Making the browser call four services and join client-side, which leaks internal topology, multiplies round trips on mobile networks and duplicates join logic in every client.
  • Copy-pasting join logic into whichever service is nearest, creating hidden coupling (“Claims service now knows Policy internals”).

API Composition gives the join a single, explicit home.

When it is needed (and when it is NOT)

Use it when:

  • The query needs data from 2 to about 5 services and the result set is small (one claim, one customer, one page of 20 claims).
  • Data must be fresh (real-time balance, current claim status).
  • You do not want to maintain a separate read store.

Do NOT use it when:

  • You must join, filter or sort large datasets across services, for example “all open claims where the policy holder is in Chennai and total payments > 50,000, sorted by payment date”. The composer would pull huge lists into memory. Use CQRS with a denormalised read model (Day 8).
  • The chain is deep or the fan-out is high (more than about 5 to 6 calls). Availability multiplies: five services at 99.9% each give roughly 99.5% overall.
  • The join needs transactional consistency across services. API Composition gives no snapshot consistency; use a Saga (Day 7) for writes and accept eventual consistency for reads.
  • One service already owns most of the data. Then the “composition” is just a normal call.

How to identify the problem (key signals)

  1. Developers ask “how do I JOIN Claims with Policy?” and someone proposes a cross-database view or linked server.
  2. The Angular app makes 4 to 8 API calls to render one screen and the network tab shows a waterfall.
  3. The same join code (claim + policy + customer) is duplicated in the web app, the mobile app and a reporting job.
  4. A service has a “temporary” read-only connection string to another service’s database.
  5. Page load p95 is dominated by the slowest downstream service.
  6. Mobile users complain about slow loading on poor networks while backend metrics look healthy.
  7. A change to the Customer API breaks screens owned by other teams because clients each parse it differently.

Flow Diagram

A composer calls the driver service first, then fans out in parallel and joins in memory.

flowchart TD
UI["Angular claim screen"] --> CO["Claim Composer / BFF"]
CO -- "1. driver call" --> CL["Claims API"]
CL --> CO
CO -- "2. parallel, 2s timeout" --> PO["Policy API"]
CO -- "2. parallel" --> CU["Customer API"]
CO -- "2. parallel" --> PA["Payments API"]
PO --> J["Join in memory"]
CU --> J
PA --> J
J --> RES["ClaimView + missing list"]
RES --> UI

Level 1: Beginner

Analogy: A travel agent. You ask for one itinerary. The agent phones the airline, hotel and taxi company separately, then hands you one printed sheet. You never call three companies yourself.

Minimal working example (.NET 10, minimal API, in-memory composition of two services):

// Program.cs - Claim Composer (minimal, compiles with .NET 10 SDK)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("claims", c => c.BaseAddress = new Uri("http://claims-service"));
builder.Services.AddHttpClient("policies", c => c.BaseAddress = new Uri("http://policy-service"));
var app = builder.Build();
app.MapGet("/claims/{id:guid}/details", async (Guid id, IHttpClientFactory f, CancellationToken ct) =>
{
var claim = await f.CreateClient("claims")
.GetFromJsonAsync<ClaimDto>($"/claims/{id}", ct);
if (claim is null) return Results.NotFound();
var policy = await f.CreateClient("policies")
.GetFromJsonAsync<PolicyDto>($"/policies/{claim.PolicyId}", ct);
return Results.Ok(new ClaimDetailsDto(claim.Id, claim.Status, claim.Amount,
policy?.PolicyNumber, policy?.HolderName));
});
app.Run();
record ClaimDto(Guid Id, Guid PolicyId, string Status, decimal Amount);
record PolicyDto(Guid Id, string PolicyNumber, string HolderName);
record ClaimDetailsDto(Guid ClaimId, string Status, decimal Amount,
string? PolicyNumber, string? HolderName);

Note the dependency: the policy id comes from the claim, so these two calls are sequential. Anything independent should be called in parallel (next level).

Level 2: Intermediate

Real-world shape: an ASP.NET Core composer (or a BFF, Day 20) sits in front of Angular. It calls independent services in parallel, applies a per-call timeout, and returns partial results when non-critical services fail.

ClaimDetailsComposer.cs
public sealed class ClaimDetailsComposer(IHttpClientFactory factory, ILogger<ClaimDetailsComposer> log)
{
public async Task<ClaimView?> ComposeAsync(Guid claimId, CancellationToken ct)
{
// Step 1: the claim is mandatory and drives the other calls.
var claim = await factory.CreateClient("claims")
.GetFromJsonAsync<ClaimDto>($"/claims/{claimId}", ct);
if (claim is null) return null;
// Step 2: independent calls run in parallel.
var policyTask = TryGet<PolicyDto>("policies", $"/policies/{claim.PolicyId}", ct);
var customerTask = TryGet<CustomerDto>("customers", $"/customers/{claim.CustomerId}", ct);
var paymentsTask = TryGet<List<PaymentDto>>("payments", $"/payments?claimId={claimId}", ct);
await Task.WhenAll(policyTask, customerTask, paymentsTask);
return new ClaimView(
claim,
policyTask.Result,
customerTask.Result,
paymentsTask.Result ?? [],
// tells the UI which sections are degraded
Missing: new[] { policyTask.Result is null ? "policy" : null,
customerTask.Result is null ? "customer" : null,
paymentsTask.Result is null ? "payments" : null }
.OfType<string>().ToArray());
}
private async Task<T?> TryGet<T>(string client, string url, CancellationToken ct) where T : class
{
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(2)); // per-call budget
return await factory.CreateClient(client).GetFromJsonAsync<T>(url, cts.Token);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
log.LogWarning(ex, "Composition call {Client} {Url} failed", client, url);
return null; // degrade instead of failing the whole page
}
}
}

Register it with builder.Services.AddScoped<ClaimDetailsComposer>(); and add resilience with Microsoft.Extensions.Http.Resilience (AddStandardResilienceHandler()) on each named client.

Angular side (standalone component, signals):

claim-details.component.ts
import { Component, inject, input } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { rxResource } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-claim-details',
template: `
@if (claim.value(); as c) {
<h2>{{ c.claim.status }} - {{ c.claim.amount | currency }}</h2>
@if (c.missing.includes('payments')) {
<p class="warn">Payment history is temporarily unavailable.</p>
}
} @else if (claim.isLoading()) {
<p>Loading...</p>
}
`,
})
export class ClaimDetailsComponent {
private http = inject(HttpClient);
claimId = input.required<string>();
claim = rxResource({
params: () => this.claimId(),
stream: ({ params }) => this.http.get<any>(`/api/claims/${params}/details`),
});
}

The rxResource API has changed shape between Angular versions (request/loader in v19 became params/stream in v20+). Check the release notes for the Angular version your team is on.

Database angle: each service still queries only its own SQL Server or PostgreSQL database. The join happens in the composer’s memory, so make sure each downstream endpoint supports batch lookups (GET /customers?ids=1,2,3) to avoid N+1 calls when composing a list.

Level 3: Advanced

Performance

  • Parallelise independent calls; total latency becomes max(calls) not sum(calls).
  • Avoid N+1 fan-out. Composing a list of 50 claims must not make 50 policy calls; use batch endpoints (ids= queries) or cache.
  • Cap payload size. Always paginate the driving list; never pull “all claims” into memory.
  • Cache slow-changing data (customer profile, policy master) with short TTL using HybridCache (available in .NET 9 and later) or Redis.

Scalability

  • The composer is stateless, so scale it horizontally. Its CPU is mostly JSON (de)serialisation; watch memory under large payloads.
  • Downstream services see amplified load: one user request becomes N calls. Add rate limits and bulkheads (Day 27) on the composer’s outbound clients.

Security

  • Forward the caller’s access token (or exchange it, e.g. OAuth token exchange / on-behalf-of) so each service enforces its own authorisation. Do not let the composer use a super-user service credential and return data the caller may not see.
  • Filter fields: the composer should return only what the screen needs (avoid leaking internal fields such as fraud scores).

Failure modes

FailureEffectMitigation
One downstream is slowWhole page waitsPer-call timeout, return partial result
One downstream is down500 for the whole screenCircuit breaker + fallback (Days 26, 29)
Inconsistent readsClaim says “Paid” but Payments not yet updatedAccept eventual consistency, show timestamps
Retry stormsComposer retries multiply loadBounded retries with jitter, retry budget
Contract driftDownstream renames a fieldConsumer-driven contract tests (Day 51)

Common mistakes

  • Sequential calls that could be parallel.
  • Business logic inside the composer (it should only fetch and shape).
  • Putting the composer inside one of the domain services, which creates hidden coupling.
  • No timeouts, relying on default 100-second HttpClient timeout.
  • Treating it as a reporting engine.

Level 4: Expert and Architect view

Trade-offs and alternatives

OptionFreshnessComplexityQuery power (filter/sort/join)Failure isolationBest fit
API CompositionReal timeLowWeak on large setsPoor (runtime dependency on all services)Detail screens, small joins
CQRS read model (Day 8)EventualHigh (events, projections)StrongGood (own store)Search, dashboards, large joins
GraphQL gateway / federationReal timeMediumMedium (still N calls behind)Poor to mediumMany client shapes, flexible fields
Shared database (Day 6)Real timeLowStrongPoor and couples schemasInterim migration only
Data warehouse / reportingDelayedMediumVery strongExcellentAnalytics, not operational screens

Combines with: API Gateway (Day 19) or BFF (Day 20) as the host of the composer, Circuit Breaker (Day 26), Bulkhead (Day 27), Retry & Backoff (Day 28), Fallback (Day 29), Distributed Tracing (Day 31), and CQRS (Day 8) as the escape hatch when composition stops scaling.

Where to host the composer: in the BFF for UI-specific views, in the API Gateway only for very light aggregation (gateways should not hold business rules), or as a dedicated composition service when several clients share the same aggregate.

ADR (short form)

  • Title: ADR-010 Use API Composition for the Claim Details view
  • Context: Claims, Policy, Customer and Payments each own a private database. The adjuster portal needs a combined view of one claim, expected at up to 200 requests per second, p95 under 800 ms.
  • Decision: Build a stateless Claim Composer inside the adjuster BFF. Claims is called first; Policy, Customer and Payments are called in parallel with 2-second timeouts and partial-result support. Downstream services expose batch endpoints.
  • Consequences: (+) No new data infrastructure, fresh data, clear ownership. (-) Availability depends on four services, latency is bounded by the slowest, no cross-service filtering. Mitigated with resilience handlers and caching.
  • Revisit when: a screen needs cross-service search or sorting, or fan-out exceeds 5 services. Then introduce a CQRS read model.

Azure implementation

Services that implement or support it

  • Azure Container Apps or Azure Kubernetes Service (AKS): host the composer (an ASP.NET Core container). Container Apps is the simpler choice for a small team, with HTTP autoscaling and scale to zero.
  • Azure App Service: simplest hosting if you are not containerised.
  • Azure API Management (APIM): front door with authentication, rate limits and caching policies. APIM policies can do light aggregation (send-request), but keep real composition in code.
  • Azure Functions: possible for a single composition endpoint with spiky traffic (Flex Consumption plan is the current recommended serverless plan).
  • Azure Cache for Redis or HybridCache: cache slow-changing downstream data.
  • Application Insights + OpenTelemetry (Azure Monitor): end-to-end trace of one composition, showing which downstream call is slowest.
  • Azure Front Door / Application Gateway: edge routing and WAF in front of APIM.
  • Microsoft Entra ID: issue tokens; use on-behalf-of flow when the composer calls downstream APIs as the user.

How to configure (typical)

  1. Deploy each service and the composer to one Container Apps environment. Composer calls services by internal name (http://policy-service), which stays inside the environment.
  2. Enable ingress on the composer only; internal services use internal ingress.
  3. Add an HTTP scale rule on the composer (for example scale on concurrent requests).
  4. Add AddOpenTelemetry().UseAzureMonitor() (Azure Monitor OpenTelemetry distro) so traces propagate through all calls.
  5. In APIM, apply validate-jwt and rate-limit-by-key on the composer’s route.
  6. Store connection strings and secrets in Key Vault with managed identity.

Pricing and tier considerations (verify on the Azure pricing pages before committing; prices and tier names change)

  • Container Apps: Consumption plan bills for vCPU-seconds, memory and requests with a monthly free grant, and can scale to zero; Dedicated (workload profiles) suits steady load.
  • APIM: the Consumption tier is pay per call and suits low or spiky traffic; Developer is for non-production; Standard v2 and Premium tiers give production SLAs, VNet options and higher throughput. Pick by SLA, VNet and scale needs.
  • Azure Cache for Redis: Basic has no SLA, so use Standard or higher in production.
  • Application Insights bills by data ingested; sample traces at high volume.

Reference architecture (text)

Browser (Angular) -> Azure Front Door (WAF) -> APIM (JWT validation, rate limiting) -> Adjuster BFF / Claim Composer (Container Apps) -> in parallel to Claims service (Azure SQL / SQL Server), Policy service (PostgreSQL Flexible Server), Customer service (Azure SQL), Payments service (PostgreSQL). Redis caches customer and policy lookups. All components emit OpenTelemetry to Application Insights; secrets come from Key Vault via managed identity.

Teaching guide for my team

2-minute beginner explanation

“Each service owns its own database, so we cannot write one SQL JOIN across them. When a screen needs claim, policy and customer together, one small service asks each owner through its API, then combines the answers in code and returns one response. Think of a travel agent who phones the airline and hotel for you and gives you one itinerary.”

5-minute intermediate explanation

Explain: driver call first (claim), then parallel calls for independent data using Task.WhenAll; per-call timeouts; partial results with a “missing” list so the UI can degrade gracefully; batch endpoints to avoid N+1; forwarding the user’s token; and the rule of thumb that it works for small, fresh, detail-style queries but not for big cross-service search. Show the availability math (5 x 99.9% is about 99.5%) and when to move to CQRS.

Hands-on exercise

Build two tiny ASP.NET Core services (Claims, Policy) with in-memory data and a Composer. Then: (1) make the composer call them sequentially and measure; (2) make independent calls parallel and measure; (3) add a 500 ms delay to Policy and confirm the 2-second timeout returns a partial result with missing: ["policy"]. Expected outcome: step 2 is faster than step 1 by roughly the duration of the shorter call; step 3 still returns 200 with claim data and a missing marker, and the log shows a warning.

Interview-style questions

  1. Why can’t we just JOIN across service databases? Each service owns a private store, often on different engines and servers; cross-database access would couple schemas and break independent deployment.
  2. What are the main weaknesses of API Composition? Reduced availability (all dependencies must respond), higher latency bounded by the slowest call, no cross-service transaction or snapshot consistency, and poor fit for large filtered or sorted joins.
  3. When would you replace it with CQRS? When queries need filtering, sorting or search across services’ data, when fan-out grows beyond about 5 services, or when you need read performance independent of downstream availability.

Mastery checklist

  • I can explain why cross-service JOINs are impossible under Database per Service.
  • I can implement a composer with parallel calls, per-call timeouts and partial results.
  • I can spot and fix an N+1 fan-out with batch endpoints or caching.
  • I can calculate the composite availability of a call chain.
  • I can forward or exchange user tokens correctly so downstream authorisation still applies.
  • I can decide between API Composition, GraphQL and a CQRS read model for a given screen.
  • I can trace one composed request end to end in Application Insights.
  • I can write an ADR justifying where the composer is hosted.

Key takeaway

API Composition is the simplest way to answer cross-service queries: ask each owner through its API and join in memory. Use it for small, fresh, detail-style reads, and reach for CQRS when the join gets big or the fan-out gets wide.

Interactive Architectural Roadmaps

Explore Complete Roadmaps & Pattern Checklists

Track your learning with interactive checklists for all 23 Gang of Four patterns and modern Microservice architecture patterns.

Share:
Back to Blog

Related Posts

View All Posts
Microservices

Day 11: Domain Event

A Domain Event is an immutable record that something meaningful happened in the business domain, named in the past tense (for example ClaimApproved).

Manikandan
Manikandan·16 min read
Microservices

Day 9: Event Sourcing

Event Sourcing stores every change to a business object as an immutable event in an append-only log, instead of overwriting the current state in a row.

Manikandan
Manikandan·18 min read
Microservices

Day 8: CQRS

CQRS (Command Query Responsibility Segregation) splits an application's model into two sides.

Manikandan
Manikandan·18 min read
Microservices

Day 7: Saga Pattern

A saga is a way to complete one business process that spans several services, each with its own database, without a distributed lock or a two-phase commit.

Manikandan
Manikandan·20 min read