Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close

    Guided course · Architecture and Design in .NET

    Microservices and Messaging in .NET: back to the course

    Module 1 · Boundaries and Communication · Lesson 2 of 16

    REST vs gRPC vs Events: Choosing How Services Communicate

    Learning outcome

    You can choose between REST/JSON, gRPC and asynchronous messages for a given interaction, and justify the choice with coupling, latency, payload, tooling and failure behavior. You can show a gRPC service and client in .NET 10, explain deadlines and status codes, and explain why "everything async" is as wrong as "everything synchronous".

    Intuition

    Ask two questions about every interaction. Does the caller need the answer now to continue? If yes, it is a request/response call (REST or gRPC). If no, it is a message (a command to one owner, or an event that anyone can react to). Who is the caller? Browsers and third parties speak HTTP/JSON comfortably. Internal service-to-service traffic with a shared contract is where gRPC shines.

    flowchart TD
        Q1{"Caller needs the result to continue?"} -- yes --> Q2{"Public / browser / third party?"}
        Q2 -- yes --> REST["REST + JSON (HTTP/1.1 or 2)"]
        Q2 -- no --> GRPC["gRPC (HTTP/2, Protobuf, deadlines)"]
        Q1 -- no --> Q3{"Exactly one owner must act?"}
        Q3 -- yes --> CMD["Command message (Send to a queue)"]
        Q3 -- no --> EVT["Event (Publish to all subscribers)"]

    Deep dive

    • REST/JSON: universal tooling, human-readable, cacheable GETs, works from browsers. Costs: text payloads, schema by convention (OpenAPI helps), and it is easy to drift into chatty APIs.
    • gRPC: contract-first .proto files, generated clients and servers, Protobuf binary encoding, HTTP/2 multiplexing, streaming (server, client, bidirectional), and deadlines that can be propagated. Costs: browsers need gRPC-Web or JSON transcoding, payloads are not human-readable, and load balancers must understand HTTP/2 (long-lived connections defeat naive L4 balancing).
    • Messages (queues/topics): temporal decoupling (the consumer can be down), load leveling, fan-out to many subscribers. Costs: eventual consistency, duplicates (Lesson 6), ordering questions (Lesson 14), harder debugging (Lesson 12).
    • Coupling is not removed by going async. If service B must react before A can answer the user, an async message just hides a synchronous dependency behind a queue and a timeout.
    • Failure semantics differ:
      • REST: status codes (404, 409, 503...), retries need idempotency (link below).
      • gRPC: StatusCode values (NotFound, DeadlineExceeded, Unavailable...). In .NET the client sets a deadline as an absolute DateTime; it travels as a grpc-timeout header, and the server's ServerCallContext.CancellationToken fires when it passes.
      • Messages: the broker redelivers on failure, so the consumer must be idempotent, and poison messages need a dead-letter queue (Lesson 7).
    • Message size: ASP.NET Core gRPC limits incoming messages to 4 MB by default (MaxReceiveMessageSize = 4,194,304 bytes after AddGrpc() in Grpc.AspNetCore 2.84.0; a freshly constructed GrpcServiceOptions shows null because the default is applied by the options setup) and does not limit outgoing ones. Large files belong in blob storage with a reference in the message, for both gRPC and brokers.

    Code example: the same call over REST and gRPC

    The proto contract:

    protobuf
    syntax = "proto3";
    option csharp_namespace = "Catalog";
    service CatalogService { rpc GetProduct (ProductRequest) returns (ProductReply); }
    message ProductRequest { int32 id = 1; }
    message ProductReply { int32 id = 1; string name = 2; int64 price_cents = 3; repeated string tags = 4; bool in_stock = 5; }
    C#
    // The same "get product" call exposed as REST/JSON (HTTP/1.1) and as gRPC (HTTP/2, Protobuf), in one process.
    using System.Text.Json;
    using Catalog;
    using Google.Protobuf;
    using Grpc.Core;
    using Grpc.Net.Client;
    using Microsoft.AspNetCore.Server.Kestrel.Core;
    
    var builder = WebApplication.CreateBuilder();
    builder.Logging.ClearProviders();
    builder.Services.AddGrpc();
    builder.WebHost.ConfigureKestrel(k =>
    {
        k.ListenLocalhost(5101, o => o.Protocols = HttpProtocols.Http1);   // REST
        k.ListenLocalhost(5102, o => o.Protocols = HttpProtocols.Http2);   // gRPC without TLS (h2c) needs an HTTP/2-only endpoint
    });
    var app = builder.Build();
    app.MapGet("/products/{id:int}", (int id) => Data.Get(id) is { } p ? Results.Ok(p) : Results.NotFound());
    app.MapGrpcService<CatalogImpl>();
    await app.StartAsync();
    
    // REST call
    using var http = new HttpClient { BaseAddress = new Uri("http://localhost:5101") };
    var resp = await http.GetAsync("/products/42");
    var json = await resp.Content.ReadAsByteArrayAsync();
    Console.WriteLine($"REST  HTTP/{resp.Version} {(int)resp.StatusCode} {resp.Content.Headers.ContentType}");
    Console.WriteLine($"REST  body {json.Length} bytes: {System.Text.Encoding.UTF8.GetString(json)}");
    
    // gRPC call
    using var channel = GrpcChannel.ForAddress("http://localhost:5102");
    var client = new CatalogService.CatalogServiceClient(channel);
    var reply = await client.GetProductAsync(new ProductRequest { Id = 42 });
    Console.WriteLine($"gRPC  reply: id={reply.Id} name={reply.Name} priceCents={reply.PriceCents} tags=[{string.Join(",", reply.Tags)}] inStock={reply.InStock}");
    Console.WriteLine($"gRPC  Protobuf message {reply.CalculateSize()} bytes (plus a 5-byte gRPC frame prefix on the wire)");
    
    // Contract errors: unknown id
    try { await client.GetProductAsync(new ProductRequest { Id = 7 }); }
    catch (RpcException e) { Console.WriteLine($"gRPC  error status: {e.StatusCode} \"{e.Status.Detail}\""); }
    var miss = await http.GetAsync("/products/7");
    Console.WriteLine($"REST  error status: {(int)miss.StatusCode} {miss.StatusCode}");
    
    // Deadline: the client gives the server 50 ms; the server is slow for id 99
    try { await client.GetProductAsync(new ProductRequest { Id = 99 }, deadline: DateTime.UtcNow.AddMilliseconds(50)); }
    catch (RpcException e) { Console.WriteLine($"gRPC  deadline: {e.StatusCode}"); }
    
    await app.StopAsync();
    
    static class Data
    {
        public static object? Get(int id) => id == 42 ? new { id = 42, name = "Mechanical keyboard", priceCents = 749900L, tags = new[] { "peripherals", "usb-c" }, inStock = true } : null;
    }
    class CatalogImpl : CatalogService.CatalogServiceBase
    {
        public override async Task<ProductReply> GetProduct(ProductRequest r, ServerCallContext ctx)
        {
            if (r.Id == 99) await Task.Delay(1000, ctx.CancellationToken);
            if (r.Id != 42) throw new RpcException(new Status(StatusCode.NotFound, $"product {r.Id} not found"));
            return new ProductReply { Id = 42, Name = "Mechanical keyboard", PriceCents = 749900, Tags = { "peripherals", "usb-c" }, InStock = true };
        }
    }

    Output (.NET 10, Grpc.AspNetCore 2.84.0, Kestrel on localhost)

    Text
    REST  HTTP/1.1 200 application/json; charset=utf-8
    REST  body 104 bytes: {"id":42,"name":"Mechanical keyboard","priceCents":749900,"tags":["peripherals","usb-c"],"inStock":true}
    gRPC  reply: id=42 name=Mechanical keyboard priceCents=749900 tags=[peripherals,usb-c] inStock=True
    gRPC  Protobuf message 49 bytes (plus a 5-byte gRPC frame prefix on the wire)
    gRPC  error status: NotFound "product 7 not found"
    REST  error status: 404 NotFound
    gRPC  deadline: DeadlineExceeded

    Code walkthrough

    • REST went over HTTP/1.1 with a 104-byte JSON body. The same data as a Protobuf message is 49 bytes: field names are not sent, only field numbers and compact values. On the wire gRPC adds a 5-byte length-prefix frame per message plus HTTP/2 headers, so do not quote "gRPC is 2x smaller" as a rule. Measure your own messages.
    • Kestrel needed an HTTP/2-only endpoint (port 5102) for gRPC without TLS. With TLS, HTTP/1.1 and HTTP/2 can share a port via ALPN.
    • A missing product became NotFound with a detail string in gRPC and 404 in REST. Map domain errors to status codes deliberately in both.
    • The call with a 50 ms deadline against a server that waits 1 second failed with DeadlineExceeded. The server saw its CancellationToken cancelled, so it stopped working too. Pass the token to every downstream call so the whole chain gives up together.

    Failure modes

    • Public APIs exposed only as gRPC, forcing browser clients through extra proxies.
    • gRPC without deadlines: a stuck downstream service keeps calls and associated resources in flight until something else times out.
    • HTTP/2 connections pinned to one backend by an L4 load balancer: one pod gets all traffic. Use L7 balancing or client-side balancing.
    • Using messages for queries ("request/response over a queue" for every read) and then fighting timeouts.
    • Changing .proto field numbers or reusing a deleted number: old clients decode the wrong data silently.

    Interview angle

    "REST or gRPC between your services?" Answer with criteria: "Internal, latency-sensitive, contract-first and polyglot: gRPC, with deadlines and HTTP/2-aware load balancing. Public or browser-facing: REST/JSON with OpenAPI. And if the caller does not need the result to continue, neither: publish an event or send a command, and accept eventual consistency."

    Practice questions

    1. Why is a Protobuf message smaller than the same JSON?

    Answer: Protobuf sends field numbers and varint/length-delimited binary values instead of field names and text. In the example 49 bytes vs 104 bytes.

    1. What happens on the server when a gRPC client deadline passes?

    Answer: The client gets DeadlineExceeded; on the server ServerCallContext.CancellationToken is cancelled, so well-written handlers stop and propagate cancellation downstream.

    1. When is an async message the wrong choice?

    Answer: When the caller needs the result to continue (for example, validate stock before confirming a checkout). A queue then only adds latency and a timeout path.

    1. What is a safe change to a .proto message?

    Answer: Adding a new field with a new number. Never change the type or number of an existing field, and reserved the numbers of removed fields.

    Revision checklist

    • Needs the answer now: REST (public) or gRPC (internal). Otherwise: command or event.
    • gRPC: contract-first, Protobuf, HTTP/2, deadlines, 4 MB default receive limit.
    • Deadlines and cancellation must flow through the whole chain.
    • Async does not remove coupling; it changes its failure mode.

    Analogy

    Everyday picture

    Phone call, form, and noticeboard

    A REST or gRPC call is a phone call: you wait on the line for the answer. A command message is a form dropped in one department's tray: one owner will process it later. An event is a notice pinned on a board: anyone interested reads it, and the person who pinned it does not know who did.

    Quick reference

    Choosing the style

    • Public/browser + need answer: REST/JSON.
    • Internal + need answer + contract-first: gRPC with deadlines.
    • One owner must act later: command (Send).
    • Others may react: event (Publish).
    • Large payloads: blob storage + reference (claim check).
    Sign in to mark lessons done and keep your place in the course.Sign in