Sina Vafadar

Encoding and Evolution

Aug 11, 2026 · 24 min

Designing Data-Intensive Applications · Chapter 4 · Recall lab

Chapter 3 was about how one process stores data. Chapter 4 is about what happens when two versions of your code exist at the same time — because in a large application, code changes cannot happen instantaneously. That single fact generates the whole chapter: two directions of compatibility, a handful of encoding formats judged by how well they hold those directions, and three modes of dataflow in which the question keeps recurring.

The premise § Chapter opening

Old and new may all coexist at the same time

Applications inevitably change: features are added or modified as new products launch, requirements become better understood, or business circumstances change. Chapter 1 called the goal evolvability. And in most cases a change to an application’s features also requires a change to the data it stores — a new field or record type to capture, or existing data to present in a new way.

The data models of chapter 2 cope with that differently. Relational databases generally assume all data conforms to one schema: it can be changed through migrations, ALTER statements, but there is exactly one schema in force at any point in time. Schema-on-read databases don’t enforce one, so the database can contain a mixture of older and newer data formats written at different times.

When the format changes, application code usually has to change too. But code changes can’t happen instantaneously either.

Server-side

You may want a rolling upgrade, also known as a staged rollout: deploy the new version to a few nodes at a time, check it’s running smoothly, and gradually work through all the nodes. That allows deployment without service downtime, which encourages more frequent releases and better evolvability.

Client-side

You’re at the mercy of the user, who may not install the update for some time. There is no equivalent of the deploy button, and no upper bound on how long the old version stays in service.

A rolling upgrade, and the window it opens

one service, 6 nodes, upgraded a few at a time
v1 v2 v1 v2 v1 v2 v1 v2 v1 v2 v1 v2
the coexistence window
new code writes record old code reads forward compatibility
old code writes record new code reads backward compatibility
Neither lane is hypothetical while the wave is crossing. Both are running, over the same data, in the same cluster.

Backward compatibility

Newer code can read data that was written by older code. Normally not hard to achieve: as author of the newer code you know the format written by the older code, so you can explicitly handle it — if necessary by simply keeping the old code around to read the old data.

Forward compatibility

Older code can read data that was written by newer code. Trickier, because it requires older code to ignore additions made by a newer version of the code. The old code has no way of knowing what’s coming.

Both definitions name an old thing and a new thing, which is exactly why they are so easy to get the wrong way round. Choosing the two ends is more reliable than parsing the sentence.

Which one do you need? Pick a writer and a reader

read by old code read by new code written by old code written by new code
Backward compatibility
old code a record new code
Newer code can read data that was written by older code. This is normally not hard to achieve: as author of the newer code you know the format written by the older code, so you can explicitly handle it — if necessary by simply keeping the old code around to read the old data.

In a database it is unavoidable even with a single process, because the reader is just a later version of the same process: storing something is sending a message to your future self. For RPC this is the request direction, given the assumption that all servers are updated before any client.

Half one § Formats for Encoding Data

Two representations, and the translation between them

Programs usually work with data in at least two different representations. In memory it is objects, structs, lists, arrays, hash tables and trees — structures optimized for efficient access and manipulation by the CPU, typically using pointers. On disk or on the wire it has to be a self-contained sequence of bytes, and since a pointer wouldn’t make sense to any other process, that representation looks quite different.

Encoding, and what the pointer does not survive

process a · in memory
userName"Martin" favoriteNumber1337 interests0x7ffd…
elsewhere on the heap "daydreaming""hacking"
a pointer means nothing to any other process
encode
decode a self-contained sequence of bytes
process b · in memory
userName"Martin" favoriteNumber1337 interests["daydreaming", "hacking"]
rebuilt from the bytes, at a different address
Encoding is also called serialization or marshalling; decoding is parsing, deserialization, unmarshalling. Neither has anything to do with encryption.

Encoding is the translation from the in-memory representation to a byte sequence — also known as serialization or marshalling. Decoding is the reverse: parsing, deserialization, unmarshalling.

Terminology clash. Serialization is also used in the context of transactions, with a completely different meaning, so the book sticks with encoding even though serialization is perhaps the more common term. And a separate warning worth keeping: encoding has nothing to do with encryption. The in-memory/byte-sequence split has exceptions too, such as certain memory-mapped files, or operating directly on compressed data as in column compression.

Language-specific formats, convenient and a bad idea

Many languages ship built-in support for encoding in-memory objects: Java has java.io.Serializable, Ruby has Marshal, Python has pickle. Third-party libraries exist too, such as Kryo for Java. They’re very convenient — in-memory objects saved and restored with minimal extra code — and they have a number of deep problems.

Language lock-inThe encoding is often tied to a particular programming language, and reading the data in another language is very difficult. Store or transmit data in such an encoding and you are committing yourself to your current language for potentially a very long time, precluding integrating your systems with those of other organizations, which may use different languages.

Conclusion: it’s generally a bad idea to use your language’s built-in encoding for anything other than very transient purposes.

The lowest common denominator § JSON, XML, and Binary Variants

Widely known, widely supported, almost as widely disliked

Moving to standardized encodings readable by many languages, JSON and XML are the obvious contenders. XML is often criticized for being too verbose and unnecessarily complicated. JSON’s popularity is mainly due to built-in support in web browsers, by virtue of being a subset of JavaScript, and simplicity relative to XML. CSV is another popular language-independent format, albeit less powerful.

All three are textual and thus somewhat human-readable, though the syntax is a popular topic of debate. Beyond the superficial syntactic issues, they have subtle problems.

Number ambiguityIn XML and CSV you cannot distinguish a number from a string that happens to consist of digits, except by referring to an external schema. JSON does distinguish strings and numbers, but it does not distinguish integers from floating-point numbers, and it specifies no precision.

That bites with large numbers. Integers greater than 253 cannot be exactly represented in an IEEE 754 double, so they become inaccurate when parsed in a language that uses floating point — such as JavaScript. Twitter uses a 64-bit number to identify each tweet, and the JSON returned by Twitter’s API includes tweet IDs twice, once as a JSON number and once as a decimal string, to work around the fact that the numbers are not correctly parsed by JavaScript applications.

The 2⁵³ problem, performed rather than described

Integers greater than 253 cannot be exactly represented in an IEEE 754 double-precision floating-point number, so they become inaccurate when parsed in a language that uses floating-point numbers — such as JavaScript. This page is running JavaScript, so the demonstration below is not an illustration: the digits really do go through JSON.parse, and what comes back is what came back.

as a JSON string parsed as a JSON number equal?
"9007199254740991"9007199254740991yes
Survived16 digits, and this value is inside the exactly-representable range, so the round trip through JSON.parse is lossless. Push it one higher.

Despite these flaws, JSON, XML and CSV are good enough for many purposes, and will likely remain popular, especially as data interchange formats — sending data from one organization to another. In those situations, as long as people agree on what the format is, it often doesn’t matter how pretty or efficient it is: the difficulty of getting different organizations to agree on anything outweighs most other concerns.

Binary encodings of JSON, and why they barely help

For data used only internally there’s less pressure to use a lowest-common-denominator format, so you could pick something more compact or faster to parse. For a small dataset the gains are negligible, but once you get into the terabytes, the choice of data format can have a big impact. That observation led to a profusion of binary encodings for JSON — MessagePack, BSON, BJSON, UBJSON, BISON, Smile — and for XML, such as WBXML and Fast Infoset. None is as widely adopted as the textual versions.

Some extend the set of datatypes, for instance distinguishing integers from floats or adding binary strings, but they otherwise keep the JSON data model unchanged. Crucially, since they don’t prescribe a schema, they need to include all the object field names within the encoded data.

The exhibit the chapter turns on § one record, six encodings

Where the bytes actually go

The rest of the first half is a table of six byte counts, and a table is the one form in which those numbers have to be taken on trust. So the six encodings below are real: one small record, six encoders, every byte on screen, and every total summed from the bytes above it. Click a group to find out what it is for, and switch encoding to watch the same record lose its field names.

The record every encoding below is holding

{
  "userName": "Martin",
  "favoriteNumber": 1337,
  "interests": ["daydreaming", "hacking"]
}

field names the values framing

JSON, textual (whitespace removed) · 81 bytes · self-describing, no schema

The object opensOne byte of punctuation. Textual formats pay for structure in characters, and every one of them is a byte on the wire.

Three colours, and they are the whole argument of this half of the chapter. Amber is what the encoding spends saying which field this is — the name spelled out, or the tag that stands in for it. Accent is the values, the payload you actually wanted. Grey is framing: type markers, lengths, delimiters, union branches, stop bytes.

Read the scoreboard by those bands rather than by the totals, and the chapter’s central claim stops being an assertion. Every encoding here carries the same record, and four of the six spend exactly 26 bytes on its values.

The same record, six ways, split by what the bytes are spent on

JSON · 81 bytesThe baseline. Human-readable, universally supported, and every field name spelled out in full: 31 of the 81 bytes are field names and another 22 are quotes, colons, commas and brackets. Only 28 bytes are the values. JSON is less verbose than XML, but both use a lot of space compared with the binary formats.

The one comparison worth memorising

MessagePack spends 31 bytes on field names. Textual JSON spends 31. They are the same bytes, and that is the entire reason a binary encoding of JSON saves only 15 bytes on 81: it changed the framing and left the names alone, because a format that doesn’t prescribe a schema has no way to refer to a field except by naming it. It’s not clear whether such a small space reduction — and perhaps a speedup in parsing — is worth the loss of human-readability.

Avro spends 0. Everything between here and the end of the first half is the story of how that number reaches zero.

Schema-driven, part one § Thrift and Protocol Buffers

Replace field names with field tags

Apache Thrift and Protocol Buffers are binary encoding libraries based on the same principle. Protocol Buffers was originally developed at Google, Thrift at Facebook, and both were made open source in 2007–08. Both require a schema for any data that is encoded, and both come with a code generation tool that turns a schema definition into classes in various programming languages, which your application calls to encode or decode records.

The same record declared three ways

struct Person {
  1: required string       userName,
  2: optional i64          favoriteNumber,
  3: optional list<string> interests
}
Field tags, and two markers that change nothingThe numbers 1, 2, 3 are what goes on the wire in place of the names. required and optional make no difference at all to how a field is encoded — nothing in the binary data indicates whether a field was required. The difference is that required enables a runtime check that fails if the field is not set, which is useful for catching bugs.

Confusingly, Thrift has two different binary encoding formats — BinaryProtocol and CompactProtocol. Actually three: DenseProtocol exists but is only supported by the C++ implementation, so it doesn’t count as cross-language. Plus two different JSON-based encoding formats. What fun. Protocol Buffers has only one binary encoding format, and it does the bit packing slightly differently from Thrift’s CompactProtocol but is otherwise very similar.

Both savings between BinaryProtocol’s 59 bytes and CompactProtocol’s 34 are visible in the strips above: the field header becomes one byte carrying tag and type together, and every integer becomes variable-length. The second is worth a dial of its own, because the chapter states its boundaries as a table and a table makes them look arbitrary.

A variable-length integer, and where its boundaries come from

111100100xf2 · more follows000101000x14 · last
1,337 · 2 bytesZigZag first, so that small negative numbers stay small: 1,337 becomes 2,674. Then seven bits of it per byte, with the top bit of each byte indicating whether there are still more bytes to come — the flag is marked above, and it is the only reason a reader knows where the number ends.

The last value that fits in 2 bytes is 8,191; one more and it takes 3. A fixed 64-bit integer would spend 8 bytes on this value whatever it is, which is what Thrift's BinaryProtocol does — the varint spends 2, a saving of 6.

value rangebytes used
−64 to 631
−8192 to 81912
bigger numbersmore

One detail that surprises people

Each field in those schemas was marked required or optional, but this makes no difference to how the field is encoded — nothing in the binary data indicates whether a field was required. The difference is simply that required enables a runtime check that fails if the field is not set, which can be useful for catching bugs.

Field tags and schema evolution

An encoded record is just the concatenation of its encoded fields. Each field is identified by its tag number and annotated with a datatype, and if a field value is not set it is simply omitted from the encoded record. From which it follows that field tags are critical to the meaning of the encoded data — and that a reader meeting a tag it does not recognise has everything it needs to carry on anyway.

An old reader taking a record a newer version wrote

0a#1 len064d617274696eMartin10#2 varintb90a13371a#3 len0b646179647265616d696e67daydreaming1a#3 len076861636b696e67hacking22#4 len0c2f702f372f3030302e6a7067? 12 bytes
47 bytes, written by the new codeA newer version of the schema added a fourth field, photoUrl, with tag 4. The record is now 47 bytes rather than 33. The reader about to walk it has never heard of that field.
Rename a field · safeYou can change the name of a field in the schema, since the encoded data never refers to field names — only to tag numbers. Both directions unaffected.

A last asymmetry between the two libraries. Protocol Buffers has no list or array datatype — instead it has a repeated marker, a third option alongside required and optional, and the encoding of a repeated field is exactly what it says: the same field tag simply appears multiple times in the record, which you can see twice over in its strip above. Thrift has a dedicated list datatype, parameterized with the type of the elements. That doesn’t allow the single-valued-to-multi-valued evolution Protocol Buffers permits, but it does have the advantage of supporting nested lists.

Schema-driven, part two § Avro

No tag numbers, no field names, just values

Apache Avro is another binary encoding format, interestingly different from Protocol Buffers and Thrift. It was started in 2009 as a subproject of Hadoop, as a result of Thrift not being a good fit for Hadoop’s use cases. It also uses a schema, and has two schema languages: Avro IDL, intended for human editing, and one based on JSON that is more easily machine-readable.

Avro IDL, intended for human editing

record Person {
    string               userName;
    union { null, long } favoriteNumber = null;
    array<string>        interests;
}

The equivalent JSON representation

{
  "type": "record",
  "name": "Person",
  "fields": [
    {"name": "userName",
     "type": "string"},
    {"name": "favoriteNumber",
     "type": ["null", "long"],
     "default": null},
    {"name": "interests",
     "type": {"type": "array",
              "items": "string"}}
  ]
}

First of all, notice there are no tag numbers. Encoding the record with this schema gives just 32 bytes — the most compact of all the encodings in the chapter. Examine that byte sequence in the inspector above and you find nothing to identify fields or their datatypes. The encoding simply consists of values concatenated together. A string is just a length prefix followed by UTF-8 bytes, but nothing in the encoded data tells you it is a string; it could just as well be an integer, or something else entirely.

To parse it you go through the fields in the order they appear in the schema and use the schema to tell you each field’s datatype. Which means the binary data can only be decoded correctly if the code reading it is using the exact same schema as the code that wrote it. Any mismatch would mean incorrectly decoded data.

So how does Avro support schema evolution at all?

The writer’s schema and the reader’s schema

When an application encodes data it uses whatever version of the schema it knows about, perhaps compiled into the application. That is the writer’s schema. When an application decodes data it expects some schema, which is the reader’s schema — the one the application code relies on, possibly the one its code was generated from at build time.

The key idea: the writer’s schema and the reader’s schema don’t have to be the same. They only need to be compatible. When data is decoded, the Avro library resolves the differences by looking at the two schemas side by side and translating the data from the writer’s schema into the reader’s schema. The Avro specification defines exactly how this resolution works.

Three mismatches, and what resolution does with each

writer’s schemareader’s schemauserNamefavoriteNumberinterestsinterestsuserNamefavoriteNumber
No problem at allIt is no problem if the writer’s schema and the reader’s schema have their fields in a different order, because the resolution matches up the fields by field name. The reader walks its own schema and pulls each field from wherever the writer put it. Unrecognised field names are the thing it cannot cope with — not unfamiliar positions.

Schema evolution rules

Forward compatibility here means

A new version of the schema as writer, and an old version as reader.

Backward compatibility here means

A new version of the schema as reader, and an old version as writer.

The rule itselfTo maintain compatibility, you may only add or remove a field that has a default value. In the schema above, favoriteNumber has a default of null.

Add a field with a default, so it exists in the new schema but not the old. When a reader using the new schema reads a record written with the old schema, the default value is filled in for the missing field.

Null is not a default for free

In some languages null is an acceptable default for any variable. Not in Avro. To allow a field to be null you have to use a union type — for example union { null, long, string } field; means the field can be a number, a string, or null. You can only use null as a default value if it is one of the branches of the union. Strictly, the default value must be of the type of the first branch of the union, which is a specific limitation of Avro rather than a general feature of union types.

That’s a little more verbose than making everything nullable by default, but it helps prevent bugs by being explicit about what can and cannot be null. Consequently Avro doesn’t have optional and required markers the way Protocol Buffers and Thrift do.

But what is the writer’s schema?

How does the reader know which schema a particular piece of data was encoded with? You can’t include the entire schema with every record — the schema would likely be much bigger than the encoded data, making all the space savings from the binary encoding futile. Look at the Avro strip above: the record is 32 bytes and the JSON form of its schema is several hundred. The answer depends on the context.

A large file with lots of recordsA common use for Avro, especially with Hadoop: a file containing millions of records, all encoded with the same schema. The writer includes the writer’s schema once at the beginning of the file. Avro specifies a file format — object container files — that does exactly this, which is also what makes such a file self-describing.

A database of schema versions is a useful thing to have in any case, since it acts as documentation and gives you a chance to check schema compatibility. As the version number you could use a simple incrementing integer, or a hash of the schema.

Dynamically generated schemas

Why does it matter that Avro’s schema has no tag numbers? Because Avro is friendlier to dynamically generated schemas. Say you want to dump a relational database’s contents to a file in a binary format, avoiding the problems with textual formats. With Avro you can fairly easily generate a schema from the relational schema — a record schema per table, each column becoming a field, the column name mapping to the field name — and dump it all into an Avro object container file.

With Avro

The database schema changes: a column added, one removed. Just generate a new Avro schema from the updated database schema and export in it. The export process doesn’t need to pay any attention to the schema change; it simply does the conversion every time it runs. Readers see that the record’s fields have changed, but since fields are identified by name, the updated writer’s schema still matches up with the old reader’s schema.

With Thrift or Protocol Buffers

The field tags would likely have to be assigned by hand: every time the database schema changes, an administrator has to manually update the mapping from database column names to field tags. It might be possible to automate, but the generator would have to be very careful not to assign previously used field tags. This kind of dynamically generated schema simply wasn’t a design goal of Thrift or Protocol Buffers, whereas it was for Avro.

Code generation and dynamically typed languages

Thrift and Protocol Buffers rely on code generation. That’s useful in statically typed languages such as Java, C++ or C#, because it allows efficient in-memory structures for decoded data, plus type checking and autocompletion in IDEs. In dynamically typed languages such as JavaScript, Ruby or Python there’s not much point, since there is no compile-time type checker to satisfy — and code generation is often frowned upon in those languages, which otherwise avoid an explicit compilation step. Worse, with a dynamically generated schema, code generation is an unnecessary obstacle to getting to the data.

Avro provides optional code generation for statically typed languages, but works just as well without it. Given an object container file, which embeds the writer’s schema, you can open it with the Avro library and look at the data much as you would a JSON file: the file is self-describing because it includes all the necessary metadata. That’s especially useful with dynamically typed data processing languages like Apache Pig, where you can just open some Avro files, start analyzing, and write derived datasets back out in Avro without even thinking about schemas.

The verdict on half one § The Merits of Schemas

Four properties worth the loss of human-readability

Protocol Buffers, Thrift and Avro all use a schema to describe a binary encoding format, and their schema languages are much simpler than XML Schema or JSON Schema, which support far more detailed validation rules — “the string value of this field must match this regular expression”, “the integer value must be between 0 and 100”. Being simpler to implement and to use, they have grown to support a fairly wide range of programming languages.

Compactness

Much more compact than the various binary JSON variants, since they can omit field names from the encoded data — the 31 bytes at the top of the scoreboard that never had to be there.

Living documentation

The schema is a valuable form of documentation, and because it’s required for decoding you can be sure it’s up to date — whereas manually maintained documentation easily diverges from reality.

Pre-deployment checks

Keeping a database of schemas lets you check forward and backward compatibility of a schema change before anything is deployed.

Compile-time type checking

For users of statically typed languages, generating code from the schema enables type checking at compile time.

None of this is new

These ideas have a lot in common with ASN.1, a schema definition language first standardized in 1984. It was used to define various network protocols, and its binary encoding DER is still used to encode SSL certificates (X.509). ASN.1 supports schema evolution using tag numbers, similar to Protocol Buffers and Thrift. However, it’s also very complex and badly documented, so it’s probably not a good choice for new applications.

Many data systems also implement proprietary binary encodings. Most relational databases have a network protocol for sending queries and receiving responses; those protocols are generally specific to a particular database, and the vendor provides a driver — using ODBC or JDBC — that decodes responses from the network protocol into in-memory data structures.

The summary sentence worth memorising: schema evolution allows the same kind of flexibility as schemaless, schema-on-read JSON databases provide, while also providing better guarantees about your data and better tooling.

Half two, mode one § Dataflow Through Databases

Storing something is sending a message to your future self

Compatibility is a relationship between one process that encodes the data and another process that decodes it — a fairly abstract idea, since there are many ways data can flow from one process to another. The chapter walks three: via databases, via service calls, via asynchronous message passing.

In a database, the writer encodes and the reader decodes. There may be just a single process, in which case the reader is simply a later version of the same process — storing something in the database is like sending a message to your future self. Backward compatibility is clearly necessary there, or your future self won’t be able to decode what you previously wrote.

But it’s common for several processes to access a database at the same time: different applications or services, or several instances of the same service running in parallel for scalability or fault tolerance. Either way, in a changing application it’s likely that some processes are running newer code and some older — for instance because a new version is mid-rolling-upgrade. So a value may be written by a newer version and subsequently read by an older version that is still running: forward compatibility is also often required for databases.

That much the byte walk above already showed. Here is the part it didn’t.

The additional snag

one record, in the database
fieldvalue
userNameMartin
favoriteNumber1337
BeforeA record written before the new field existed. Two fields, both understood by every version of the code.

Different values written at different times

A database generally allows any value to be updated at any time, so within a single database you may have values written five milliseconds ago and values written five years ago. When you deploy a new version of a server-side application you may replace the old version entirely within a few minutes. The same is not true of database contents: the five-year-old data will still be there, in the original encoding, unless you have explicitly rewritten it.

The observation is sometimes summed up as data outlives code.

Rewriting — migrating — data into a new schema is certainly possible, but it’s expensive on a large dataset, so most databases avoid it. Most relational databases allow simple schema changes such as adding a new column with a null default value without rewriting existing data; when an old row is read, the database fills in nulls for the columns missing from the encoded data on disk. MySQL is the exception, often rewriting an entire table even though it isn’t strictly necessary. LinkedIn’s document database Espresso uses Avro for storage, so it gets Avro’s schema evolution rules.

Which yields a nice way to state the payoff: schema evolution allows the entire database to appear as if it was encoded with a single schema, even though the underlying storage may contain records encoded with various historical versions of the schema.

Archival storage

Suppose you snapshot the database periodically, for backups or for loading into a data warehouse. The dump will typically be encoded using the latest schema, even if the original storage contained a mixture of schema versions from different eras — since you’re copying the data anyway, you might as well encode the copy consistently. Because the dump is written in one go and thereafter immutable, formats like Avro object container files are a good fit. It’s also a good opportunity to encode the data in an analytics-friendly column-oriented format such as Parquet, which is where chapter 3 ended.

Half two, mode two § Dataflow Through Services: REST and RPC

Clients, servers, and a bad abstraction

The most common arrangement for processes communicating over a network is two roles: clients and servers. Servers expose an API over the network and clients connect to make requests to it. The API exposed by the server is known as a service.

The web works this way: browsers make GET requests to download HTML, CSS, JavaScript and images, and POST requests to submit data. That API is a standardized set of protocols and data formats — HTTP, URLs, SSL/TLS, HTML — and because browsers, servers and website authors mostly agree on these standards, you can use any browser to access any website. At least in theory.

Browsers aren’t the only clients. A native app on a mobile device or desktop can make requests, and a client-side JavaScript application in a browser can use XMLHttpRequest to become an HTTP client, a technique known as Ajax. Then the server’s response is typically not HTML for a human but data in an encoding convenient for further processing by client-side code, such as JSON. HTTP may be the transport, but the API on top is application-specific, and client and server must agree on its details.

A server can itself be a client to another service — a typical web app server acts as client to a database. That’s how a large application gets decomposed into smaller services by area of functionality, an approach traditionally called service-oriented architecture (SOA), more recently refined and rebranded as microservices architecture.

How services resemble databases

They typically allow clients to submit and query data.

How they differ

Databases allow arbitrary queries using query languages. Services expose an application-specific API that only allows inputs and outputs predetermined by the business logic of the service. That restriction provides encapsulation: services can impose fine-grained restrictions on what clients can and cannot do.

And the design goal that makes this chapter relevant: SOA and microservices aim to make the application easier to change and maintain by making services independently deployable and evolvable. Each service should be owned by one team, and that team should be able to release new versions frequently without having to coordinate with other teams. In other words, we should expect old and new versions of servers and clients to be running at the same time, so the encoding used must be compatible across versions of the service API.

Web services, three contexts and two philosophies

When HTTP is the underlying protocol for talking to a service, it’s called a web service — a slight misnomer, since web services aren’t only used on the web.

Context 1

A client application on a user’s device — a native mobile app, or a JavaScript web app using Ajax — making requests over HTTP, typically over the public internet.

Context 2

One service requesting another owned by the same organization, often in the same datacenter, as part of a SOA or microservices architecture. Software supporting this is sometimes called middleware.

Context 3

One service requesting a service owned by a different organization, usually via the internet. Includes public APIs such as credit card processing, or OAuth for shared access to user data.

REST and SOAP, almost diametrically opposed in philosophy

RESTNot a protocol, but a design philosophy that builds upon the principles of HTTP. It emphasises simple data formats, using URLs for identifying resources and HTTP features for cache control, authentication and content type negotiation. An API designed according to its principles is called RESTful.

REST has been gaining popularity compared with SOAP, at least for cross-organizational service integration, and is often associated with microservices. RESTful APIs tend to favour simpler approaches, typically less code generation and automated tooling. A definition format such as OpenAPI, also known as Swagger, can describe a RESTful API and produce documentation.

The problems with remote procedure calls

Web services are merely the latest incarnation of a long line of technologies for making API requests over a network, many of which received a lot of hype but have serious problems. Enterprise JavaBeans (EJB) and Java’s Remote Method Invocation (RMI) are limited to Java. The Distributed Component Object Model (DCOM) is limited to Microsoft platforms. The Common Object Request Broker Architecture (CORBA) is excessively complex, and does not provide backward or forward compatibility.

All are based on the idea of a remote procedure call (RPC), around since the 1970s. The RPC model tries to make a request to a remote network service look the same as calling a function or method in your programming language, within the same process — an abstraction called location transparency. It seems convenient at first, but the approach is fundamentally flawed, because a network request is very different from a local function call.

PredictabilityA local function call is predictable and either succeeds or fails, depending only on parameters that are under your control. A network request is unpredictable: the request or the response may be lost to a network problem, or the remote machine may be slow or unavailable — problems entirely outside your control. Network problems are common, so you have to anticipate them, for example by retrying a failed request.

The conclusion: there’s no point trying to make a remote service look too much like a local object in your programming language, because it’s a fundamentally different thing. Part of the appeal of REST is that it doesn’t try to hide the fact that it’s a network protocol — although that doesn’t seem to stop people building RPC libraries on top of REST.

Current directions for RPC

Despite the problems, RPC isn’t going away. Frameworks have been built on top of all the encodings in this chapter: Thrift and Avro come with RPC support included, gRPC is an RPC implementation using Protocol Buffers, Finagle also uses Thrift, and Rest.li uses JSON over HTTP.

This new generation is more explicit about the fact that a remote request is different from a local function call. Finagle and Rest.li use futures, or promises, to encapsulate asynchronous actions that may fail — futures also simplify making requests to multiple services in parallel and combining their results. gRPC supports streams, where a call consists not of one request and one response but a series of both over time. Some frameworks also provide service discovery, letting a client find out at which IP address and port a particular service can be found.

What custom binary RPC buys you

Better performance than something generic like JSON over REST.

What a RESTful API buys you

It’s good for experimentation and debugging — you can make requests with a browser or curl, with no code generation or software installation; it’s supported by all mainstream languages and platforms; and there’s a vast ecosystem of tools: servers, caches, load balancers, proxies, firewalls, monitoring, debugging, testing.

Hence the split in practice: REST seems to be the predominant style for public APIs, while the main focus of RPC frameworks is on requests between services owned by the same organization, typically within the same datacenter.

Data encoding and evolution for RPC

The simplifying assumption

Compared with dataflow through databases, services allow one simplification: it is reasonable to assume that all the servers will be updated first, and all the clients second. Therefore you only need backward compatibility on requests — the newer server must read requests written by older clients — and forward compatibility on responses — the older client must read responses written by the newer server.

The compatibility properties of an RPC scheme are inherited from whatever encoding it uses: Thrift, gRPC over Protocol Buffers, and Avro RPC all evolve according to the rules of their respective formats. In SOAP, requests and responses are specified with XML schemas, which can be evolved, but there are some subtle pitfalls. RESTful APIs most commonly use JSON without a formally specified schema for responses, and JSON or URI-encoded or form-encoded parameters for requests; adding optional request parameters and adding new fields to response objects are usually considered changes that maintain compatibility.

Service compatibility is made harder by the fact that RPC is often used across organizational boundaries: the provider of a service often has no control over its clients and cannot force them to upgrade. So compatibility must be maintained for a long time, perhaps indefinitely. If a compatibility-breaking change is required, the provider often ends up maintaining multiple versions of the service API side by side.

And there is no agreement on how API versioning should work — how a client indicates which version it wants. For RESTful APIs, common approaches are a version number in the URL or in the HTTP Accept header. For services using API keys to identify a client, another option is to store the client’s requested API version on the server and allow that selection to be updated through a separate administrative interface.

Half two, mode three § Message-Passing Dataflow

Somewhere between RPC and databases

Asynchronous message-passing systems are similar to RPC in that a client’s request — usually called a message — is delivered to another process with low latency. They’re similar to databases in that the message is not sent via a direct network connection but goes via an intermediary called a message broker, also called a message queue or message-oriented middleware, which stores the message temporarily.

Five advantages of a broker over direct RPC

  • It can act as a buffer if the recipient is unavailable or overloaded, and so improve system reliability.
  • It can automatically redeliver messages to a process that has crashed, and so prevent messages from being lost.
  • It avoids the sender needing to know the IP address and port number of the recipient, which is particularly useful in a cloud deployment where virtual machines often come and go.
  • It allows one message to be sent to several recipients.
  • It logically decouples the sender from the recipient: the sender just publishes messages and does not care who consumes them.

The difference from RPC is that message-passing communication is usually one-way: a sender normally doesn’t expect a reply to its messages. A process can send a response, but that would usually be done on a separate channel. The pattern is asynchronous: the sender doesn’t wait for the message to be delivered, but simply sends it and then forgets about it.

Message brokers

The landscape used to be dominated by commercial enterprise software from companies such as TIBCO, IBM WebSphere and webMethods. More recently open source implementations have become popular: RabbitMQ, ActiveMQ, HornetQ, NATS and Apache Kafka.

Delivery semantics vary by implementation and configuration, but in general: one process sends a message to a named queue or topic, and the broker ensures the message is delivered to one or more consumers of or subscribers to that queue or topic. There can be many producers and many consumers on the same topic.

A topic provides only one-way dataflow. But a consumer may itself publish to another topic — so you can chain them together — or to a reply queue consumed by the sender of the original message, which allows a request/response dataflow similar to RPC.

Brokers typically don’t enforce any particular data model: a message is just a sequence of bytes with some metadata, so you can use any encoding format. And here’s the payoff: if the encoding is backward and forward compatible, you have the greatest flexibility to change publishers and consumers independently and deploy them in any order.

One caution: if a consumer republishes messages to another topic, you may need to be careful to preserve unknown fields — the same hazard as the database case above, and the same fix.

The three modes, on one clock

via databases
writer encoded record the database · data outlives code the same bytes reader, five years later
backward compatibility, unavoidably · forward compatibility, usually
via services · REST and RPC
client request server response client again
backward on requests · forward on responses · servers updated first
via asynchronous message passing
producer message broker · one named topic to consumer 1 to consumer 2 consumers
both directions · and then you can deploy publishers and consumers in any order
One question, three timings. The broker's job is that the middle box can hold the message while nobody is listening.

Distributed actor frameworks

The actor model is a programming model for concurrency in a single process. Rather than dealing directly with threads, and the associated problems of race conditions, locking and deadlock, logic is encapsulated in actors. Each actor typically represents one client or entity, may have some local state that is not shared with any other actor, and communicates with other actors by sending and receiving asynchronous messages. Message delivery is not guaranteed: in certain error scenarios, messages will be lost. Since each actor processes only one message at a time it needn’t worry about threads, and each can be scheduled independently by the framework.

In distributed actor frameworks the model is used to scale an application across multiple nodes. The same message-passing mechanism is used whether sender and recipient are on the same node or different nodes; if different, the message is transparently encoded into a byte sequence, sent over the network, and decoded on the other side.

Why location transparency works better here than in RPC

Because the actor model already assumes that messages may be lost, even within a single process. Latency over the network is likely higher than within the same process, but there is less of a fundamental mismatch between local and remote communication when using the actor model. A distributed actor framework essentially integrates a message broker and the actor programming model into a single framework — but if you want rolling upgrades of an actor-based application, you still have to worry about forward and backward compatibility, since messages may be sent from a node running the new version to one running the old version, and vice versa.

AkkaUses Java’s built-in serialization by default, which provides neither forward nor backward compatibility. You can replace it with something like Protocol Buffers, and thereby gain the ability to do rolling upgrades.
Recall check 12 questions

Did it stick?

One attempt per question; the explanation appears either way.

1. Define backward and forward compatibility, and say which is usually harder.

2. Why is a rolling upgrade the reason this chapter exists?

3. What is the chief drawback of using your language's built-in encoding (pickle, Marshal, java.io.Serializable)?

4. Why does Twitter's API return each tweet ID twice, once as a number and once as a string?

5. MessagePack encodes the example record in 66 bytes versus JSON's 81. Why so little saving?

6. What role do field tags play in Thrift and Protocol Buffers?

7. You're adding a field to a Protocol Buffers schema after initial deployment. What constraint applies?

8. In Avro, what does the reader do when the writer's schema contains a field the reader's schema doesn't have?

9. How can a reader know the writer's schema without shipping the schema in every record?

10. Why is Avro friendlier than Thrift or Protocol Buffers to dynamically generated schemas?

11. Why is the RPC ideal of location transparency fundamentally flawed?

12. What simplifying assumption can you make about RPC compatibility that you can't make about databases?

Answered 0 of 12 · 0 correct

Vocabulary Click to flip

The terms this chapter installs


Chapter 4 in one breath. Encoding details affect not only efficiency but, more importantly, the architecture of applications and your options for deploying them. Many services need rolling upgrades, where a new version is gradually deployed to a few nodes at a time rather than everywhere at once — allowing releases without downtime, which encourages frequent small releases over rare big ones, and making deployments less risky by letting faulty releases be detected and rolled back before they affect many users. Hugely beneficial for evolvability. During a rolling upgrade we must assume different nodes run different versions of the code, so all data flowing around the system must provide backward compatibility, where new code can read old data, and forward compatibility, where old code can read new data.

The formats: language-specific encodings are restricted to one language and often fail to provide either direction. Textual formats — JSON, XML, CSV — are widespread, their compatibility depends on how you use them, their optional schema languages are sometimes helpful and sometimes a hindrance, and they’re vague about datatypes, so be careful with numbers and binary strings. Binary schema-driven formats — Thrift, Protocol Buffers, Avro — allow compact, efficient encoding with clearly defined compatibility semantics, and their schemas are useful for documentation and code generation, at the cost that data needs to be decoded before it is human-readable. The 81 bytes at the top of the inspector and the 32 at the bottom are the same record; the 49 bytes of difference are almost entirely names and punctuation.

The modes: databases, where the writing process encodes and the reading process decodes; RPC and REST APIs, where the client encodes a request, the server decodes it and encodes a response, and the client finally decodes the response; and asynchronous message passing via brokers or actors, where nodes communicate by messages encoded by the sender and decoded by the recipient. The conclusion: with a bit of care, backward and forward compatibility and rolling upgrades are quite achievable. May your application’s evolution be rapid and your deployments be frequent.

Study aid built for recall practice. Concepts, code, schemas and named systems are Martin Kleppmann’s, from Designing Data-Intensive Applications, chapter 4 — go back to the book for his wording and the full reference list. The byte counts here are not quoted from it: the six encoders in src/lib/ddia4.ts encode one record and the exhibits count the result, which happens to agree with the book’s figures on all six.

← all lab notes