The previous lesson left inventory running as an XML-RPC service, and left its three weaknesses in plain sight: 230 bytes of XML to transmit two arguments, an implicit contract that breaks silently when a signature changes, and no mechanism for the server to send a stream of data to the client. All three come down to the same underlying question: how are data structures turned into bytes, and how does that format evolve without breaking anyone? That is the question of serialization, and a good part of the performance, interoperability and maintainability of a distributed platform depends on its answer.
In this lesson we will compare text formats (JSON, XML) and binary formats (Protocol Buffers, Avro, MessagePack), learn how to write Protocol Buffers schemas and the rules that allow them to change without breaking old clients, and build the definitive version of Kilometre Zero's orders → inventory interface with gRPC: a contract in inventory.proto, a server in services/inventory/server.py, a client with a deadline in orders, and a server stream for watching stock changes. We will finish by measuring in code how many bytes protobuf saves compared with JSON for the same order. Asynchronous messaging (queues, Kafka) is the subject of the next lesson: here everything is synchronous.
Contents
- Serialization: from in-memory structures to bytes
- Text formats versus binary formats
- Contract-first: the schema as the source of truth
- Protocol Buffers: syntax, types and numbered fields
- Schema evolution: forward and backward compatibility
- gRPC: RPC over HTTP/2
inventoryin gRPC: contract, server and client with a deadline- Server streaming: watching stock changes
- Measuring: JSON versus protobuf for the same order
- Common mistakes and tips
- Exercises
- Conclusion
- Serialization: from in-memory structures to bytes
In lesson 02-02 we called it marshalling; the general term is serialization: transforming an in-memory object (a Python dictionary, an instance of a Java class) into a sequence of bytes that can be written to the network or to disk, and deserialization, the reverse process. Everything that crosses a process boundary goes through it: RPC arguments, events on a queue, rows in a database, files in an object store.
A serialization format has to decide four things:
- How it represents types: is an integer text (
"120") or binary (4 bytes)? Does it distinguish between integers and decimals? Does it have dates, or are they strings that follow a convention? - How it names fields: does each message carry the name (
"product": "aged-cheese"), or a number (1: "aged-cheese"), or nothing at all (fixed position)? - Where the schema lives: in the message itself (self-describing), in an external file that both ends share, or in the programmers' heads?
- How it evolves: what happens when the sender adds a field the receiver does not know about, or the other way round?
The answers to these questions split the formats into two big families.
- Text formats versus binary formats
| Criterion | JSON | XML | Protocol Buffers | Avro | MessagePack |
|---|---|---|---|---|---|
| Representation | Text | Text | Binary | Binary | Binary |
| Human-readable | Yes | Yes (verbose) | No | No | No |
| Field names in the message | Yes, in every one | Yes, in every one | No (numbers) | No (separate schema) | Yes, in every one |
| Schema | Optional (JSON Schema, separate) | Optional (XSD, DTD) | Mandatory (.proto) |
Mandatory (.avsc), travels with the data or lives in a registry |
No |
| Relative size (same order) | 100% | ~180% | ~40% | ~35% | ~75% |
| (De)serialization speed | Medium | Low | High | High | High |
| Types | Few (number, string, boolean, list, object, null) | Everything is text | Rich (int32/64, float, bytes, enum, nested messages, map) | Rich, with logical types (date, decimal) | Like JSON plus binary |
| Schema evolution | Manual, no rules | Manual | Clear rules (field numbers) | Clear rules (writer/reader schema resolution) | Manual |
| Ecosystem | Universal | Enterprise, SOAP | gRPC, Google, Kubernetes | Kafka, Hadoop, Spark | Redis, some RPC systems |
| Use at Kilometre Zero | Public REST API; events in the first phase (02-04) | No | gRPC between services | Candidate for Kafka events with a schema registry (02-05) | No |
A few observations that the table does not capture:
- JSON does not distinguish integers from decimals, nor does it have a type for money:
14.50is a double-precisionfloat, and we already saw in 01-06 that storing the price as afloatis a mistake (fallacy 8, plus rounding errors). The usual conventions (sending cents as an integer, or the amount as a string"14.50") are just that, conventions, which have to be documented and which any client can fail to follow. - The cost of JSON is not just size, it is parsing: turning
"120"into the integer 120 means interpreting characters; reading 4 bytes as an integer is a single instruction. In a service that serializes thousands of messages per second, the CPU spent on JSON is measurable. - Schemaless binary formats (MessagePack) save very little: they still send the field names in every message. The big saving comes from replacing names with numbers, and that requires a shared schema.
- Avro and Protocol Buffers solve the same problem with different philosophies: protobuf compiles the schema into code and numbers the fields; Avro does not necessarily generate code and resolves the differences between the schema a piece of data was written with and the schema it is read with. Avro fits particularly well with Kafka and with large-scale data files (Module 5); protobuf, with RPC.
- Contract-first: the schema as the source of truth
There are two ways to arrive at a contract between orders and inventory:
- Code-first: you write the implementation (the XML-RPC
Inventoryclass, the RMI Java interface) and the contract is whatever can be deduced from it. Fast at first; the contract ends up coupled to one language and changes without anyone reviewing it. - Contract-first: you write the contract first in a neutral language (the IDL from 02-02), review it as you would review a public API, version it in the repository, and generate the client and server code in each language from it.
With contract-first, the .proto file is the single source of truth: if the orders team wants to know what ReserveStock returns, they read it there, not in inventory's Python code. Changes to the contract go through code review, they can be validated automatically against the compatibility rules (section 5), and code generation guarantees that no client can call a method with the wrong types: the TypeError from exercise 3 of the previous lesson becomes a compile-time error. At Kilometre Zero, the contracts will live in km0/contracts/ and each service will generate its code from there.
- Protocol Buffers: syntax, types and numbered fields
Protocol Buffers (protobuf) is Google's IDL and serialization format, and the one gRPC uses by default. A .proto file defines messages (data structures) and, optionally, services (sets of RPCs). Let's start with a simple message, the Kilometre Zero order:
// km0/contracts/order.proto
syntax = "proto3";
package km0.orders.v1;
message OrderLine {
string product = 1; // product slug: "aged-cheese"
int32 quantity = 2;
int64 price_cents = 3; // money in cents, never as a float
}
message Order {
string id = 1; // "P-2026-000123"
string customer = 2; // "anna"
int64 timestamp_ms = 3; // milliseconds since the epoch, UTC
repeated OrderLine lines = 4; // list of lines
string market = 5; // "girona", "lleida", "tarragona", "valencia"
}Each element has a role:
syntax = "proto3"sets the language version. proto3 is the current one and the one we will use.packageprevents name clashes between contracts and appears in the fully qualified type names (km0.orders.v1.Order). Thev1suffix is a convention for versioning whole contracts, which is different from the field-by-field evolution of section 5.- Each field has a type, a name and a number. The number is what matters: it is what travels over the network, not the name. When
product = "aged-cheese"is serialized, what gets written is "field 1, string type, length 11, bytes". The nameproductdoes not appear in a single byte; it exists only in the generated code. That is why protobuf is compact, and why renaming a field is free while changing its number breaks everything. repeatedmarks a list. In proto3 all fields are optional in the sense that they may be missing; if they are, their default value is read (0, empty string, empty list,false), and a field holding its default value is not serialized (more bytes saved).
Scalar types
| Protobuf type | In Python | Recommended use | Note |
|---|---|---|---|
int32, int64 |
int |
Counters, numeric identifiers | Varint encoding: small values take 1 byte; negative ones, 10. For frequent negatives, sint32/sint64 |
uint32, uint64 |
int |
No negatives | Varint |
fixed32, fixed64 |
int |
Values that are always large (hashes) | Fixed size, faster than varint for large values |
float, double |
float |
GPS coordinates, measurements | Never money |
bool |
bool |
Flags | 1 byte |
string |
str |
UTF-8 text | Length prefix + bytes |
bytes |
bytes |
Opaque binary (images, tokens) | Length prefix + bytes |
Beyond the scalars: enum (with the value 0 mandatory as the first element and reserved for "unknown"), nested messages, map<string, int32> (dictionaries), oneof (exactly one of several fields, useful for "result or error") and proto3's optional modifier (since version 3.15), which makes it possible to distinguish "field absent" from "field set to its default value", something that is otherwise impossible: without optional, there is no way of knowing whether quantity = 0 means zero or "nobody told me".
What it looks like on the wire
To understand the saving, here is the serialization of OrderLine{product: "aged-cheese", quantity: 2, price_cents: 1450}:
0a 0b 61 67 65 64 2d 63 68 65 65 73 65 field 1 (0x0a = no. 1, length-delimited type), 11 bytes, "aged-cheese" 10 02 field 2 (0x10 = no. 2, varint type), value 2 18 aa 0b field 3 (0x18 = no. 3, varint type), value 1450 in 2 bytes
18 bytes. The same object in compact JSON, {"product":"aged-cheese","quantity":2,"price_cents":1450}, takes 57. The difference is the field names, the quotes, the colons and the numbers represented as text.
- Schema evolution: forward and backward compatibility
A contract that cannot change is useless. Kilometre Zero will deploy inventory and orders independently (it was one of the goals of 01-06), which means that for a while, different versions of the contract will coexist in production. Two definitions:
- Backward compatibility: new code can read messages written with the old schema. Needed when the reader is upgraded first (for example, a new
inventoryreceives requests from an oldorders). - Forward compatibility: old code can read messages written with the new schema. Needed when the writer is upgraded first.
In practice you need both, because you do not control the deployment order of every client (and with events persisted in Kafka, 02-04, messages from days ago will be read with today's schema). Protobuf provides them as long as you follow these rules:
| Change | Compatible? | Why |
|---|---|---|
| Adding a field with a new number | Yes | The old reader ignores numbers it does not know (and preserves them as unknown fields if it forwards the message); the new reader reads the default value if the old writer did not send it |
| Removing a field and reserving its number and name | Yes | Nobody will ever use that number again with a different meaning |
| Renaming a field | Yes (on the wire) | The name does not travel. It breaks the generated code that uses it, but not message compatibility |
| Reusing a removed number for a new field | No | An old message with number 4 as a string will be read as the new field 4 of another type: corrupt data with no error |
| Changing the type of a field | No (except between types with the same encoding, such as int32↔int64, with caveats) |
The wire encoding differs |
Changing repeated to scalar or vice versa |
No | The encoding changes |
| Changing the default value (implicit in proto3) | Not applicable | proto3 does not allow custom defaults, precisely for this reason |
Turning a field into optional |
Yes | Same encoding |
Adding a value to an enum |
Yes, with care | The old reader will see an unknown value; it must handle it (which is why 0 is "unknown") |
Example: in the second phase, orders wants to add the delivery address to the order and remove market (which will now be derived from the address):
message Order {
reserved 5; // the number of "market": it will never be reused
reserved "market"; // nor the name, so that nobody redefines it by mistake
string id = 1;
string customer = 2;
int64 timestamp_ms = 3;
repeated OrderLine lines = 4;
Address delivery_address = 6; // a NEW number, never 5
}
message Address {
string street = 1;
string city = 2;
string postal_code = 3;
}A new orders sends field 6; an old delivery ignores it and carries on working (with market empty, which its code must tolerate). An old orders sends field 5; a new delivery discards it. No deployment needs to be coordinated. This discipline (unique numbers that are never reused, reserved when removing, new fields always optional with a tolerable default value) is probably the most valuable skill in the whole lesson, and it will apply in just the same way to the event schemas in 02-05.
- gRPC: RPC over HTTP/2
gRPC is Google's RPC system, released in 2015, and today the de facto standard for synchronous communication between services. It takes the ideas from 02-02 (stubs, skeleton, IDL) and builds them on the two pieces above: Protocol Buffers as the IDL and serialization format, and HTTP/2 as the transport. That second decision is not a minor detail:
- HTTP/2 multiplexing (lesson 02-01) lets
orderskeep a single channel open toinventoryand fire hundreds of simultaneous calls through it without opening new connections or waiting for responses one after another. - HTTP/2 streams are bidirectional and long-lived, which makes streaming possible.
- Compressed headers carry metadata (authentication, traces) at little cost.
- Being HTTP, it gets through load balancers, proxies and service meshes (as long as they support HTTP/2).
The four kinds of call
flowchart LR
subgraph U["Unary"]
U1[client] -- 1 request --> U2[server]
U2 -- 1 response --> U1
end
subgraph SS["Server streaming"]
S1[client] -- 1 request --> S2[server]
S2 -- N responses --> S1
end
subgraph CS["Client streaming"]
C1[client] -- N requests --> C2[server]
C2 -- 1 response --> C1
end
subgraph BD["Bidirectional"]
B1[client] <-- N ↔ M --> B2[server]
end
| Kind | Signature in .proto |
Example at Kilometre Zero |
|---|---|---|
| Unary | rpc ReserveStock (Req) returns (Resp) |
orders reserves stock and waits for confirmation |
| Server streaming | rpc WatchChanges (Req) returns (stream Change) |
catalog subscribes to stock changes for certain products in order to show "only a few left" |
| Client streaming | rpc SendPositions (stream Position) returns (Summary) |
van-3 sends positions throughout its route and receives a summary at the end |
| Bidirectional | rpc Chat (stream Msg) returns (stream Msg) |
Real-time negotiation between delivery and the courier's app (assignments and confirmations) |
Deadlines, status codes and metadata
Three gRPC concepts that solve problems left half-finished in 02-02:
- Deadline. Instead of a relative timeout ("wait 2 s"), gRPC propagates an absolute point in time ("this call expires at 10:00:02.000"). The difference matters when one call triggers others: if
ordershas 2 s to answer Anna and takes 1.5 s to get toinventory,inventoryknows it only has 0.5 s left and can abort pointless work. The server checkscontext.time_remaining(); the client receivesDEADLINE_EXCEEDED. It is the systematic solution to "no remote call without a time limit". - Status codes. gRPC defines 17 standard codes, with semantics shared across all languages. They replace our made-up
faultCodes:
| Code | Meaning | Family (02-02) | Retry? |
|---|---|---|---|
OK |
Success | — | — |
INVALID_ARGUMENT |
The request is malformed (negative quantity) | Application | No |
NOT_FOUND |
The resource does not exist (unknown product) | Application | No |
FAILED_PRECONDITION |
The state of the system does not allow the operation (out of stock) | Application | No (until the state changes) |
ALREADY_EXISTS |
It already exists (a reservation with that id) | Application | No |
PERMISSION_DENIED, UNAUTHENTICATED |
Authorization (Module 6) | Application | No |
RESOURCE_EXHAUSTED |
Quota or rate limit exceeded | Infrastructure | Yes, after a wait |
UNAVAILABLE |
The server is not available (connection refused, restarting) | Connection | Yes, after a wait |
DEADLINE_EXCEEDED |
The deadline expired | Timeout | Only if idempotent |
UNKNOWN, INTERNAL |
Unhandled error on the server | Bug | No |
UNIMPLEMENTED |
The method does not exist in this version of the server | Protocol / deployment | No |
- Metadata. Key-value pairs that accompany the call without being part of the contract: they are carried as HTTP/2 headers. This is where authentication tokens (06-01), trace identifiers (07-02) and, as we will see, the request identifier for idempotency (02-05) travel.
inventory in gRPC: contract, server and client with a deadline
inventory in gRPC: contract, server and client with a deadlineIt is time to replace the XML-RPC server. Installation (in the service's requirements.txt):
The contract
// km0/contracts/inventory.proto
syntax = "proto3";
package km0.inventory.v1;
service Inventory {
rpc GetStock (GetStockRequest) returns (GetStockResponse);
rpc ReserveStock (ReserveStockRequest) returns (ReserveStockResponse);
// Server streaming: the client asks to watch certain products and receives
// a StockChange every time one of them changes, until it cuts the call.
rpc WatchChanges (WatchChangesRequest) returns (stream StockChange);
}
message GetStockRequest {
string product = 1;
}
message GetStockResponse {
string product = 1;
int32 units = 2;
string replica = 3; // which replica answered: "inv-bcn", "inv-vlc"
}
message ReserveStockRequest {
string product = 1;
int32 quantity = 2;
string order_id = 3; // "P-2026-000123", for traceability
string reservation_id = 4; // UUID generated by the client; the basis of the
// idempotency implemented in 02-05
}
message ReserveStockResponse {
string reservation_id = 1;
int32 remaining = 2;
}
message WatchChangesRequest {
repeated string products = 1; // empty = all
}
message StockChange {
string product = 1;
int32 before = 2;
int32 after = 3;
string reason = 4; // "reservation", "restock", "cancellation"
int64 timestamp_ms = 5;
}Generating the code
cd km0/services/inventory
python -m grpc_tools.protoc -I ../../contracts \
--python_out=. --grpc_python_out=. ../../contracts/inventory.protoThis produces two files that are never edited by hand (they are regenerated every time the .proto changes, ideally when the Docker image is built):
inventory_pb2.py: the message classes (ReserveStockRequest, etc.), serialization included.inventory_pb2_grpc.py:InventoryServicer(the base class the server implements: the skeleton) andInventoryStub(the client stub).
The same command, run in services/orders, generates the same files for the client. Each service compiles the shared contract; nobody copies code from another service.
The server
# km0/services/inventory/server.py
import queue
import threading
import time
import uuid
from concurrent import futures
import grpc
import inventory_pb2
import inventory_pb2_grpc
REPLICA = "inv-bcn"
class InventoryServicer(inventory_pb2_grpc.InventoryServicer):
"""Implementation of the service. Each method receives the deserialized
request and a 'context' with the deadline, metadata and error control."""
def __init__(self):
self._stock = {"pink-tomato": 120, "zucchini": 80, "aged-cheese": 5,
"fresh-cheese": 30, "crianza-wine": 200}
self._lock = threading.Lock()
self._watchers = [] # one queue per WatchChanges client
def GetStock(self, request, context):
with self._lock:
units = self._stock.get(request.product)
if units is None:
# abort() serializes the error with its code and ends the call
context.abort(grpc.StatusCode.NOT_FOUND,
f"unknown product: {request.product}")
return inventory_pb2.GetStockResponse(
product=request.product, units=units, replica=REPLICA)
def ReserveStock(self, request, context):
if request.quantity <= 0:
context.abort(grpc.StatusCode.INVALID_ARGUMENT,
"quantity must be positive")
if not request.reservation_id:
context.abort(grpc.StatusCode.INVALID_ARGUMENT, "missing reservation_id")
with self._lock:
available = self._stock.get(request.product)
if available is None:
context.abort(grpc.StatusCode.NOT_FOUND,
f"unknown product: {request.product}")
if available < request.quantity:
context.abort(grpc.StatusCode.FAILED_PRECONDITION,
f"only {available} units of {request.product} left")
self._stock[request.product] = available - request.quantity
remaining = self._stock[request.product]
print(f"[{REPLICA}] order {request.order_id}: reserved "
f"{request.quantity} of {request.product}, {remaining} left")
self._notify(request.product, available, remaining, "reservation")
return inventory_pb2.ReserveStockResponse(
reservation_id=request.reservation_id, remaining=remaining)
def WatchChanges(self, request, context):
"""Generator: each 'yield' sends a message to the client over the stream."""
q = queue.Queue()
product_filter = set(request.products)
self._watchers.append(q)
try:
while context.is_active(): # False when the client cancels or the deadline expires
try:
change = q.get(timeout=1.0)
except queue.Empty:
continue
if not product_filter or change.product in product_filter:
yield change
finally:
self._watchers.remove(q) # clean up when the stream ends
def _notify(self, product, before, after, reason):
change = inventory_pb2.StockChange(
product=product, before=before, after=after, reason=reason,
timestamp_ms=int(time.time() * 1000))
for q in list(self._watchers):
q.put(change)
def main():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=16))
inventory_pb2_grpc.add_InventoryServicer_to_server(InventoryServicer(), server)
server.add_insecure_port("0.0.0.0:50051") # no TLS for now: mTLS in 06-04
server.start()
print(f"[{REPLICA}] gRPC listening on :50051")
server.wait_for_termination()
if __name__ == "__main__":
main()Compared with XML-RPC (02-02), notice what has changed: errors use standard codes with semantics known to any client; the context gives access to the deadline and to cancellation; a streaming method is simply a Python generator; and the server is a ThreadPoolExecutor with an explicit size (16 threads: when they run out, calls wait, and with a deadline they expire instead of piling up indefinitely).
The client in orders, with a deadline
# km0/services/orders/inventory_client.py
import uuid
import grpc
import inventory_pb2
import inventory_pb2_grpc
class InventoryClient:
"""Wrapper around the gRPC stub. One channel per process, reused."""
def __init__(self, target="localhost:50051", timeout_s=2.0):
# The channel is lazy and persistent: one multiplexed HTTP/2 connection
self._channel = grpc.insecure_channel(target)
self._stub = inventory_pb2_grpc.InventoryStub(self._channel)
self._timeout = timeout_s
def get_stock(self, product):
response = self._stub.GetStock(
inventory_pb2.GetStockRequest(product=product),
timeout=self._timeout) # becomes an absolute deadline
return response.units
def reserve_stock(self, product, quantity, order_id):
reservation_id = str(uuid.uuid4()) # generated BEFORE the call (02-05)
request = inventory_pb2.ReserveStockRequest(
product=product, quantity=quantity,
order_id=order_id, reservation_id=reservation_id)
response = self._stub.ReserveStock(
request, timeout=self._timeout,
metadata=(("x-order-id", order_id),)) # metadata: outside the contract
return response.remaining
def reserve_for_order(client, product, quantity, order_id):
"""Translates each status code into a business decision (table in section 6)."""
try:
remaining = client.reserve_stock(product, quantity, order_id)
return True, f"reserved {quantity} of {product}, {remaining} left"
except grpc.RpcError as e:
code, details = e.code(), e.details()
if code == grpc.StatusCode.FAILED_PRECONDITION:
return False, f"out of stock: {details}"
if code in (grpc.StatusCode.NOT_FOUND, grpc.StatusCode.INVALID_ARGUMENT):
return False, f"request rejected: {details}"
if code == grpc.StatusCode.UNAVAILABLE:
return False, "inventory unavailable; order on hold (not executed)"
if code == grpc.StatusCode.DEADLINE_EXCEEDED:
return False, "inventory did not respond in time; status unknown (02-05)"
return False, f"unexpected error {code.name}: {details}"
if __name__ == "__main__":
client = InventoryClient()
print("initial stock:", client.get_stock("aged-cheese"))
for name, product, quantity, order in [("Anna", "aged-cheese", 2, "P-2026-000123"),
("Mark", "aged-cheese", 4, "P-2026-000124"),
("Lucy", "crianza-wine", 6, "P-2026-000125")]:
ok, msg = reserve_for_order(client, product, quantity, order)
print(f"{name}: {'OK' if ok else 'NO'} - {msg}")Output:
initial stock: 5 Anna: OK - reserved 2 of aged-cheese, 3 left Mark: NO - out of stock: only 3 units of aged-cheese left Lucy: OK - reserved 6 of crianza-wine, 194 left
And with inventory stopped: Anna: NO - inventory unavailable; order on hold (not executed), within milliseconds, thanks to gRPC distinguishing UNAVAILABLE (connection refused) from DEADLINE_EXCEEDED (silence). It is the table of four error families from 02-02, now standardised. The reservation_id travels in every request but the server does not yet use it to filter out duplicates: that is exactly what 02-05 will add.
- Server streaming: watching stock changes
A client that wants to show "only a few left" (it will be catalog) should not have to ask for the stock every time a product is displayed. With WatchChanges it opens a stream and receives every change:
# km0/services/catalog/stock_watcher.py
import grpc
import inventory_pb2
import inventory_pb2_grpc
channel = grpc.insecure_channel("localhost:50051")
stub = inventory_pb2_grpc.InventoryStub(channel)
request = inventory_pb2.WatchChangesRequest(products=["aged-cheese", "fresh-cheese"])
# long timeout: the stream lives until it expires, the client cancels it or the server closes
stream = stub.WatchChanges(request, timeout=3600)
try:
for change in stream: # blocks until each message arrives
warning = " <- ONLY A FEW LEFT" if change.after < 5 else ""
print(f"[catalog] {change.product}: {change.before} -> {change.after} "
f"({change.reason}){warning}")
except grpc.RpcError as e:
print("stream ended:", e.code().name)Start the watcher and then run the orders client: you will see aged-cheese: 5 -> 3 (reservation) <- ONLY A FEW LEFT. Everything travels over one HTTP/2 stream that stays open; each yield on the server is a frame on that stream. Two warnings: this stream is a point-to-point connection, so if the watcher restarts, whatever happened in the meantime is lost, and if ten services are interested in the changes, inventory maintains ten streams. For broadcasting events to many consumers with persistence, the right tool is the messaging of the next lesson; gRPC streaming shines in one-to-one flows, such as the telemetry of one particular courier.
- Measuring: JSON versus protobuf for the same order
Nothing is more convincing than numbers. We generate the code for order.proto (python -m grpc_tools.protoc -I ../../contracts --python_out=. ../../contracts/order.proto) and compare:
# km0/services/orders/measure_size.py
import json
import time
import order_pb2
order_dict = {
"id": "P-2026-000123", "customer": "anna", "timestamp_ms": 1789000000000, "market": "girona",
"lines": [
{"product": "aged-cheese", "quantity": 2, "price_cents": 1450},
{"product": "pink-tomato", "quantity": 3, "price_cents": 320},
{"product": "crianza-wine", "quantity": 6, "price_cents": 990},
],
}
order_pb = order_pb2.Order(
id=order_dict["id"], customer=order_dict["customer"],
timestamp_ms=order_dict["timestamp_ms"], market=order_dict["market"],
lines=[order_pb2.OrderLine(**l) for l in order_dict["lines"]])
json_bytes = json.dumps(order_dict, separators=(",", ":")).encode("utf-8")
pb_bytes = order_pb.SerializeToString()
print(f"Compact JSON : {len(json_bytes):4d} bytes")
print(f"Protobuf : {len(pb_bytes):4d} bytes ({100 * len(pb_bytes) / len(json_bytes):.0f}%)")
N = 100_000
t0 = time.perf_counter()
for _ in range(N):
json.loads(json.dumps(order_dict, separators=(",", ":")))
t_json = time.perf_counter() - t0
t0 = time.perf_counter()
for _ in range(N):
order_pb2.Order.FromString(order_pb.SerializeToString())
t_pb = time.perf_counter() - t0
print(f"JSON: {N / t_json:,.0f} cycles/s protobuf: {N / t_pb:,.0f} cycles/s")Indicative output (the timings depend on the machine and on whether protobuf is using its C implementation):
Compact JSON : 270 bytes Protobuf : 97 bytes (36%) JSON: 210,000 cycles/s protobuf: 650,000 cycles/s
A three-line order weighs less than half as much and is processed about three times faster. At 1,200 orders per second (lesson 01-03) and dozens of internal calls per order, the difference translates into bandwidth, CPU and latency. And what this script does not measure is just as important: the .proto validated the types when the message was built (price_cents="14.50" would have failed immediately), whereas the dictionary accepts anything.
Adding inventory to docker-compose.yml
With this, inventory is the first real service in km0/services/. Its Dockerfile compiles the contract when the image is built, and docker-compose.yml gains a service:
inventory:
build:
context: . # needs access to contracts/ and services/inventory/
dockerfile: services/inventory/Dockerfile
ports:
- "50051:50051"# km0/services/inventory/Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY services/inventory/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY contracts/ /contracts/
COPY services/inventory/ .
RUN python -m grpc_tools.protoc -I /contracts --python_out=. --grpc_python_out=. /contracts/inventory.proto
CMD ["python", "server.py"]The monolith, for now, keeps using its own internal inventory module; the strangler fig approach (01-06) consists of gradually redirecting its calls to this service. There is nothing technically special about that change (it means replacing a local call with InventoryClient) and we will not go into it; what is very special is that orders and inventory now have separate data, and that is the problem of Module 3.
Common Mistakes and Tips
- Reusing a field number. The most serious mistake with protobuf, because it produces no error at all: it produces corrupt data that is read with complete confidence. Always use
reservedwhen removing a field. - Changing the type of a field "because it's equivalent".
int32→stringbreaks;int32→int64works on the wire, but a large value written by the new side will be truncated on the old one. When in doubt, a new field with a new number. - Money in
float/double. Cents in anint64, or a decimal string if arbitrary precision is needed. We will keep repeating it right up to the final project. - Trusting that "field absent" and "default value" can be told apart. In proto3 they cannot, except with
optional. Ifquantity = 0can be legitimate, useoptionalor validate on the server. - One channel per call.
grpc.insecure_channelopens an HTTP/2 connection with its handshake; create one per process (or a handful) and reuse it. One channel per call is HTTP/1.1 without keep-alive, with extra steps. - No deadline. The
timeoutparameter is optional in the API and mandatory in practice. Without it, the call waits for ever, with everything we learnt in 01-04. - Ignoring the status code and looking only at the text.
e.details()is for people;e.code()is for programs. The text may change between server versions; the code does not. - Editing the generated
_pb2.pyfiles. They are overwritten the next time the code is generated. Every change goes in the.proto. - Tip: validate the compatibility of your
.protofiles in continuous integration with a tool such asbuf breaking: it turns the rules of section 5 into an automatic check, and no incompatible change reaches production by oversight. - Tip: when you design a message, think about version 3 before publishing version 1: keep the low numbers (1-15, which take a single tag byte) for the most frequent fields, use neutral names, and do not put into a message anything that belongs to another service.
Exercises
Exercise 1: Evolving ReserveStockRequest
The orders team needs a reservation to be able to be temporary (it expires after 15 minutes if it is not paid for, so as to release the last cheese if Anna abandons her basket) and also wants to remove order_id from the request, because from now on it will travel as metadata. Write the new version of the message respecting the compatibility rules, and explain what an old inventory server will see when it receives the new request, and what a new inventory will see when it receives an old request.
Exercise 2: Propagating the deadline
orders receives a request from Anna's app with an overall limit of 3 seconds, and to serve it, it first calls inventory (to reserve) and then payments (to charge). Explain why using timeout=3.0 in both gRPC calls is wrong, and write a function time_remaining(absolute_deadline) that computes the timeout to pass to each call. What should orders do if there are 50 ms left when it is about to call payments?
Exercise 3: Choosing the kind of call and the format
For each interaction, give the kind of gRPC call (unary, server streaming, client streaming or bidirectional) or say whether something else is a better fit (REST, messaging), and give the serialization format:
- Lucy's app downloads a product page with its photo.
ordersasksinventoryfor the stock of the 8 products in a basket.van-3sends 1 position per second todeliveryduring a 2-hour route, and at the end receives a summary of the kilometres driven.analyticsneeds all the orders from "Grape Harvest Week" for an overnight report.- A customer support operator and a courier's app exchange short messages in real time about a delivery incident.
Solutions
Solution 1:
message ReserveStockRequest {
reserved 3;
reserved "order_id";
string product = 1;
int32 quantity = 2;
string reservation_id = 4;
int32 expires_in_seconds = 5; // 0 (default value) = permanent reservation
}order_id is removed by reserving its number (3) and its name; the new field takes number 5, never 3. We choose for the default value (0) to mean "previous behaviour", so that a request that does not send it behaves as before. An old inventory that receives the new request will see an empty order_id (the default string: its traceability log will print a blank order :, which is tolerable) and will ignore field 5, which it does not know: it will make a permanent reservation, which is degraded behaviour but not an error; the team must be aware that expiry does not work until inventory is deployed. A new inventory that receives an old request will see expires_in_seconds = 0 and make a permanent reservation, exactly what that client expected. No deployment needs to be coordinated, although the full functionality only exists once both are up to date.
Solution 2:
With timeout=3.0 on each call, the worst case is that inventory takes 2.9 s (within its limit) and payments another 2.9 s: orders would answer Anna in 5.8 s, almost twice what was promised, and the app would already have given up. The deadline must be a single, absolute one, computed when the request is received, and each call gets whatever is left:
import time
def time_remaining(absolute_deadline):
"""Seconds left until the deadline; never negative."""
return max(0.0, absolute_deadline - time.monotonic())
deadline = time.monotonic() + 3.0 # on receiving Anna's request
inventory_stub.ReserveStock(req, timeout=time_remaining(deadline))
remaining = time_remaining(deadline)
if remaining < 0.2: # threshold: less than payments normally takes
# Not worth calling: it will fail on the deadline and leave the charge in an unknown state.
# Better to abort cleanly: release the reservation (or let it expire) and answer
# "we could not process your order, please try again" or leave it as "payment pending".
...
else:
payments_stub.Charge(payment_req, timeout=remaining)With 50 ms remaining, the right thing to do is not to call payments: a call that is going to expire leaves the most dangerous operation (a charge) in the "nobody knows" state. Aborting beforehand is the version of "fail fast" that gRPC makes easy by making the remaining time explicit; gRPC servers can also check context.time_remaining() so as not to start work they will not be able to finish. Note the use of time.monotonic() (not time.time()): the deadline is a duration, not a wall-clock instant, and wall clocks jump (lesson 01-05).
Solution 3:
- REST with JSON for the metadata and the photo as a separate HTTP resource (cacheable by a CDN). It is an external client, browser or app, and the content benefits from HTTP caching. gRPC in the browser requires gRPC-Web and brings no caching.
- Unary, with a message carrying
repeated string products: one call, one response with the 8 unit counts (coarse-grained, 02-02). Protobuf. - Client streaming: N positions → 1 summary. Protobuf; each position is ~25 bytes. Beware: if the mobile network is poor, an HTTP/2 stream over TCP suffers from head-of-line blocking (02-01), and MQTT (08-02) may be a better option for the mobile leg; gRPC streaming fits between the gateway and
delivery. - No synchronous call at all: it is a large volume and a batch process.
analyticsshould consume the order events (02-04) or read from the analytical store (Module 5), not askordersfor tens of thousands of records in one call (although technically server streaming would allow it, it would couple the analytical load to the transactional service: symptom 5 from 01-06). Format: Avro or Parquet for bulk data. - Bidirectional: messages in both directions, in any order, in real time. Protobuf. If one of the ends is a browser, the practical alternative is WebSockets (08-02), which is conceptually the same thing with a transport that browsers support natively.
Conclusion
This lesson has closed the circle opened in 02-02. Serialization decides how structures are turned into bytes, and that decision determines size, speed, type safety and the ability to evolve: text formats (JSON, XML) are readable and universal but heavy and schemaless; binary formats with a schema (Protocol Buffers, Avro) weigh less than half as much, are processed several times faster and, above all, have evolution rules (unique field numbers that are never reused, reserved when removing, new fields with tolerable default values) that make it possible to deploy orders and inventory independently. With contract-first, the .proto is the source of truth from which the code is generated. gRPC builds those schemas on top of HTTP/2 to offer a multiplexed channel, four kinds of call (unary and three forms of streaming), absolute deadlines that propagate, standard status codes and metadata. Kilometre Zero now has its first genuinely extracted service: inventory in services/inventory/server.py, with its contract in contracts/inventory.proto, a client in orders that translates each status code into a business decision, and a stream of stock changes. The measurement has confirmed the saving: a three-line order goes from 270 bytes in JSON to 97 in protobuf.
But everything done in this module so far is synchronous: orders calls, waits and receives. By the end of 01-06 it was clear that this is the right thing for reserving stock and the wrong thing for almost everything else: notifying analytics of a sale, planning the delivery or updating the catalogue indicator should not block the customer or depend on the recipient being alive at that very moment. For that, someone needs to store the message and deliver it when the recipient is able to take it: a broker. The next lesson, Messaging and Message Queues, introduces RabbitMQ and Apache Kafka, and with them Kilometre Zero's first asynchronous flow: the order.created event.
Distributed Architectures Course
Module 1: Introduction to Distributed Systems
- Basic Concepts of Distributed Systems
- Distributed System Models
- Advantages and Challenges of Distributed Systems
- The Fallacies of Distributed Computing
- Time, Clocks and Event Ordering
- From Monolith to Distributed Platform: the Kilometre Zero Case
Module 2: Communication in Distributed Systems
- Communication Protocols
- RPC and RMI
- gRPC and Data Serialization
- Messaging and Message Queues
- Asynchronous Communication Patterns
Module 3: Consistency and Replication
- Consistency Models
- The CAP Theorem and PACELC
- Consensus Algorithms
- Data Replication
- Distributed Transactions and Sagas
Module 4: Distributed Storage
- Data Partitioning and Consistent Hashing
- Distributed File Systems
- Object Storage
- Distributed Databases
- Distributed Caches
Module 5: Distributed Computing
- Distributed Computing Models
- MapReduce and Hadoop
- Spark and In-Memory Computing
- Stream Processing
- Job Scheduling and Data Pipelines
Module 6: Security in Distributed Systems
- Authentication and Authorization
- Encryption and Data Protection
- Identity Management
- Service-to-Service Security: mTLS and Secrets Management
- API Gateways, Rate Limiting and Auditing
Module 7: Monitoring and Maintenance
- Monitoring Distributed Systems
- Centralized Logs and Distributed Tracing
- Failure Management and Recovery
- Resilience Patterns: Timeouts, Retries and Circuit Breakers
- Automation and Orchestration
- Testing Distributed Systems and Chaos Engineering
