Section 08

APIs: styles, design, auth, performance & rate limiting

APIs are the contracts between systems. This section covers which API style to use, how to design them well, how to secure them, how to make them fast, and how to protect them.

8.1 API architecture patterns (styles)

The main API styles, each built for a different kind of interaction between client and server.

StyleShapeStrengthsWeaknessesUse for
RESTResources + HTTP verbs, JSONSimple, cacheable, universal toolingOver/under-fetching, many round tripsPublic, partner and CRUD APIs
GraphQLOne endpoint, client writes the queryGet exactly the fields you need, typed schemaHTTP caching is harder, query-cost attacks, N+1Rich UIs, mobile, BFF
gRPCRPC on HTTP/2 + ProtobufFast, streaming, contract-first, generated codeWeak browser support, hard to read on the wireInternal microservices
SOAPXML envelope + WSDLStrict contracts, WS-Security, transactionsVerbose, heavyLegacy enterprise and banking cores
WebhooksServer POSTs an event to a client URLReal-time, no pollingDelivery guarantees, securityEvent notifications to partners
WebSocket / SSEPersistent connectionReal-time pushStateful scalingLive UIs, streaming
Async / event APIsMessages on a broker (documented with AsyncAPI)Decoupled, scalableEventual consistencyIntegration, event-driven systems
  • A common enterprise mix: REST outside (partners, mobile), gRPC or events inside, GraphQL/BFF for UI aggregation.
  • Contract-first: OpenAPI (REST), .proto (gRPC), SDL (GraphQL), AsyncAPI (events).
How to implement it
StyleLibrary / APINotes
RESTbuilt-in Minimal APIs (MapGet…) or controllersMinimal APIs are the default for new services
GraphQLHotChocolate.AspNetCoreSchema-first or code-first server, DataLoader against N+1, query cost limits
gRPCGrpc.AspNetCore, Grpc.Net.Client; Microsoft.AspNetCore.Grpc.JsonTranscodingContract-first from .proto. JSON transcoding also exposes the same service as REST
SOAPCoreWCF.Http (server); System.ServiceModel.Http + dotnet-svcutil (client)Host or call legacy WCF / SOAP services on .NET 10
WebSocket / SSEbuilt-in SignalR; TypedResults.ServerSentEvents (new in .NET 10)Live UIs and streaming. SignalR handles scale-out (Azure SignalR Service / Redis backplane)
Webhooks / eventsIHttpClientFactory + HMACSHA256; Azure.Messaging.ServiceBus, Confluent.KafkaNo built-in webhook framework: sign the payload, retry with Polly, keep a delivery log
// .NET 10: stream server-sent events from a minimal API
app.MapGet("/payments/{id}/status", (string id, PaymentEvents events, CancellationToken ct) =>
    TypedResults.ServerSentEvents(events.WatchAsync(id, ct), eventType: "status"));

REST vs GraphQL vs gRPC: stop asking "which is best". Ask "who is the consumer?" 🎯

🌍 External partners → REST (universal, cacheable)

📱 Rich front-end needing flexible data → GraphQL

⚙️ Service-to-service, low latency → gRPC

🔔 "Tell me when it happens" → Webhooks / events

#APIs #Architecture #Integration

8.2 REST design best practices Added

Nouns for resources, HTTP verbs for actions, status codes for outcomes, and a contract you never break silently.

Methods

VerbUseSafeIdempotent
GETRead✅✅
POSTCreate / action❌❌ (use an Idempotency-Key)
PUTReplace❌✅
PATCHPartial update❌Not guaranteed
DELETERemove❌✅

URL examples

  • ✅ GET /customers/42/accounts
  • ✅ POST /payments
  • ❌ POST /getCustomerAccounts

Status codes cheat sheet

200 OK · 201 Created · 202 Accepted · 204 No Content
304 Not Modified
400 Bad Request · 401 Unauthenticated · 403 Forbidden · 404 Not Found
409 Conflict · 412 Precondition Failed · 422 Unprocessable · 429 Too Many Requests
500 Server Error · 502 Bad Gateway · 503 Unavailable · 504 Gateway Timeout

Versioning

URI /v1/ (most common), header api-version, query ?api-version=, or media type. Only breaking changes need a new major version.

Pagination

Offset ?page=3&size=50 is simple but slow and drifts on deep pages. Cursor / keyset ?after=abc is stable and fast.

Filter / sort / fields

?status=active&sort=-createdAt&fields=id,name

Errors

Use a consistent body such as RFC 9457 Problem Details: type, title, status, detail, traceId.

Concurrency

ETag + If-Match for optimistic locking. Return 412 on conflict.

HATEOAS

Responses include links to next actions. Rarely used in full.

  • Backward-compatible changes: adding optional fields or new endpoints. Breaking changes: removing or renaming fields, changing types, making a field required.
  • Consumers should be tolerant readers (ignore unknown fields).
  • Publish a deprecation policy (e.g. Sunset header, 6–12 months' notice).
How to implement it
Library / APIPackageUse it for
OpenAPI documentMicrosoft.AspNetCore.OpenApi (AddOpenApi)Generates the contract; OpenAPI 3.1 by default in .NET 10. Swashbuckle was dropped from the templates in .NET 9 but still works
API docs UIScalar.AspNetCore or Swashbuckle.AspNetCore.SwaggerUIInteractive docs over the generated document
VersioningAsp.Versioning.Http (minimal APIs) / Asp.Versioning.MvcURL, header, query or media-type versions; reports supported and deprecated versions
Errors + validationbuilt-in AddProblemDetails, IExceptionHandler, AddValidation (.NET 10); FluentValidationRFC 9457 error bodies; DataAnnotations checked automatically on minimal API parameters
PATCHMicrosoft.AspNetCore.JsonPatch.SystemTextJsonRFC 6902 JSON Patch on System.Text.Json (new in .NET 10)
Typed clientsMicrosoft.Kiota.Bundle (+ Kiota CLI), Refit, NSwagGenerate clients from the OpenAPI document
builder.Services.AddOpenApi();          // document at /openapi/v1.json
builder.Services.AddProblemDetails();   // consistent RFC 9457 errors
builder.Services.AddValidation();       // .NET 10: [Required], [Range] etc. on parameters -> 400

var app = builder.Build();
app.UseExceptionHandler();              // unhandled exceptions -> ProblemDetails 500
app.MapOpenApi();
app.MapScalarApiReference();            // UI at /scalar

app.MapPost("/v1/payments", async (CreatePayment cmd, PaymentService svc, CancellationToken ct) =>
{
    var payment = await svc.CreateAsync(cmd, ct);
    return TypedResults.Created($"/v1/payments/{payment.Id}", payment);   // 201 + Location
});

ETags aren't automatic. Return an ETag header from a row version, read If-Match (EntityTagHeaderValue) and return 412 when it doesn't match.

8.3 API authentication & authorization

Authentication = who are you? Authorization = what are you allowed to do? (Fail authN → 401. Fail authZ → 403.)

MethodHow it worksGood forCaution
Basic authBase64(user:password) in a headerInternal or legacy onlyCredentials sent every call. TLS is mandatory
API keyStatic key in a headerIdentifying apps, metering, simple partner accessIdentifies an app, not a user. Easy to leak. Rotate
OAuth 2.0 bearer tokenShort-lived access token from an authorization serverDelegated access, user and M2M APIsValidate signature, audience, expiry and scopes
OIDCIdentity layer on OAuth2 (ID token)User login and SSOID token ≠ access token
JWTSelf-contained signed tokenStateless validation at the gatewayCan't revoke easily. Keep expiry short
mTLSBoth sides present X.509 certificatesB2B, service-to-service, open bankingCertificate lifecycle management
HMAC signatureRequest signed with a shared secret + timestampWebhooks, payment APIs (AWS SigV4)Secret distribution, clock skew

Authorization models

How to implement it
MethodLibrary / API
OAuth 2.0 / JWT bearerMicrosoft.AspNetCore.Authentication.JwtBearer; for Entra ID, Microsoft.Identity.Web
API keyNo built-in handler: a custom AuthenticationHandler<T>, comparing hashes with CryptographicOperations.FixedTimeEquals
mTLSMicrosoft.AspNetCore.Authentication.Certificate + Kestrel ClientCertificateMode.RequireCertificate
HMAC signaturebuilt-in HMACSHA256.HashData over timestamp + body, checked with FixedTimeEquals
RBAC / scopes / ABACbuilt-in authorization policies: RequireRole, RequireClaim, custom IAuthorizationRequirement + handler
Object-level (BOLA)built-in resource-based IAuthorizationService.AuthorizeAsync(user, resource, requirement)
public sealed class AccountOwner : IAuthorizationRequirement;

public sealed class AccountOwnerHandler : AuthorizationHandler<AccountOwner, Account>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext ctx, AccountOwner req, Account account)
    {
        if (account.OwnerId == ctx.User.FindFirst("oid")?.Value) ctx.Succeed(req);
        return Task.CompletedTask;
    }
}
// builder.Services.AddSingleton<IAuthorizationHandler, AccountOwnerHandler>();

app.MapGet("/accounts/{id:int}", async (int id, AppDbContext db,
    IAuthorizationService auth, ClaimsPrincipal user) =>
{
    var account = await db.Accounts.FindAsync(id);
    if (account is null) return Results.NotFound();
    var check = await auth.AuthorizeAsync(user, account, new AccountOwner());  // can THIS user see THIS account?
    return check.Succeeded ? Results.Ok(account) : Results.Forbid();          // 403
}).RequireAuthorization();                                                     // 401 if not signed in

8.4 OAuth 2.0, OIDC & JWT deep dive Added

OAuth 2.0 lets an app get limited access to an API on behalf of a user or itself, without ever seeing the user's password.

Four roles

Resource ownerthe user
Clientapp wanting access
Authorization serverEntra ID, Okta, Ping
Resource serveryour API

Authorization Code + PKCE (user-facing apps, SPAs, mobile)

Grant typeUse when
Authorization Code + PKCEAny app acting for a user (web, SPA, mobile)
Client CredentialsMachine-to-machine. No user (batch job, backend service)
Device CodeDevices without a browser (TV, CLI)
Refresh TokenGet a new access token without logging in again
On-Behalf-Of / Token ExchangeAPI A calls API B while keeping the user's identity
Implicit, Password (ROPC)Deprecated in OAuth 2.1 / security best practice

JWT anatomy

  • OAuth = authorization (access token). OIDC = authentication (ID token + userinfo).
  • Never put secrets or sensitive PII in a JWT. It's only encoded.
  • Validate: signature (via JWKS kid), iss, aud, exp/nbf, scopes and roles. Keep access tokens short (5–60 min).
  • SPAs: prefer the BFF pattern (tokens kept server-side) over storing tokens in localStorage.
  • Banking: FAPI (Financial-grade API) profile tightens OAuth with mTLS or private_key_jwt, PAR and JARM. Australia's Consumer Data Right (CDR) uses it.
How to implement it
Role / flowPackageNotes
Resource server (your API)Microsoft.Identity.Web or Microsoft.AspNetCore.Authentication.JwtBearerValidates signature (JWKS), issuer, audience and expiry for you
Auth code + PKCE (web login)Microsoft.AspNetCore.Authentication.OpenIdConnect / Microsoft.Identity.WebPKCE is on by default. Server-side web apps are already a BFF: tokens stay on the server
Client credentials / OBOMicrosoft.Identity.Client (MSAL); Azure.Identity for managed identityMSAL caches tokens. Prefer a certificate or managed identity over a client secret
Attach tokens to HttpClientDuende.AccessTokenManagement (Apache 2.0)Gets, caches and refreshes tokens for IHttpClientFactory clients with any OAuth server
Your own auth serverOpenIddict.AspNetCore (free); Duende.IdentityServer commercial licenceDuende has a free community edition for small companies. Most banks use Entra ID, Okta or Ping instead
Read / create JWTsMicrosoft.IdentityModel.JsonWebTokens (JsonWebTokenHandler)Low-level token handling; rarely needed directly
// API: validate Entra ID tokens and require a scope
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("payments.write", p => p.RequireScope("payments.write"));

// Batch job: client credentials with a certificate (no user, no secret)
var msal = ConfidentialClientApplicationBuilder.Create(clientId)
    .WithCertificate(certificate)
    .WithAuthority($"https://login.microsoftonline.com/{tenantId}")
    .Build();
var result = await msal.AcquireTokenForClient(["api://payments/.default"]).ExecuteAsync();

Login without passwords: ASP.NET Core Identity in .NET 10 supports passkeys (WebAuthn / FIDO2).

OAuth 2.0 explained with a hotel key card 🏨

You (resource owner) show ID at reception (authorization server). You get a key card (access token) that opens only your room (scope) until checkout (expiry).

The door (API) doesn't need your passport. It just checks that the card is valid.

OAuth = what you can access. OIDC = who you are.

#Security #OAuth #APIs

8.5 API performance

Measure first (p95/p99 latency, throughput, error rate), find the slowest hop, then fix that hop.

Where the time goes

🗃 Caching

HTTP cache headers, CDN, gateway response cache, Redis.

📄 Pagination & field selection

Never return 10,000 rows. Use cursors and sparse fields.

🗜 Compression

gzip / Brotli for JSON payloads over ~1 KB.

🔌 Connection pooling & keep-alive

Reuse DB and HTTP connections. Avoid a new TLS handshake per call.

⚡ Async processing

Return 202 for long work. Push to a queue.

🧮 Fix the N+1 problem

Batch DB queries (JOIN, IN, DataLoader) instead of one query per item.

📦 Smaller payloads

Protobuf/gRPC internally. Trim unused fields.

🔀 Parallelise calls

Fan out independent downstream calls together, not one after another.

📈 Scale & protect

Auto-scale, load balance, rate limit, timeouts, circuit breakers.

  • Load-test tools: JMeter, k6, Gatling, Azure Load Testing. Monitor with App Insights / APM and distributed tracing.
  • Set a latency budget per hop (e.g. gateway 20 ms, service 50 ms, DB 30 ms).
  • Index DB queries. Slow APIs are usually slow queries.
How to implement it
FixLibrary / API
Cachingbuilt-in AddOutputCache, HybridCache (see section 05)
Compressionbuilt-in AddResponseCompression (Brotli, gzip). Often better done at the gateway or CDN
Connection reuseIHttpClientFactory (AddHttpClient), AddDbContextPool, HTTP/2 and HTTP/3 in Kestrel
N+1 and big payloadsEF Core Select projections, Include / AsSplitQuery, AsNoTracking; HotChocolate DataLoader; keyset pagination
Faster serialisation / start-upbuilt-in System.Text.Json source generation (JsonSerializerContext), Native AOT (PublishAot)
Parallel and asyncTask.WhenAll; IAsyncEnumerable<T> streaming; 202 + Channel<T> / queue for long work
// Keyset pagination: fast and stable on deep pages (needs an index on Id)
app.MapGet("/payments", (long? after, AppDbContext db, CancellationToken ct) =>
    db.Payments.AsNoTracking()
        .Where(p => after == null || p.Id > after)
        .OrderBy(p => p.Id)
        .Take(50)
        .Select(p => new PaymentDto(p.Id, p.Amount, p.Status))   // only the columns needed
        .ToListAsync(ct));

// Fan out independent downstream calls together, not one after another
var customerTask = customers.GetAsync(id, ct);
var accountsTask = accounts.ListAsync(id, ct);
await Task.WhenAll(customerTask, accountsTask);

Measure with OpenTelemetry traces, dotnet-counters and a load test before tuning. Response compression over HTTPS is off by default because of BREACH-style attacks on secrets in responses.

8.6 Rate limit algorithms

Rate limiting caps how many requests a client can make in a time window. It protects backends, ensures fairness and blocks abuse.

🪣 Token bucket ⭐ most popular

refill +r tokens/sec capacity B (here B = 5) 200 ✓ allowed 429 · bucket empty 1 request = 1 token burst ≤ B, then r
  • Allows bursts up to B, then a steady rate r.
  • ➕ Simple, memory-light, allows bursts. Used by AWS and Stripe.
  • ➖ Two parameters to tune.

💧 Leaky bucket

requests (bursty) queue (capacity 3) queue full → drop drips out at a fixed rate →
  • Smooths traffic to a constant output rate.
  • ➕ Steady load on the backend.
  • ➖ Bursts get queued or dropped. Adds latency.

🪟 Fixed window counter

  • Count per calendar window (e.g. per minute).
  • ➕ Simplest (Redis INCR + EXPIRE).
  • ➖ Boundary burst problem.

📜 Sliding window log

  • ➕ Exact, no boundary problem.
  • ➖ Memory-heavy (one entry per request).

🧮 Sliding window counter

previous minute current minute 80 req 40 req 25% sliding 60 s estimate = current + previous × overlap% 40 + 80 × 0.25 = 60 60 ≤ limit 100 → allow ✓
  • Blends the current and previous fixed windows.
  • ➕ Accurate enough, low memory. Popular at scale (used by Cloudflare).
  • ➖ An approximation.
AlgorithmBurstsAccuracyMemoryTypical use
Token bucketAllowed (up to B)GoodLowAPI gateways, public APIs
Leaky bucketSmoothedGoodLowTraffic shaping, protecting fragile backends
Fixed window2× at edgesWeakLowestSimple quotas
Sliding logNoExactHighLow-volume, strict limits
Sliding counterMinimalVery goodLowHigh-scale distributed limits

How to apply it

  • Rate limit (short term, e.g. 100/s) vs quota (long term, e.g. 1M/month) vs throttling (slowing down rather than rejecting).
  • Clients should back off with exponential backoff and jitter when they get a 429.
  • Tiered limits per product or plan (Free / Gold / Partner) is a classic APIM setup.
How to implement it
AlgorithmLibrary / API
Token bucketbuilt-in TokenBucketRateLimiter (AddTokenBucketLimiter)
Leaky bucketNo built-in limiter. Approximate it with a token bucket that has a QueueLimit, or a ConcurrencyLimiter
Fixed windowbuilt-in FixedWindowRateLimiter (AddFixedWindowLimiter)
Sliding window counterbuilt-in SlidingWindowRateLimiter (window split into SegmentsPerWindow). There is no sliding-log limiter
Per key / user / IPbuilt-in PartitionedRateLimiter via RateLimitPartition.Get…Limiter; ConcurrencyLimiter caps in-flight requests
Distributed (all nodes)RedisRateLimiting.AspNetCore (community, Redis-backed limiters), or your own Redis Lua script via StackExchange.Redis
builder.Services.AddRateLimiter(o =>
{
    o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    o.AddPolicy("per-api-key", http => RateLimitPartition.GetTokenBucketLimiter(
        partitionKey: http.Request.Headers["X-Api-Key"].ToString() is { Length: > 0 } key
            ? key : $"ip:{http.Connection.RemoteIpAddress}",
        factory: _ => new TokenBucketRateLimiterOptions
        {
            TokenLimit = 100,                              // bucket size B (max burst)
            TokensPerPeriod = 10,                          // refill r ...
            ReplenishmentPeriod = TimeSpan.FromSeconds(1), // ... per second
            QueueLimit = 0                                 // reject at once, don't queue
        }));
    o.OnRejected = (ctx, ct) =>
    {
        if (ctx.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
            ctx.HttpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
        return ValueTask.CompletedTask;
    };
});

var app = builder.Build();
app.UseRateLimiter();
app.MapPost("/payments", CreatePayment).RequireRateLimiting("per-api-key");

Built-in limiters count per instance (10 nodes = 10× the limit). Authenticate the key before using it as a partition, or callers can invent keys to get fresh buckets. On the client side, Microsoft.Extensions.Http.Resilience retries honour Retry-After.

5 rate-limiting algorithms in one post 🚦

🪣 Token bucket: allows bursts, then a steady rate

💧 Leaky bucket: smooth, constant output

🪟 Fixed window: simple, but 2× bursts at the edges

📜 Sliding log: exact, memory hungry

🧮 Sliding counter: the practical sweet spot

Rule of thumb: token bucket at the gateway, return 429 + Retry-After, and let clients back off.

#APIs #SystemDesign #RateLimiting

8.7 Idempotency Added

An operation is idempotent if doing it once or many times has the same effect. Essential when networks retry.

  • Payments, transfers and order creation must be idempotent.
  • Store keys with a TTL (e.g. 24 h). Scope them per client. Reject the same key sent with a different payload.
  • Message consumers: deduplicate by message ID (the idempotent receiver pattern).
How to implement it
PieceLibrary / API
Read the keybuilt-in IEndpointFilter on the POST endpoints (there's no built-in idempotency middleware)
Store the responseIDistributedCache on Redis (Microsoft.Extensions.Caching.StackExchangeRedis) or a SQL table, with a 24 h TTL
Hard guaranteeA unique index on (ClientId, IdempotencyKey) (EF Core HasIndex(…).IsUnique()), written in the same transaction as the payment
Message consumersIdempotent receiver: insert the message ID into a processed-messages table with a unique key, in the same transaction as the business change
ClientsMicrosoft.Extensions.Http.Resilience retries: send the same Idempotency-Key on every retry
public sealed class IdempotencyFilter(IDistributedCache cache) : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
    {
        var http = ctx.HttpContext;
        string? key = http.Request.Headers["Idempotency-Key"];
        if (string.IsNullOrWhiteSpace(key))
            return TypedResults.Problem("Idempotency-Key header is required", statusCode: 400);

        var cacheKey = $"idem:{http.User.FindFirst("azp")?.Value}:{key}";   // scope keys per client
        if (await cache.GetStringAsync(cacheKey) is { } saved)
            return TypedResults.Text(saved, "application/json");          // replay, don't charge twice

        var result = await next(ctx);
        if (result is IValueHttpResult { Value: { } value })
            await cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(value, JsonSerializerOptions.Web),
                new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24) });
        return result;
    }
}
// app.MapPost("/payments", CreatePayment).AddEndpointFilter<IdempotencyFilter>();

This sketch has a race: two identical requests arriving together both run. Claim the key first (Redis SET NX or the unique index), store a hash of the body to reject the same key with a different payload (422), and replay the original status code too.

8.8 GraphQL: when and how Added

GraphQL is a typed query language over one endpoint: the client asks for exactly the fields it needs, and the server resolves them from a schema.

How it compares with REST, gRPC and events is in 8.1. This is the deep dive.

How it works

Schema (SDL)

The contract. Object, scalar, enum, input, interface and union types. ! means non-null.

Three operations

Query = read. Mutation = write (runs in order). Subscription = server push over WebSocket or SSE.

Resolvers

One function per field, called as the engine walks the query.

Errors

Usually HTTP 200 with an errors array. Alert on it, not the status.

Run it safely

RiskFix
N+1 queriesDataLoader: batch and dedupe keys per request
Huge or deep queriesCost analysis, depth limit, page-size caps, timeouts
Unknown queriesPersisted (trusted) operations: only registered hashes run
CachingNo per-URL cache. Persisted ops over GET + CDN; client normalised cache
Schema exposureDisable introspection in production for private APIs
VersioningNo /v2: add fields, mark old ones @deprecated

Federation: one graph, many teams

When to use / when not

Use GraphQL whenAvoid it when
Many clients need different shapes (web, mobile)Simple CRUD with one client
A UI aggregates several services (BFF)Public partner APIs that rely on HTTP caching
Front ends change fast; fewer round tripsFiles, big downloads, bulk export
You want a typed contract and toolingService-to-service calls: use gRPC or events
  • The client writes the query, so the server must defend itself: cost, depth and page limits.
  • DataLoader is not optional. Without it, nested fields cause N+1.
  • First-party apps: persisted operations. Partner or public: cost analysis.
  • Authorize per field / per object, not just at the endpoint. BOLA still applies.
  • Federation pays off only with many teams. Start with one server.
How to implement it
NeedPackageNotes
GraphQL serverHotChocolate.AspNetCore (16.6.x, MIT)ChilliCream. Targets net10.0. Cost analysis is on by default
Source generatorHotChocolate.Types.Analyzers[QueryType], [ObjectType<T>], [DataLoader]
EF Core paging, filtersHotChocolate.Data.EntityFrameworkCursor paging, filtering, sorting, projections
Persisted operationsHotChocolate.PersistedOperations.FileSystem / .RedisLook up operations by hash
Subscriptions, many nodesHotChocolate.Subscriptions.RedisThe default in-memory pub/sub works on one node only
Federation gatewayHotChocolate.Fusion.AspNetCoreComposes subgraphs (Composite Schemas spec) via the Nitro CLI or Aspire
Typed .NET clientStrawberryShake.ServerGenerates a typed client from .graphql files
Alternative serverGraphQL.Server.Transports.AspNetCore (graphql-dotnet, 8.3.x)Less common in new .NET projects
[assembly: DataLoaderModule("BankDataLoaders")]

builder.Services.AddBankDataLoaders();           // generated from [DataLoader] methods
builder.Services
    .AddGraphQLServer()
    .AddTypes()                                  // generated from [QueryType], [ObjectType<T>]
    .ModifyCostOptions(o => { o.MaxFieldCost = 2_000; o.MaxTypeCost = 5_000; });
app.MapGraphQL();                                // POST /graphql

[QueryType]
public static partial class AccountQueries
{
    public static IQueryable<Account> GetAccounts(BankDb db) => db.Accounts.OrderBy(a => a.Id);
}

[ObjectType<Account>]
public static partial class AccountNode
{
    public static Task<Customer?> GetOwnerAsync(
        [Parent] Account account, ICustomerByIdDataLoader byId, CancellationToken ct)
        => byId.LoadAsync(account.CustomerId, ct);   // batched: no N+1
}

internal static class CustomerDataLoaders
{
    [DataLoader]
    public static Task<Dictionary<int, Customer>> GetCustomerByIdAsync(
        IReadOnlyList<int> ids, BankDb db, CancellationToken ct)
        => db.Customers.Where(c => ids.Contains(c.Id)).ToDictionaryAsync(c => c.Id, ct);
}

Keep every HotChocolate.* package on the same version. 16.6.x is the stable line; 16.7 builds are preview.

GraphQL is not "REST but better". It's a trade 🔄

✅ Clients get exactly the fields they need, in one round trip.

⚠️ But the client now writes the query, so the server must defend itself: cost limits, depth limits, page caps.

🐢 Nested fields cause N+1 queries. DataLoader batches them into one.

🧊 One POST endpoint means no easy HTTP caching. Persisted operations help.

🧩 Federation joins many teams' subgraphs into one supergraph, but only pays off at scale.

My rule: GraphQL for rich UIs and BFFs, REST for partners, gRPC or events inside. Where do you draw the line?

#GraphQL #APIDesign #SolutionArchitecture #DotNet

⚡ Quick-fire Q&A

API key vs OAuth token?

An API key identifies the calling app and never expires unless rotated. An OAuth token is short-lived, scoped, can represent a user, and is issued by a trusted authorization server.

Which grant type for a nightly batch job calling an API?

Client Credentials, ideally with a certificate or private_key_jwt, or a managed identity, instead of a client secret.

How do you rate limit across 10 gateway nodes?

Use a shared counter store (Redis) with atomic operations, or a gateway feature with synchronised counters. Accept slight approximation for speed.

401 vs 403?

401: we don't know who you are (missing or invalid token). 403: we know who you are, but you're not allowed.

How do you stop a client sending a huge, deeply nested GraphQL query?

Cost analysis (reject before execution when field or type cost is over budget), a depth limit, page-size caps and an execution timeout. For your own apps, go further with persisted (trusted) operations, so only pre-registered queries run.

Why is caching harder with GraphQL than REST?

Everything is a POST to one URL, so browsers, CDNs and gateways can't cache by URL. Use persisted operations over GET (cacheable by hash), a normalised client cache (Apollo, Relay), and DataLoader plus data-layer caching on the server.

← 07 · Data Governance & Privacy