Problem Context

GraphQL solves two real problems REST is bad at: over-fetching (mobile downloads 12 fields, uses 3) and under-fetching (one screen needs 5 round trips). The trade is operational complexity โ€” N+1 query risk, harder caching, harder rate limiting. In 2026 the ecosystem has matured around Apollo Federation v2.x for subgraph composition, persisted queries for bandwidth + security, and HotChocolate 14 on .NET (with built-in DataLoader, projection, and federation support).

The right question in 2026 is no longer "REST or GraphQL?" but "where does each fit?" โ€” GraphQL for product/aggregation graphs (BFFs, mobile, partner APIs); REST for resource CRUD, webhooks, and anything cacheable at the edge.

๐Ÿค” Sound familiar?
  • Your single GraphQL query triggers 400 SQL statements
  • Clients send 50KB queries on every render
  • You can't rate-limit because every request hits one URL
  • Your federated graph composition fails on every deploy

DataLoader for N+1, persisted queries for bandwidth + security, and field-level cost analysis for limits.

Concept Explanation

The four building blocks:

  • Schema โ€” strongly-typed (SDL). Types, queries, mutations, subscriptions.
  • Resolvers โ€” functions that fetch one field. Composable, testable.
  • DataLoader โ€” request-scoped batching + caching. Solves N+1.
  • Federation โ€” multiple subgraphs composed into one supergraph by a gateway (Apollo Router / Cosmo Router).

flowchart LR
    C["Mobile client"] -->|persisted query hash| GW["GraphQL Gateway<br/>(Apollo Router / HotChocolate)"]
    GW --> S1["Users subgraph"]
    GW --> S2["Orders subgraph"]
    GW --> S3["Catalog subgraph"]
    S1 --> DB1["Users DB"]
    S2 --> DB2["Orders DB"]
    S3 --> DB3["Catalog DB"]

    style GW fill:#E10098,color:#fff,stroke:#a8006d

Implementation

Step 1: Define the schema (HotChocolate 14, .NET 9)

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddGraphQLServer()
    .AddQueryType<Query>()
    .AddMutationType<Mutation>()
    .AddProjections()       // EF Core projections from selection set
    .AddFiltering()
    .AddSorting()
    .AddInstrumentation();  // OpenTelemetry traces

var app = builder.Build();
app.MapGraphQL();           // POST /graphql + GET /graphql/ui (Nitro)
app.Run();

Step 2: Resolvers with EF Core projection

public class Query
{
    [UseProjection]                       // selection set โ†’ SELECT only requested cols
    [UseFiltering]
    [UseSorting]
    public IQueryable<Order> GetOrders([Service] AppDb db) => db.Orders;
}

public class Order
{
    public long Id { get; set; }
    public DateTimeOffset PlacedAt { get; set; }
    public long CustomerId { get; set; }

    // Field-level resolver, batched via DataLoader (next step)
    public async Task<Customer?> GetCustomer(
        CustomerByIdDataLoader loader, CancellationToken ct)
        => await loader.LoadAsync(CustomerId, ct);
}

Step 3: DataLoader for N+1 elimination

// HotChocolate 14 source-generated DataLoader
public static class CustomerDataLoaders
{
    [DataLoader]
    internal static async Task<IReadOnlyDictionary<long, Customer>>
        GetCustomerByIdAsync(
            IReadOnlyList<long> ids,
            AppDb db,
            CancellationToken ct)
        => await db.Customers
            .Where(c => ids.Contains(c.Id))
            .ToDictionaryAsync(c => c.Id, ct);
}
// 100 orders โ†’ ONE query: SELECT * FROM customers WHERE id IN (...)

Step 4: Persisted queries (bandwidth + allow-list)

builder.Services
    .AddGraphQLServer()
    .UsePersistedOperationPipeline()       // require known query hashes only
    .AddFileSystemOperationDocumentStorage("./persisted");

// Build step extracts queries from client โ†’ SHA-256 โ†’ ./persisted/{hash}.graphql
// Production rejects any query not in the store. No more 50KB POST bodies,
// no more arbitrary queries from attackers.

Step 5: Cost analysis + complexity limits

builder.Services
    .AddGraphQLServer()
    .ModifyCostOptions(o =>
    {
        o.MaxFieldCost = 1_000;
        o.MaxTypeCost  = 1_000;
        o.EnforceCostLimits = true;
    });

// In schema:
// type Query {
//   orders(first: Int @cost(weight: "10")): [Order!]! @listSize(slicingArguments: ["first"])
// }
// Stops a malicious 10-level-deep nested query from melting the DB.

Step 6: Federation v2 subgraph

builder.Services
    .AddGraphQLServer()
    .AddApolloFederation()
    .AddType<OrderType>();

public class OrderType : ObjectType<Order>
{
    protected override void Configure(IObjectTypeDescriptor<Order> d)
    {
        d.Key("id");                          // @key(fields: "id")
        d.Field(o => o.Customer)
         .ResolveReferenceWith(...);          // entity resolver for federation
    }
}
// Apollo Router / Cosmo composes this with other subgraphs into one supergraph.

Common Pitfalls

  1. N+1 everywhere. Every nested field that hits a DB without DataLoader is an N+1 waiting to happen. Make DataLoader the default, not the exception.
  2. Allowing arbitrary queries in production. Without persisted queries or query allow-listing, a client (or attacker) can request the whole graph. Use UsePersistedOperationPipeline.
  3. No depth/cost limits. A 7-level nested query on a graph with cycles can run for hours. SetMaxFieldCost and depth limits before going live.
  4. Treating mutations like REST. Mutations should return the updated entity (so clients update their cache without a refetch). void mutations are an anti-pattern.
  5. One giant monolith schema.Past ~50 types it's painful. Federate by domain (users, orders, catalog) and let a router compose.
  6. Caching is "free". GraphQL hits one URL, so HTTP caching is harder than REST. Use persisted queries + response cache by query hash + per-field DataLoader cache.

Practical Takeaways

  • GraphQL shines for BFFs, mobile, and aggregation graphs. REST still wins for cacheable resource CRUD.
  • HotChocolate 14 + EF Core projections eliminates most N+1 risk via selection-set translation.
  • DataLoader is non-optional. Treat it like the using keyword.
  • Persisted queries cut bandwidth and lock down the surface area at the same time. Always on in prod.
  • Federation v2 lets teams own subgraphs independently โ€” but only adopt it once you have 3+ teams.
  • Set cost + depth limits before the first deploy, not after the first incident.
  • Keep mutations returning the updated entity; clients will thank you.