API Design Patterns: REST, gRPC, GraphQL & Event-Driven
A deep-dive into API design patterns — REST best practices, versioning strategies, gRPC for inter-service communication, GraphQL for flexible queries, and event-driven integration. With .NET implementation examples.
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 actionsFor 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 resetHTTP 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 somethingResponse Envelope
Be consistent. Pick one and stick to it:
// ✅ 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
// 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();// 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=2URL versioning is the most pragmatic choice. Use Asp.Versioning.Http package:
// 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;
});[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 —
.protofile is the source of truth - Streaming — client/server/bidirectional streams natively
- Generated client code — no hand-written HTTP clients
Defining a Service
// 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
// Program.cs
builder.Services.AddGrpc(options =>
{
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
});
app.MapGrpcService<OrderGrpcService>();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
// 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,
};
});// 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.
# 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
// Program.cs
builder.Services
.AddGraphQLServer()
.AddQueryType<Query>()
.AddMutationType<Mutation>()
.AddSubscriptionType<Subscription>()
.AddFiltering()
.AddSorting()
.AddProjections()
.UseAutomaticPersistedQueryPipeline();
app.MapGraphQL();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 dashboardsEvent Schema Design
// 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, notConfirmOrder) - 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
// Program.cs
builder.Services.AddMassTransit(x =>
{
x.AddConsumers(typeof(Program).Assembly);
x.UsingAzureServiceBus((ctx, cfg) =>
{
cfg.Host(builder.Configuration["ServiceBus:ConnectionString"]);
cfg.ConfigureEndpoints(ctx);
});
});// 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.
// Client sends a unique key per request
POST /orders
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000// 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:
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
API Design Principles Knowledge Check
5 questions · Test what you just learned · Instant explanations
Enjoyed this article?
Explore the System Design learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.