REST API Engineering · Lesson 6 of 19

API Design Patterns: REST, gRPC, GraphQL & Events

Why API Design Matters

An API is a contract. Once published, clients depend on it. Bad API design leads to:

  • Breaking changes that force coordinated upgrades across teams
  • Leaky abstractions that expose internal data models directly
  • Chatty interfaces that require 10 calls to load one screen
  • Ambiguous semantics where HTTP 200 means both success and business failure

Good API design is a first-class architectural concern — not something you figure out after the code is written.


The Four API Styles

| Style | Transport | Format | Best For | |-------|-----------|--------|----------| | REST | HTTP/1.1 | JSON | Public APIs, web/mobile clients | | gRPC | HTTP/2 | Protocol Buffers | Internal service-to-service | | GraphQL | HTTP | JSON | Flexible client-driven queries | | Async/Events | AMQP/Kafka | JSON/Avro | Decoupled cross-service integration |


REST

REST is the dominant style for public-facing APIs. The key constraints:

  • Uniform interface: resources identified by URL, manipulated via standard HTTP methods
  • Stateless: no session state on the server — each request is self-contained
  • Cacheable: responses declare their cacheability

Resource Design

Model resources around nouns, not verbs. Resources are things, actions are HTTP methods.

❌ GET  /getOrders
❌ POST /createOrder
❌ POST /cancelOrder/{id}

✅ GET    /orders
✅ POST   /orders
✅ DELETE /orders/{id}
✅ POST   /orders/{id}/cancellation   ← sub-resource for actions

For actions that don't map cleanly to CRUD, use a sub-resource noun:

POST /orders/{id}/confirmation   → confirm an order
POST /payments/{id}/refunds      → initiate a refund
POST /users/{id}/password-reset  → trigger a reset

HTTP Methods and Status Codes

GET    → retrieve (idempotent, safe)
POST   → create / trigger action (not idempotent)
PUT    → replace entire resource (idempotent)
PATCH  → partial update (idempotent)
DELETE → remove (idempotent)

Status codes that matter:

200 OK           → success with body
201 Created      → POST that created a resource (+ Location header)
204 No Content   → success, no body (DELETE, some PUT)
400 Bad Request  → validation failure (include error details in body)
401 Unauthorized → missing/invalid auth token
403 Forbidden    → authenticated but not authorised
404 Not Found    → resource doesn't exist
409 Conflict     → state conflict (duplicate, version mismatch)
422 Unprocessable Entity → valid JSON but business rule violation
429 Too Many Requests    → rate limit exceeded
500 Internal Server Error → you broke something

Response Envelope

Be consistent. Pick one and stick to it:

JSON
// ✅ Simple — just the resource
{
  "id": "ord-123",
  "status": "confirmed",
  "total": { "amount": 49.99, "currency": "GBP" }
}

// ✅ Envelope — useful for metadata
{
  "data": { "id": "ord-123", "status": "confirmed" },
  "meta": { "requestId": "req-abc", "timestamp": "2026-04-13T10:00:00Z" }
}

// ✅ Error response — always structured
{
  "type": "https://learnixo.com/errors/validation",
  "title": "Validation failed",
  "status": 400,
  "detail": "One or more fields failed validation.",
  "errors": {
    "email": ["Email is required."],
    "quantity": ["Quantity must be greater than 0."]
  }
}

The error format above follows RFC 7807 Problem Details — supported natively in ASP.NET Core via ProblemDetails.

Pagination

Never return unbounded collections. Choose one strategy:

// Cursor-based (preferred for large datasets — stable under inserts)
GET /orders?after=ord-xyz&limit=20
→ { "data": [...], "nextCursor": "ord-abc", "hasMore": true }

// Offset-based (simple, but unstable under concurrent writes)
GET /orders?page=3&pageSize=20
→ { "data": [...], "total": 200, "page": 3, "pageSize": 20 }

Filtering, Sorting, Searching

GET /orders?status=confirmed&customerId=cust-123
GET /orders?sort=-createdAt          ← minus prefix = descending
GET /products?search=widget&category=tools

.NET Minimal API REST Implementation

C#
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();   // RFC 7807 error responses
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
// ... other services

var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();

// Map endpoint groups
app.MapGroup("/api/v1/orders")
   .MapOrderEndpoints()
   .RequireAuthorization()
   .WithOpenApi();

app.Run();
C#
// OrderEndpoints.cs
public static class OrderEndpoints
{
    public static RouteGroupBuilder MapOrderEndpoints(this RouteGroupBuilder group)
    {
        group.MapGet("/", GetOrders);
        group.MapGet("/{id}", GetOrder);
        group.MapPost("/", CreateOrder);
        group.MapPost("/{id}/confirmation", ConfirmOrder);
        group.MapDelete("/{id}", CancelOrder);
        return group;
    }

    private static async Task<IResult> GetOrders(
        [AsParameters] OrdersQuery query,
        IMediator mediator,
        CancellationToken ct)
    {
        var result = await mediator.Send(new GetOrdersQuery(query), ct);
        return TypedResults.Ok(result);
    }

    private static async Task<IResult> CreateOrder(
        CreateOrderRequest request,
        IValidator<CreateOrderRequest> validator,
        IMediator mediator,
        CancellationToken ct)
    {
        var validation = await validator.ValidateAsync(request, ct);
        if (!validation.IsValid)
            return TypedResults.ValidationProblem(validation.ToDictionary());

        var orderId = await mediator.Send(new CreateOrderCommand(request), ct);
        return TypedResults.Created($"/api/v1/orders/{orderId}", new { id = orderId });
    }

    private static async Task<IResult> ConfirmOrder(
        Guid id,
        IMediator mediator,
        CancellationToken ct)
    {
        await mediator.Send(new ConfirmOrderCommand(id), ct);
        return TypedResults.NoContent();
    }
}

API Versioning

APIs must be versioned from day one. Three approaches:

// URL path — simplest, most visible
GET /api/v1/orders
GET /api/v2/orders

// Header — cleaner URLs, harder to test in browser
GET /api/orders
Accept-Version: 2

// Query parameter — avoid for REST (pollutes URLs)
GET /api/orders?version=2

URL versioning is the most pragmatic choice. Use Asp.Versioning.Http package:

C#
// Program.cs
builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;
    options.ApiVersionReader = new UrlSegmentApiVersionReader();
});

builder.Services.AddVersionedApiExplorer(options =>
{
    options.GroupNameFormat = "'v'VVV";
    options.SubstituteApiVersionInUrl = true;
});
C#
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/orders")]
public class OrdersV1Controller : ControllerBase { ... }

[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/orders")]
public class OrdersV2Controller : ControllerBase { ... }

Versioning rules:

  • Adding fields to a response — non-breaking, no version bump needed
  • Removing fields / changing types — breaking change, bump the major version
  • Adding required request fields — breaking change
  • Adding optional request fields — non-breaking

gRPC

gRPC uses HTTP/2 and Protocol Buffers. Advantages over REST for internal services:

  • 4–10× smaller payloads — binary Protobuf vs JSON
  • Strongly typed contracts.proto file is the source of truth
  • Streaming — client/server/bidirectional streams natively
  • Generated client code — no hand-written HTTP clients

Defining a Service

PROTOBUF
// orders.proto
syntax = "proto3";
option csharp_namespace = "OrderService";

package orders;

service OrderService {
  rpc GetOrder     (GetOrderRequest)      returns (OrderResponse);
  rpc CreateOrder  (CreateOrderRequest)   returns (CreateOrderResponse);
  rpc StreamOrders (StreamOrdersRequest)  returns (stream OrderResponse);
}

message GetOrderRequest  { string order_id = 1; }
message CreateOrderRequest {
  string customer_id = 1;
  repeated OrderItem items = 2;
}
message OrderItem {
  string product_id = 1;
  int32  quantity   = 2;
}
message OrderResponse {
  string order_id  = 1;
  string status    = 2;
  double total     = 3;
}
message CreateOrderResponse { string order_id = 1; }
message StreamOrdersRequest { string customer_id = 1; }

.NET gRPC Server

C#
// Program.cs
builder.Services.AddGrpc(options =>
{
    options.EnableDetailedErrors = builder.Environment.IsDevelopment();
});

app.MapGrpcService<OrderGrpcService>();
C#
public class OrderGrpcService : OrderService.OrderServiceBase
{
    private readonly IMediator _mediator;
    public OrderGrpcService(IMediator mediator) => _mediator = mediator;

    public override async Task<OrderResponse> GetOrder(
        GetOrderRequest request,
        ServerCallContext context)
    {
        var order = await _mediator.Send(new GetOrderQuery(Guid.Parse(request.OrderId)));
        if (order is null)
            throw new RpcException(new Status(StatusCode.NotFound, $"Order {request.OrderId} not found."));

        return new OrderResponse
        {
            OrderId = order.Id.ToString(),
            Status  = order.Status.ToString(),
            Total   = (double)order.Total.Amount,
        };
    }

    public override async Task StreamOrders(
        StreamOrdersRequest request,
        IServerStreamWriter<OrderResponse> stream,
        ServerCallContext context)
    {
        var orders = await _mediator.Send(new GetCustomerOrdersQuery(request.CustomerId));
        foreach (var order in orders)
        {
            if (context.CancellationToken.IsCancellationRequested) break;
            await stream.WriteAsync(new OrderResponse
            {
                OrderId = order.Id.ToString(),
                Status  = order.Status.ToString(),
            });
        }
    }
}

.NET gRPC Client

C#
// Typed client — registered once, reused
builder.Services.AddGrpcClient<OrderService.OrderServiceClient>(options =>
{
    options.Address = new Uri(builder.Configuration["Services:OrderService"]);
}).ConfigurePrimaryHttpMessageHandler(() =>
{
    return new SocketsHttpHandler
    {
        PooledConnectionIdleTimeout    = TimeSpan.FromMinutes(5),
        KeepAlivePingDelay             = TimeSpan.FromSeconds(60),
        KeepAlivePingTimeout           = TimeSpan.FromSeconds(30),
        EnableMultipleHttp2Connections = true,
    };
});
C#
// Using the client
public class ShippingService
{
    private readonly OrderService.OrderServiceClient _orders;
    public ShippingService(OrderService.OrderServiceClient orders) => _orders = orders;

    public async Task<string> GetOrderStatusAsync(string orderId)
    {
        var response = await _orders.GetOrderAsync(new GetOrderRequest { OrderId = orderId });
        return response.Status;
    }
}

GraphQL

GraphQL lets clients request exactly the fields they need — no over-fetching or under-fetching.

GRAPHQL
# Client asks for exactly what it needs  no more
query {
  order(id: "ord-123") {
    id
    status
    total { amount currency }
    items {
      product { name imageUrl }
      quantity
      lineTotal { amount }
    }
  }
}

Use GraphQL when:

  • Multiple client types (web, mobile, third-party) need different shapes of the same data
  • You want to avoid endpoint proliferation for each client's view
  • You need real-time subscriptions alongside queries and mutations

.NET with Hot Chocolate

C#
// Program.cs
builder.Services
    .AddGraphQLServer()
    .AddQueryType<Query>()
    .AddMutationType<Mutation>()
    .AddSubscriptionType<Subscription>()
    .AddFiltering()
    .AddSorting()
    .AddProjections()
    .UseAutomaticPersistedQueryPipeline();

app.MapGraphQL();
C#
public class Query
{
    [UseProjection]
    [UseFiltering]
    [UseSorting]
    public IQueryable<Order> GetOrders([Service] AppDbContext db)
        => db.Orders.AsNoTracking();

    public async Task<Order?> GetOrder(
        Guid id,
        [Service] IOrderRepository repo,
        CancellationToken ct)
        => await repo.GetByIdAsync(id, ct);
}

public class Mutation
{
    public async Task<CreateOrderPayload> CreateOrder(
        CreateOrderInput input,
        [Service] IMediator mediator,
        CancellationToken ct)
    {
        var orderId = await mediator.Send(new CreateOrderCommand(input), ct);
        return new CreateOrderPayload(orderId);
    }
}

Event-Driven APIs

For fire-and-forget or cross-service integration, publish events rather than calling APIs synchronously.

// Synchronous REST — tight coupling, cascading failures
OrderService → HTTP POST → InventoryService
OrderService → HTTP POST → NotificationService
OrderService → HTTP POST → AnalyticsService

// Event-driven — loose coupling, resilient
OrderService → publishes OrderConfirmed event
  ↳ InventoryService subscribes → reserves stock
  ↳ NotificationService subscribes → sends email
  ↳ AnalyticsService subscribes → updates dashboards

Event Schema Design

C#
// Use a consistent event envelope
public record IntegrationEvent
{
    public Guid   EventId   { get; init; } = Guid.NewGuid();
    public string EventType { get; init; } = default!;
    public string Source    { get; init; } = default!;
    public DateTimeOffset OccurredAt { get; init; } = DateTimeOffset.UtcNow;
}

public record OrderConfirmedIntegrationEvent : IntegrationEvent
{
    public Guid   OrderId    { get; init; }
    public Guid   CustomerId { get; init; }
    public decimal Total     { get; init; }
    public string Currency   { get; init; } = default!;
    public IReadOnlyList<OrderLineDto> Lines { get; init; } = [];
}

Event design rules:

  • Include enough data that consumers don't need to call back to the source
  • Use past tense (OrderConfirmed, not ConfirmOrder)
  • Version events with a type discriminator ("type": "order.confirmed.v2")
  • Never include internal DB IDs that would couple consumers to your schema

Publishing with MassTransit + Azure Service Bus

C#
// Program.cs
builder.Services.AddMassTransit(x =>
{
    x.AddConsumers(typeof(Program).Assembly);

    x.UsingAzureServiceBus((ctx, cfg) =>
    {
        cfg.Host(builder.Configuration["ServiceBus:ConnectionString"]);
        cfg.ConfigureEndpoints(ctx);
    });
});
C#
// Publishing — in the domain event handler
public class OrderConfirmedDomainEventHandler
    : INotificationHandler<OrderConfirmedEvent>
{
    private readonly IPublishEndpoint _bus;
    public OrderConfirmedDomainEventHandler(IPublishEndpoint bus) => _bus = bus;

    public async Task Handle(OrderConfirmedEvent notification, CancellationToken ct)
    {
        await _bus.Publish(new OrderConfirmedIntegrationEvent
        {
            OrderId    = notification.OrderId.Value,
            CustomerId = notification.CustomerId.Value,
            Total      = notification.Total.Amount,
            Currency   = notification.Total.Currency,
        }, ct);
    }
}

// Consuming — in another service
public class OrderConfirmedConsumer
    : IConsumer<OrderConfirmedIntegrationEvent>
{
    private readonly IEmailService _email;
    public OrderConfirmedConsumer(IEmailService email) => _email = email;

    public async Task Consume(ConsumeContext<OrderConfirmedIntegrationEvent> context)
    {
        var @event = context.Message;
        await _email.SendOrderConfirmationAsync(@event.CustomerId, @event.OrderId);
    }
}

Idempotency

Make mutation endpoints idempotent — safe to retry without duplicating side effects.

C#
// Client sends a unique key per request
POST /orders
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
C#
// Middleware that deduplicates requests
public class IdempotencyMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IDistributedCache _cache;

    public async Task InvokeAsync(HttpContext context)
    {
        var key = context.Request.Headers["Idempotency-Key"].FirstOrDefault();
        if (key is null) { await _next(context); return; }

        var cacheKey = $"idempotency:{key}";
        var cached   = await _cache.GetStringAsync(cacheKey);
        if (cached is not null)
        {
            context.Response.ContentType = "application/json";
            context.Response.StatusCode  = 200;
            await context.Response.WriteAsync(cached);
            return;
        }

        // Capture the response
        var originalBody = context.Response.Body;
        using var buffer = new MemoryStream();
        context.Response.Body = buffer;

        await _next(context);

        buffer.Seek(0, SeekOrigin.Begin);
        var responseBody = await new StreamReader(buffer).ReadToEndAsync();

        // Store for 24 hours
        await _cache.SetStringAsync(cacheKey, responseBody,
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24) });

        buffer.Seek(0, SeekOrigin.Begin);
        await buffer.CopyToAsync(originalBody);
        context.Response.Body = originalBody;
    }
}

Rate Limiting

ASP.NET Core 7+ ships with built-in rate limiting:

C#
builder.Services.AddRateLimiter(options =>
{
    // Global: 100 requests/minute per IP
    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: ctx.Connection.RemoteIpAddress?.ToString() ?? "anon",
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit       = 100,
                Window            = TimeSpan.FromMinutes(1),
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                QueueLimit        = 5,
            }));

    options.OnRejected = async (ctx, ct) =>
    {
        ctx.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
        ctx.HttpContext.Response.Headers.RetryAfter = "60";
        await ctx.HttpContext.Response.WriteAsync("Rate limit exceeded. Retry after 60s.", ct);
    };
});

app.UseRateLimiter();

Key Takeaways

  • REST — model resources as nouns, use HTTP methods semantically, always version from day one
  • gRPC — prefer for internal service-to-service (smaller payloads, streaming, generated clients)
  • GraphQL — when multiple clients need different shapes of the same data
  • Events — for fire-and-forget and cross-service integration that must be decoupled
  • Idempotency — make all mutation endpoints safe to retry, especially payments
  • Rate limiting — always — protect your service from unintentional and intentional hammering
  • The right API style depends on who the consumer is: public API → REST; internal services → gRPC or events; flexible client queries → GraphQL