TechCorp now has its two main channels: REST/JSON for synchronous calls (03-01) and RabbitMQ for events (03-02). With those you can build the whole system, and in fact that is what we will do in module 4. But it is worth knowing two alternatives that solve specific problems REST/JSON solves poorly: gRPC, for high-volume internal calls with a typed, binary contract, and GraphQL, so that a front end can request exactly the data it needs from several services in a single query. They are neither fads nor replacements for REST: they are tools with a clear niche, and knowing what it is prevents both ignoring them and using them where they do not belong.

In this lesson we will look at which limitations of REST/JSON motivate each alternative; gRPC with Protocol Buffers (a real .proto for the Inventory service), HTTP/2, the four call types, and a minimal server and client in Node.js with @grpc/grpc-js explained line by line; GraphQL with a schema of orders and products, resolvers, the N+1 problem and DataLoader; a comparison table of REST, gRPC, GraphQL and messaging; and TechCorp's decision on where each one fits. The gateway and the BFF (where GraphQL would shine) belong to 03-04, and versioning of .proto files and GraphQL schemas, to 03-06.

Contents

  1. Limitations of REST/JSON that motivate alternatives
  2. gRPC: what it is and how it works
  3. Protocol Buffers: Inventory's .proto contract
  4. gRPC server and client in Node.js
  5. Errors in gRPC and when to use it
  6. GraphQL: schema, queries and mutations
  7. Resolvers in Node.js
  8. The N+1 problem and DataLoader
  9. When to use GraphQL and its risks
  10. Comparison: REST, gRPC, GraphQL and messaging
  11. TechCorp's decision

  1. Limitations of REST/JSON that motivate alternatives

REST over JSON is the de facto standard because it is simple, universal and readable. Precisely because of that it has three shortcomings that become noticeable as a system grows:

Limitation What it consists of Where TechCorp would suffer it
Verbosity and serialization cost JSON is text: every field repeats its name, numbers travel as strings of digits, and parsing it costs CPU. HTTP/1.1 opens connections and sends full headers on every request. Orders → Inventory if it became synchronous: thousands of calls per minute at the peak, each with the same JSON of lines.
Lack of a strong contract OpenAPI describes the API, but it is a document separate from the code: nothing prevents the server from returning price as a string one day. Clients are hand-written or optionally generated. A change in Catalog that breaks Orders' productTranslator is discovered in production or, with luck, in 04-05.
Over-fetching and under-fetching An endpoint returns a fixed shape. If the front end needs less, there is excess (over-fetching); if it needs data from two resources, it makes two requests (under-fetching). The mobile app's "order detail" screen needs the order, the products with their images and the payment status: three calls, or a bespoke endpoint per screen.

gRPC attacks the first two (compact binary, HTTP/2 and a contract generated from a .proto). GraphQL attacks the third (the client declares the exact shape it wants). Neither attacks all three, and neither solves what messaging solves (temporal coupling).

  1. gRPC: what it is and how it works

gRPC is a remote procedure call (RPC) framework created by Google. Its ingredients:

  • Contract first. You write a .proto file that defines the services, their methods and the messages they exchange. From it, server and client code is generated in whatever language (Node.js, Go, Java...). The contract is not documentation: it is the source.
  • Protocol Buffers (protobuf) as the serialization format: binary, compact (an int32 takes 1-5 bytes; in JSON, "quantity": 2 takes 13), and with a schema (the receiver knows the type of every field without guessing).
  • HTTP/2 as the transport: a single TCP connection multiplexes many calls in parallel, headers are compressed, and there is native streaming in both directions.
  • Four call types:
Type Client sends Server returns Example at TechCorp
Unary 1 message 1 message ReserveStock(ReservationRequest) → Reservation
Server streaming 1 message Stream of N messages WatchAvailability(productId) → stream Availability (the client receives every stock change)
Client streaming Stream of N messages 1 message ImportStock(stream Movement) → Summary (bulk load from the warehouse)
Bidirectional Stream Stream Real-time support chat (out of TechCorp's scope)

The right mental model: gRPC is like calling a function that lives in another process, with types checked at compile time (or at load time, in Node.js). REST is like manipulating documents. That is why gRPC fits internal service-to-service calls and REST fits public APIs.

  1. Protocol Buffers: Inventory's .proto contract

In 03-01 we designed POST /reservations as Inventory's REST contract "in case the Orders↔Inventory relationship becomes synchronous". This is the same contract in gRPC:

// inventory.proto
syntax = "proto3";

package techcorp.inventory.v1;

// The service: each rpc is a remote method
service InventoryService {
  // Unary: reserve stock for an order (equivalent to POST /reservations)
  rpc ReserveStock (ReservationRequest) returns (Reservation);
  // Unary: check availability of several products (batch, like GET /products?ids=)
  rpc CheckAvailability (AvailabilityRequest) returns (AvailabilityResponse);
  // Server streaming: receive availability changes for a product
  rpc WatchAvailability (WatchRequest) returns (stream Availability);
}

// The messages: each field has a type, a name and a NUMBER. The number is what travels over the wire;
// the name is only for the code. That is why a number is never reused (03-06).
message ReservationLine {
  string product_id = 1;   // "p-501"
  int32 quantity = 2;
}

message ReservationRequest {
  string order_id = 1;                    // "ord-88213"; also serves as the idempotency key
  repeated ReservationLine lines = 2;     // repeated = list
  int32 expires_in_seconds = 3;           // 900
}

enum ReservationStatus {
  RESERVATION_STATUS_UNSPECIFIED = 0;     // proto3 requires a default value of 0
  ACTIVE = 1;
  CONSUMED = 2;
  RELEASED = 3;
}

message Reservation {
  string id = 1;                          // "res-4471"
  string order_id = 2;
  ReservationStatus status = 3;
  string expires_at = 4;                  // ISO 8601; there is a standard Timestamp type, but this is more didactic
  repeated ReservationLine lines = 5;
}

message AvailabilityRequest {
  repeated string product_ids = 1;
}

message Availability {
  string product_id = 1;
  int32 available_units = 2;
  bool available = 3;
}

message AvailabilityResponse {
  repeated Availability products = 1;
}

message WatchRequest {
  string product_id = 1;
}

How to read it:

  • syntax = "proto3" is the current version of the language. package provides a namespace (and, with v1, leaves room for versioning, the subject of 03-06).
  • service groups the rpcs. Each rpc has an input message and an output message; stream in front of one of them turns it into a stream.
  • message is like a struct. Each field carries a type (string, int32, bool, repeated X, another message, enum), a name in snake_case (the protobuf convention; the generated code converts it to camelCase in JavaScript if asked to) and a field number unique within the message. That number is the field's identity in the binary: changing the name breaks nothing; changing the number does.
  • In proto3 all fields are optional and have a default value ("", 0, false, empty list). There is no null: you have to decide how to represent "absent" (with optional, available since protobuf 3.15, or with a wrapper message).
  • enums must have a 0 value, which is the default; by convention it is named *_UNSPECIFIED to tell it apart from a real value.

Notice that this .proto is the contract between Orders and Inventory: if Luis's team and the Inventory team (which in 02-01 we saw are the same team) agree on it, each generates its side and works in parallel.

  1. gRPC server and client in Node.js

In Node.js there are two ways to use a .proto: generate static code with protoc and the JavaScript plugin, or load it dynamically at runtime with @grpc/proto-loader. The second is the simplest for learning and the one we will use; in production TechCorp would also use dynamic loading unless it needs generated TypeScript types.

npm install @grpc/grpc-js @grpc/proto-loader

Server (inside inventory-service; only the gRPC part, the rest of the service belongs to module 4):

// grpc/inventoryServer.js
const path = require('node:path');
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');

// 1. Load and "compile" the .proto in memory.
//    keepCase:false converts product_id → productId in the JS objects.
//    longs/enums/defaults control how types are represented; these values are the usual ones.
const definition = protoLoader.loadSync(path.join(__dirname, 'inventory.proto'), {
  keepCase: false, longs: String, enums: String, defaults: true, oneofs: true
});
// 2. Turn the definition into usable gRPC objects; we navigate down to the package
const proto = grpc.loadPackageDefinition(definition).techcorp.inventory.v1;

// 3. Implementation of each rpc. The signature is always (call, callback) for unary calls.
//    call.request is the input message already deserialized; callback(error, response) responds.
const implementation = {
  ReserveStock: async (call, callback) => {
    const { orderId, lines, expiresInSeconds } = call.request;
    try {
      // The real logic (transaction on stock/reservations, publishing stock.reserved) belongs to module 4
      const reservation = await useCases.reserveStock({ orderId, lines, expiresInSeconds });
      callback(null, {
        id: reservation.id, orderId: reservation.orderId, status: 'ACTIVE',
        expiresAt: reservation.expiresAt.toISOString(), lines: reservation.lines
      });
    } catch (err) {
      // 4. Errors are communicated with gRPC codes, not with exceptions (section 5)
      if (err.code === 'OUT_OF_STOCK') {
        return callback({ code: grpc.status.FAILED_PRECONDITION, details: `OUT_OF_STOCK: ${err.message}` });
      }
      callback({ code: grpc.status.INTERNAL, details: 'Internal error' });
    }
  },

  CheckAvailability: async (call, callback) => {
    const { productIds } = call.request;
    if (productIds.length === 0 || productIds.length > 100) {
      return callback({ code: grpc.status.INVALID_ARGUMENT, details: 'Between 1 and 100 ids' });
    }
    const rows = await stockRepository.availabilityOf(productIds);
    callback(null, {
      products: rows.map(r => ({ productId: r.productId, availableUnits: r.available, available: r.available > 0 }))
    });
  },

  // 5. Server streaming: no callback; we write to 'call' as many times as needed and close with end()
  WatchAvailability: (call) => {
    const { productId } = call.request;
    const unsubscribe = stockNotifier.subscribe(productId, (avail) => {
      call.write({ productId, availableUnits: avail, available: avail > 0 });
    });
    call.on('cancelled', unsubscribe); // the client hung up: stop sending
  }
};

// 6. Create the server, register the service with its implementation and listen.
//    createInsecure() = no TLS; fine inside the cluster for learning. TLS/mTLS is covered in 07-02.
function startGrpc(port = 50051) {
  const server = new grpc.Server();
  server.addService(proto.InventoryService.service, implementation);
  server.bindAsync(`0.0.0.0:${port}`, grpc.ServerCredentials.createInsecure(), (err) => {
    if (err) throw err;
    console.log(`gRPC InventoryService listening on ${port}`);
  });
  return server;
}

module.exports = { startGrpc };

Client (inside orders-service):

// grpc/inventoryClient.js
const path = require('node:path');
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');

// 1. Same .proto, same options: the contract is shared (in an npm package or a contracts repo)
const definition = protoLoader.loadSync(path.join(__dirname, 'inventory.proto'), {
  keepCase: false, longs: String, enums: String, defaults: true, oneofs: true
});
const proto = grpc.loadPackageDefinition(definition).techcorp.inventory.v1;

// 2. A stub: an object with one method per rpc. The address is the stable service name (03-05).
const INVENTORY_GRPC = process.env.INVENTORY_GRPC ?? 'inventory-service:50051';
const stub = new proto.InventoryService(INVENTORY_GRPC, grpc.credentials.createInsecure());

// 3. Stubs use callbacks; we wrap them in promises to use them with async/await
function reserveStock(request, { requestId } = {}) {
  return new Promise((resolve, reject) => {
    // 4. Metadata = gRPC headers. We propagate X-Request-Id just like in REST.
    const metadata = new grpc.Metadata();
    if (requestId) metadata.set('x-request-id', requestId);
    // 5. deadline = absolute timeout: if there is no response within 2 s, DEADLINE_EXCEEDED error
    const deadline = new Date(Date.now() + 2000);

    stub.ReserveStock(request, metadata, { deadline }, (err, response) => {
      if (err) return reject(err);   // err.code is a grpc.status; err.details the text
      resolve(response);
    });
  });
}

module.exports = { reserveStock };

And its use, with our usual order:

try {
  const reservation = await reserveStock({
    orderId: 'ord-88213',
    lines: [{ productId: 'p-501', quantity: 1 }, { productId: 'p-777', quantity: 2 }],
    expiresInSeconds: 900
  }, { requestId: 'req-01J4ZK9X2M' });
  console.log(reservation.id, reservation.status); // res-4471 ACTIVE
} catch (err) {
  if (err.code === grpc.status.FAILED_PRECONDITION) { /* OUT_OF_STOCK → cancel order */ }
  else if (err.code === grpc.status.DEADLINE_EXCEEDED) { /* Inventory not responding → 503 */ }
  else throw err;
}

The essentials: the .proto is the only shared file; server and client load it and get typed objects; the method and field names come from the contract, not from a hand-written URL; and the deadline is the equivalent of the AbortSignal.timeout from 03-01.

  1. Errors in gRPC and when to use it

gRPC does not use HTTP codes: it has its own, fewer and more precise. The ones TechCorp maps:

gRPC code REST equivalent When
OK (0) 200 Success
INVALID_ARGUMENT (3) 400 / 422 Malformed request or invalid values
NOT_FOUND (5) 404 Nonexistent resource
ALREADY_EXISTS (6) 409 Already exists (duplicate reservation without idempotency)
FAILED_PRECONDITION (9) 409 The state does not allow the operation: OUT_OF_STOCK, invalid transition
PERMISSION_DENIED (7) / UNAUTHENTICATED (16) 403 / 401 Authorization / authentication
RESOURCE_EXHAUSTED (8) 429 Limit exceeded
DEADLINE_EXCEEDED (4) 503/504 on the caller Timeout
UNAVAILABLE (14) 503 Server down or starting up; the client may retry
INTERNAL (13) 500 Unhandled failure

The business code (OUT_OF_STOCK) travels in details or, better, in a structured error message (google.rpc.Status with typed details); for TechCorp the prefix in details plus a mapping in the client is enough.

When to use gRPC:

  • Internal service-to-service communication with high volume or a low-latency requirement: binary serialization and HTTP/2 become noticeable from thousands of calls per second upward.
  • When you want a strong contract and generated code in several languages (teams with Go, Java and Node.js).
  • Streaming (progress, real-time tracking).

When not to:

  • Public APIs consumed by browsers: browsers do not speak native gRPC (gRPC-Web exists with a proxy, but it adds parts), and external developers expect REST.
  • When readability matters more than performance: you cannot curl it and read the response.
  • Small teams without a performance problem: it is complexity with no return.

For TechCorp: Orders → Inventory is the natural candidate if it ever becomes synchronous (partnership from 02-03, same team, frequent calls). Today it goes through events and will stay that way; the .proto is designed and ready.

  1. GraphQL: schema, queries and mutations

GraphQL is a query language for APIs and a runtime that executes them. The central idea: the server publishes a typed schema of everything that can be requested, and the client sends a query describing exactly the shape of the response it wants. A single endpoint (POST /graphql), a single round trip, not one field more or less.

Schema (SDL, Schema Definition Language) for an orders view that combines data from Orders and from Catalog:

# schema.graphql
type Product {
  id: ID!
  name: String!
  price: Float!
  currency: String!
  imageUrl: String
  available: Boolean!
}

type OrderLine {
  productId: ID!
  name: String!              # frozen in the order (02-03)
  quantity: Int!
  unitPrice: Float!
  product: Product           # LIVE data from Catalog! (image, current availability)
}

enum OrderStatus { PENDING STOCK_RESERVED PAID CONFIRMED CANCELLED }

type Address { street: String!, postalCode: String!, city: String!, country: String! }

type Order {
  id: ID!
  status: OrderStatus!
  customerId: ID!
  lines: [OrderLine!]!
  total: Float!
  shippingAddress: Address!
  createdAt: String!
}

type Query {
  order(id: ID!): Order
  customerOrders(customerId: ID!, limit: Int = 20, cursor: String): [Order!]!
  products(ids: [ID!]!): [Product!]!
}

input LineInput { productId: ID!, quantity: Int! }
input AddressInput { street: String!, postalCode: String!, city: String!, country: String! }

type Mutation {
  createOrder(customerId: ID!, lines: [LineInput!]!, shippingAddress: AddressInput!): Order!
}

How to read it: type defines objects with typed fields (! = non-null; [X!]! = non-null list of non-null elements); Query holds the reads and Mutation the writes (both are just special types); input types are for arguments; enum as in protobuf. Notice OrderLine.product: a field that crosses services. It is what REST does not give you without a bespoke endpoint.

The mobile client's query for the order detail screen:

query OrderDetail($id: ID!) {
  order(id: $id) {
    id
    status
    total
    lines {
      name
      quantity
      unitPrice
      product { imageUrl available }
    }
  }
}

With {"id": "ord-88213"} as variables, the response has exactly that shape:

{
  "data": {
    "order": {
      "id": "ord-88213",
      "status": "CONFIRMED",
      "total": 79.7,
      "lines": [
        { "name": "BT X200 Headphones", "quantity": 1, "unitPrice": 59.9, "product": { "imageUrl": "https://cdn.techcorp.example/p-501.webp", "available": true } },
        { "name": "USB-C Cable 2 m", "quantity": 2, "unitPrice": 9.9, "product": { "imageUrl": "https://cdn.techcorp.example/p-777.webp", "available": true } }
      ]
    }
  }
}

No customerId, no shippingAddress, no createdAt: they were not requested. And a mutation:

mutation {
  createOrder(
    customerId: "c-1024",
    lines: [{ productId: "p-501", quantity: 1 }, { productId: "p-777", quantity: 2 }],
    shippingAddress: { street: "Gran Vía 12", postalCode: "28013", city: "Madrid", country: "ES" }
  ) { id status }
}

  1. Resolvers in Node.js

The schema says what can be requested; the resolvers say how each field is obtained. A resolver is a function (parent, args, context, info) per field; those not defined resolve by default by reading the property of the same name on the parent object. We will use graphql-yoga, a lightweight GraphQL server that mounts on Express (Apollo Server is the better-known alternative and equivalent for what we do here):

npm install graphql graphql-yoga
// graphql/server.js (this will live in the mobile BFF from 03-04, not in Orders)
const { createSchema, createYoga } = require('graphql-yoga');
const { readFileSync } = require('node:fs');
const express = require('express');

// REST clients of the internal services (the ones from 03-01)
const ordersApi = require('../clients/ordersClient');       // GET /orders/{id}, POST /orders
const catalogApi = require('../clients/catalogClient');     // GET /products?ids=

const typeDefs = readFileSync(require.resolve('./schema.graphql'), 'utf8');

const resolvers = {
  Query: {
    // 1. Root resolver: args carries the query arguments; context, what we inject per request
    order: (_parent, { id }, context) => ordersApi.getOrder(id, { requestId: context.requestId }),
    products: (_parent, { ids }, context) => catalogApi.getProducts(ids, { requestId: context.requestId })
  },
  Mutation: {
    createOrder: (_parent, args, context) =>
      ordersApi.createOrder(args, { requestId: context.requestId, idempotencyKey: context.idempotencyKey })
  },
  OrderLine: {
    // 2. Field resolver: 'parent' is the line already fetched by the resolver above.
    //    It only runs if the query asked for 'product'. THIS is where the N+1 problem is born (section 8).
    product: (line, _args, context) => context.productsLoader.load(line.productId)
  }
};

const yoga = createYoga({
  schema: createSchema({ typeDefs, resolvers }),
  // 3. The context is created per request: request id, a fresh DataLoader, and later on the user (07-01)
  context: ({ request }) => ({
    requestId: request.headers.get('x-request-id') ?? crypto.randomUUID(),
    idempotencyKey: request.headers.get('idempotency-key'),
    productsLoader: createProductsLoader(request) // section 8
  }),
  graphqlEndpoint: '/graphql'
});

const app = express();
app.use(yoga.graphqlEndpoint, yoga); // POST /graphql (and GET with the GraphiQL interface for testing)

The chain for OrderDetail: the engine calls Query.order (one REST call to Orders), gets the order with its two lines, and for each line calls OrderLine.product. Without further care, that is two calls to Catalog (one per line) on top of the one to Orders; with 20 lines, twenty. It is the N+1 we already avoided in REST with ?ids=; in GraphQL it must be avoided with DataLoader.

  1. The N+1 problem and DataLoader

DataLoader is a small library (created by Facebook alongside GraphQL) that does two things: it batches every call to .load(id) that happens in the same event-loop tick into a single call to your batch function, and it caches per request so as not to request the same id twice.

npm install dataloader
// graphql/loaders.js
const DataLoader = require('dataloader');
const catalogApi = require('../clients/catalogClient');

function createProductsLoader(request) {
  const requestId = request.headers.get('x-request-id');
  // The batch function receives ALL the ids requested in this tick and must return
  // an array of the SAME size and in the SAME order (null where one does not exist)
  return new DataLoader(async (ids) => {
    const products = await catalogApi.getProducts([...ids], { requestId }); // ONE call: GET /products?ids=p-501,p-777
    const byId = new Map(products.map(p => [p.id, p]));
    return ids.map(id => byId.get(id) ?? null);
  }, { maxBatchSize: 100 }); // we honor the batch limit of the Catalog contract (03-01)
}

module.exports = { createProductsLoader };

With this, the OrderDetail query makes exactly two REST calls (Orders and Catalog) regardless of the number of lines. Two rules: the DataLoader is created per request (in the context), never globally (the cache would leak data between users and would not refresh), and its batch function must respect the order of the ids.

  1. When to use GraphQL and its risks

When yes:

  • Aggregation for front ends: an app or website with many screens that combine data from several services and evolve at different paces. The client requests what it needs without the back end creating one endpoint per screen. This is the case of the BFF we will see in 03-04: GraphQL is an excellent way to implement it.
  • Clients with limited bandwidth (mobile) where over-fetching is costly.
  • When the front-end and back-end teams want to decouple their pace: the back end publishes the schema; the front end decides what to query.

When not:

  • Between internal services: services do not need flexibility of shape; they need stable, simple contracts (REST or gRPC) or events.
  • As the single facade of the whole system ("one graph to rule them all"): it ends up being a schema monolith maintained by a bottleneck team.

Risks that must be managed from day one:

Risk Why Mitigation
Expensive queries The client can request customerOrders { lines { product { ... } } } with arbitrary nesting, or huge lists Depth and "complexity" limits (each field adds up; queries above a threshold are rejected); maximum limit on lists; timeouts
HTTP cache rendered useless Everything is POST /graphql: CDNs and browsers do not cache by URL Client-side cache (Apollo Client, urql), persisted queries (the client sends a hash of a registered query and GET can be used), per-field cache on the server
N+1 One naive resolver per field DataLoader whenever a field crosses services
Partial errors A query can return data with parts set to null and an errors list; clients not used to it ignore it Always handle errors; decide per field whether it is nullable (Product in OrderLine is nullable on purpose: if Catalog fails, the order is still shown)
Per-field authorization There is no longer "one route = one permission" Check permissions in resolvers or with directives; covered in 07-01

  1. Comparison: REST, gRPC, GraphQL and messaging

Criterion REST/JSON gRPC GraphQL Messaging (RabbitMQ)
Format JSON (text) Protobuf (binary) JSON (text) Whatever you want; TechCorp: JSON
Contract OpenAPI (optional, separate from the code) .proto (mandatory, generates code) SDL schema (mandatory, introspectable) Event schema (AsyncAPI, optional)
Transport HTTP/1.1 or 2 HTTP/2 HTTP (usually POST) AMQP
Synchrony Synchronous Synchronous (+ streaming) Synchronous (+ subscriptions) Asynchronous
Temporal coupling Yes Yes Yes No
Response shape Fixed per endpoint Fixed per method Decided by the client Fixed per event
Readability / debugging Excellent (curl) Low (binary; grpcurl needed) Good (GraphiQL) Medium (broker console)
HTTP cache Native (ETag, Cache-Control) No Hard Not applicable
Browsers Native Via gRPC-Web + proxy Native No (via WebSocket/SSE in a service)
Performance Good Excellent Good (depends on resolvers) Excellent for decoupling and absorbing peaks
Use cases Public APIs, CRUD, simple internal calls High-volume internal, polyglot, streaming Aggregation for front ends, BFF Business events, sagas, integration
Tooling The whole HTTP ecosystem protoc, grpcurl, Buf Apollo, Yoga, GraphiQL, DataLoader RabbitMQ, amqplib, management console
Learning curve Low Medium Medium (high to do it well: N+1, complexity) Medium (new mental model)

  1. TechCorp's decision

Marta and the team leads set the protocol policy:

Scope Protocol Reason
Public API (web, app, partners) through the gateway on 8080 REST/JSON with OpenAPI Universal, cacheable, debuggable; it is what external consumers expect
Between services, by default Events on RabbitMQ No temporal coupling; it is the saga from 02-05
Between services, when an immediate response is needed REST/JSON (Orders → Catalog, Orders → Customers) Moderate volume; reuses the contracts from 03-01 and the same tooling
Orders ↔ Inventory Events today; gRPC candidate if it becomes synchronous Same team, partnership, potentially high volume; the .proto is already designed
Mobile app BFF GraphQL candidate Screens that aggregate Orders + Catalog + Payments; mobile bandwidth; decided in 03-04

The rule that sums up the decision: no new protocol is introduced until there is a measured problem that REST + events do not solve. gRPC and GraphQL remain known tools with a reserved place, not decisions made out of fashion.

Common Mistakes and Tips

  • Adopting gRPC "because it is faster" without measuring. If Orders → Catalog makes 50 calls per second, JSON is not your problem. Measure first (06-04).
  • Reusing field numbers in a .proto or changing the type of an existing field. The old receiver will interpret bytes with the wrong meaning. It is covered in depth in 03-06; for now: new numbers, always.
  • Forgetting the deadline in the gRPC client. Same as fetch without signal: the call can hang indefinitely.
  • GraphQL without DataLoader. The N+1 in GraphQL is silent: in development, with two lines, you do not notice; in production, with twenty, it is a storm of requests to Catalog.
  • GraphQL without complexity limits. It amounts to exposing an arbitrary query to the Internet. Maximum depth and maximum cost from the first deployment.
  • Global DataLoader (shared across requests). It caches one user's data for another and never refreshes. One per request, in the context.
  • Using GraphQL between internal services. It adds a resolution layer and loses the simplicity of REST/gRPC without gaining anything: services do not need to choose the shape.
  • Ignoring errors in the GraphQL response. data can come with partial nulls and the error be in another list. Clients must look at both.

Exercises

Exercise 1. Add to Inventory's .proto a unary method ReleaseReservation that receives the reservation id and a reason (OUT_OF_STOCK, PAYMENT_REJECTED, PAYMENT_TIMEOUT, CUSTOMER_CANCELLATION) and returns the reservation with status RELEASED. Define the messages with correct field numbers, an enum for the reason, and write the server implementation with the appropriate gRPC codes for: nonexistent reservation, reservation already CONSUMED (cannot be released), and success.

Exercise 2. Extend the GraphQL schema with type Payment { id: ID!, status: PaymentStatus!, amount: Float!, method: String! } and PaymentStatus (the statuses from 02-03), and add the field payment: Payment to Order. Write the Order.payment resolver using a DataLoader over a client paymentsApi.getPaymentsByOrders(orderIds) (GET /payments?orderIds=) and explain why the field must be nullable.

Exercise 3. For each scenario, choose REST, gRPC, GraphQL or messaging and justify it in two sentences: (a) the physical warehouse sends 50,000 stock movements every night from a Java system; (b) an external partner wants to check the status of its orders from its ERP; (c) the internal admin website shows a dashboard with the day's orders, their payments and the associated reservations; (d) when an order is confirmed, the invoice has to be generated in a future Billing service.

Solutions

Solution 1.

enum ReleaseReason {
  RELEASE_REASON_UNSPECIFIED = 0;
  OUT_OF_STOCK = 1;
  PAYMENT_REJECTED = 2;
  PAYMENT_TIMEOUT = 3;
  CUSTOMER_CANCELLATION = 4;
}

message ReleaseRequest {
  string reservation_id = 1;
  ReleaseReason reason = 2;
}

service InventoryService {
  // ... the previous ones ...
  rpc ReleaseReservation (ReleaseRequest) returns (Reservation);
}
ReleaseReservation: async (call, callback) => {
  const { reservationId, reason } = call.request;
  if (!reservationId || reason === 'RELEASE_REASON_UNSPECIFIED') {
    return callback({ code: grpc.status.INVALID_ARGUMENT, details: 'reservation_id and reason are required' });
  }
  const reservation = await reservationsRepository.get(reservationId);
  if (!reservation) return callback({ code: grpc.status.NOT_FOUND, details: `Reservation ${reservationId} does not exist` });
  if (reservation.status === 'CONSUMED') {
    return callback({ code: grpc.status.FAILED_PRECONDITION, details: 'RESERVATION_CONSUMED: cannot be released' });
  }
  if (reservation.status === 'RELEASED') {
    // Idempotent: releasing twice returns the same result without an error
    return callback(null, toMessage(reservation));
  }
  const released = await useCases.releaseReservation(reservationId, reason); // publishes stock.released
  callback(null, toMessage(released));
}

Important detail: releasing an already-released reservation responds OK with the same reservation, not FAILED_PRECONDITION: the operation is idempotent by design, consistent with the at-least-once redelivery from 03-02.

Solution 2.

enum PaymentStatus { AUTHORIZED CAPTURED REJECTED REFUNDED }
type Payment { id: ID!, status: PaymentStatus!, amount: Float!, method: String! }
extend type Order { payment: Payment }
function createPaymentsLoader(request) {
  const requestId = request.headers.get('x-request-id');
  return new DataLoader(async (orderIds) => {
    const payments = await paymentsApi.getPaymentsByOrders([...orderIds], { requestId }); // GET /payments?orderIds=ord-88213,...
    const byOrder = new Map(payments.map(p => [p.orderId, p]));
    return orderIds.map(id => byOrder.get(id) ?? null);
  });
}
// in resolvers:
Order: { payment: (order, _args, ctx) => ctx.paymentsLoader.load(order.id) }

Nullable for two reasons: business-wise, a PENDING or STOCK_RESERVED order does not have a payment yet (the saga has not got there); and resilience-wise, if Payments does not respond, GraphQL can return the order with payment: null and an entry in errors instead of failing the whole query. A non-null Payment! would propagate the null upward and null out the entire order.

Solution 3.

(a) gRPC with client streaming (ImportStock(stream Movement)): high volume, a client in another language that generates its code from the same .proto, compact binary for 50,000 messages. Valid alternative: a batch file; but if an API is wanted, gRPC. (b) REST/JSON: external partner, generic ERP, needs curl, OpenAPI and caching; it is the public API through the gateway. (c) GraphQL in an admin BFF: one screen that aggregates three services with a changing shape; with DataLoader over GET /orders, GET /payments?orderIds= and the Inventory contract. Acceptable alternative: a REST composition endpoint in the BFF, if the dashboard is stable. (d) Messaging: Billing subscribes to order.confirmed on a billing.orders queue; Orders neither changes nor knows Billing exists (03-02, exercise 1).

Conclusion

REST/JSON has three clear shortcomings (verbosity, weak contract, fixed response shape) and we have seen the tool that solves each one. gRPC brings a .proto contract from which the code is generated, binary serialization and HTTP/2 with streaming, and fits high-volume internal calls; we have written the InventoryService with ReserveStock and CheckAvailability, its server and client with @grpc/grpc-js and @grpc/proto-loader, and the mapping of its error codes. GraphQL brings a typed schema from which the client requests the exact shape it needs, and fits aggregation for front ends; we have written the orders and products schema, resolvers with graphql-yoga and the solution to the N+1 with a per-request DataLoader. TechCorp's decision: public REST, events between services, gRPC as a candidate for Orders↔Inventory and GraphQL as a candidate for the mobile BFF, adopting nothing until a measured problem justifies it.

That BFF, and the API Gateway on port 8080 we have been talking about since 02-02, are the next topic: which problems a single entry point solves, what it should do (route, authenticate, rate-limit, aggregate) and, above all, what it should not do, how one is implemented in Node.js or declared in Kong or Traefik, and why each type of client (web, mobile) deserves its own backend for frontend.

Microservices Course

Module 1: Introduction to Microservices

Module 2: Microservice Design

Module 3: Communication between Microservices

Module 4: Implementing Microservices

Module 5: Deployment and Orchestration

Module 6: Monitoring and Maintenance

Module 7: Security in Microservices

Module 8: Case Studies and Practical Examples

© Copyright 2026. All rights reserved