← Back to roadmap
Phase 3Week 35~110 min read
0%·0/4

Schema-Driven Binary Protocols

SBE-style codecs and safe schema evolution.

Performance · Measure, Profile, Then Go Fast

Why this week

In week 11 you designed fixed binary layouts by hand: offsets as constants, from_le_bytes at each one, a zero-copy view over &[u8]. That's exactly right for one message at one version. It falls apart at real-venue scale, which means dozens of message types, a new version every few months, thousands of clients upgrading whenever they choose, and a bug in any hand-computed offset silently corrupting prices.

Real venues solve it the same way. The schema is the source of truth, encoders and decoders are generated from it, and the rules for changing it are mechanical, so a machine can check that version 7 still decodes everything version 6 clients send. CME's MDP 3.0 market data and iLink 3 order entry both use Simple Binary Encoding (SBE), a FIX Trading Community standard designed for exactly this: fixed offsets, no per-field tags, decode cost in nanoseconds. This week you learn how SBE lays out bytes, how it evolves without breaking old clients, and how to build the same machinery for your exchange in Rust.

💡
Tip

The goal isn't "use SBE". It's to make the compatibility contract explicit and testable. Every protocol eventually needs a v2. The question is whether you find out it broke a client from a conformance test or from that client's trading desk.

Day 1 — Anatomy of an SBE message

Day 01

Fixed where it can be, counted where it must be

Header, root block, groups, variable data

An SBE message is four regions, always in this order:

  1. Message header, 8 bytes in the standard schema: blockLength: u16, templateId: u16, schemaId: u16, version: u16. templateId says which message this is, schemaId which schema it belongs to, and version which version of the schema the sender used. blockLength says how many bytes the sender's root block occupies.
  2. Root block: the message's fixed-size fields at fixed offsets. No tags, no lengths, no delimiters. A field is wherever the schema says it is.
  3. Repeating groups, each with a small dimension header (typically blockLength: u16 and numInGroup: u16; CME's MDP 3.0 uses a one-byte count) followed by numInGroup entries of blockLength bytes each. Groups can nest.
  4. Variable-length data: length-prefixed byte strings, last.

Fields are little-endian by default (the schema can say otherwise), and SBE doesn't align anything unless the schema asks for it with explicit offsets. Optional fields don't have a presence bit. A designated null value of the field's type means "absent": the maximum for unsigned types, the minimum for signed ones.

The schema itself is XML. Here's a NewOrder in the shape you'll decode on Day 3 (the field ids are the FIX tag numbers, which is SBE convention):

<sbe:message name="NewOrder" id="1">
    <field name="clOrdId" id="11"  type="uint64"/>
    <field name="price"   id="44"  type="int64"/>
    <field name="qty"     id="38"  type="uint32"/>
    <field name="side"    id="54"  type="Side"/>
    <field name="minQty"  id="110" type="uint32" presence="optional" sinceVersion="2"/>
    <group name="parties" id="453" dimensionType="groupSizeEncoding">
        <field name="partyId" id="448" type="uint32"/>
        <field name="role"    id="452" type="uint8" presence="optional" sinceVersion="2"/>
    </group>
</sbe:message>

The design consequence that matters most: access is streaming and single-pass. Root fields are at constant offsets, so reading price is one load. A group's position, though, depends on the root block's length, and the second group's position depends on the first group's length. Decoders therefore walk the message front to back, and generated APIs make you read groups in order. In exchange for that discipline you get a decoder that's a few loads and no branches per field.

Docs
Simple Binary Encoding — FIX Trading Community specification

Read the sections on message structure, the message header, repeating groups, null values and schema extension. The extension rules are short, normative, and exactly what Days 2 and 3 implement.

Docs
real-logic/simple-binary-encoding wiki

The reference implementation's design notes. Read the pages on the flyweight/codec design and on safe extension of messages. Notice how much of the API exists to enforce in-order access.

Recall#why-header-blocklength

Every SBE message header carries the sender's root blockLength even though the schema already defines the root layout. Why?

Day 2 — From schema to offsets

Day 02

Layout is a pure function of the schema

And evolution is a constraint on that function

A code generator's first job is the layout: walk the fields in declaration order, place each one where the previous ended (or at an explicit offset, leaving padding), and record the root block's length. That's the same arithmetic you did by hand in week 11, but now it's done by a function that can be tested.

Alignment is a deliberate choice. SBE doesn't require it. On x86 an unaligned 8-byte load costs nothing extra unless it crosses a 64-byte cache-line boundary, and even then it costs only a few cycles. Crossing a 4 KiB page boundary is worse. Explicit offsets let you put 8-byte fields at multiples of 8 so a message never splits a hot field across lines, at the price of a few padding bytes. CME's schemas are packed, while many in-house protocols align. Make the choice once, in the schema, and let the layout function enforce it.

Evolution constrains the function. The extension rules boil down to one principle: a field's offset, type and meaning never change once published. It follows that:

  • New fields go at the end of the root block or of a group entry, tagged with sinceVersion. The sequence of sinceVersion values along a block never decreases.
  • Fields are never removed or reordered. You can deprecate one (stop populating it and send the null value), but its bytes stay.
  • New groups go after existing groups, and new variable-length fields after existing ones.

Because the layout function sees every field's sinceVersion, it can compute the root block length for each version: the length a v0 encoder writes, the length a v1 encoder writes, and so on. That's exactly what a conformance test needs to construct "a message from an old client".

Drill

Compute field offsets from a schema

Target: 6/6 casesNot yet run
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Prim {
    U8,
    U16,
    U32,
    U64,
    I64,
    Char(usize), // fixed-length character array
}

#[derive(Clone, Copy, Debug)]
pub struct FieldSpec {
    pub name: &'static str,
    pub prim: Prim,
    pub since: u16,            // sinceVersion
    pub offset: Option<usize>, // explicit offset; padding before it is allowed
}

#[derive(Debug, PartialEq, Eq)]
pub enum SchemaError {
    Overlap(&'static str),      // explicit offset lands inside an earlier field
    VersionOrder(&'static str), // a field older than one before it
}

#[derive(Debug, PartialEq, Eq)]
pub struct Layout {
    pub offsets: Vec<(&'static str, usize)>,
    pub block_length: usize,
}

pub fn prim_size(p: Prim) -> usize {
    todo!()
}

/// Lay the fields out in declaration order. A field without an explicit
/// offset starts where the previous one ended; an explicit offset must not
/// overlap the previous field. sinceVersion must never decrease: new fields
/// are only ever appended. Report the first offending field.
pub fn layout(fields: &[FieldSpec]) -> Result<Layout, SchemaError> {
    todo!()
}

/// The root block length an encoder of `version` writes: the end of the
/// last field that exists in that version (0 if none). Schema errors
/// propagate.
pub fn block_length_at(fields: &[FieldSpec], version: u16) -> Result<usize, SchemaError> {
    todo!()
}
Rust
Recall#append-only

Why can a new field only be appended at the end of a block, never inserted between existing fields?

Day 3 — Versioned decoding

Day 03

Old clients, new clients, one exchange

Acting version, blockLength skipping and nulls

A decoder compiled against schema version S reads a message whose header says version V, the acting version. Two directions must both work:

  • New decoder, old message (V < S): fields with sinceVersion > V don't exist in this message. Their accessors return the null value (or None in an idiomatic Rust API) without reading the bytes, because those bytes belong to whatever comes next.
  • Old decoder, new message (V > S): the root block has fields the decoder has never heard of. It reads the fields it knows at their known offsets and then finds the groups at header + blockLength, the sender's length and not its own idea of the block size. The same holds inside every group: step through entries by the group's blockLength, not your own entry size.

Then be strict about malformed input. If the acting version says a field exists but blockLength is too short to contain it, the message is corrupt, and decoding garbage would be worse than rejecting it. A buffer that ends before the header, a root block or a group entry ends is Short. A decoder on an exchange's order-entry path faces hostile and buggy input all day, so it must reject cleanly and never panic.

Two smaller traps:

  • Null sentinels in the wire format, Option in the API. u32::MAX meaning absent is a wire convention. Convert at the boundary, so min_qty: Option<u32> can't be mistaken for a 4-billion-lot order.
  • Enums grow too. Adding an enum value (a new TimeInForce) is a schema change that old decoders see as an unknown byte. Decode into an enum with an explicit Unknown(u8) variant, or reject, and never transmute a byte into a Rust enum: an out-of-range discriminant is undefined behaviour.
Drill

Decode v1 and v2 messages

Target: 6/6 casesNot yet run
pub const HEADER_LEN: usize = 8;
pub const TEMPLATE_NEW_ORDER: u16 = 1;
pub const SCHEMA_ID: u16 = 7;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Party {
    pub id: u32,
    pub role: Option<u8>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NewOrder {
    pub cl_ord_id: u64,
    pub price: i64,
    pub qty: u32,
    pub side: u8,
    pub min_qty: Option<u32>,
    pub parties: Vec<Party>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DecodeError {
    Short,              // the buffer ends before something it must contain
    WrongTemplate(u16),
    WrongSchema(u16),
    BlockTooShort,      // a block too small for the fields its version promises
}

/// Schema id 7, NewOrder template 1. Version 2 is current.
///
///   header : blockLength u16 | templateId u16 | schemaId u16 | version u16
///   root   : clOrdId u64 @0 | price i64 @8 | qty u32 @16 | side u8 @20  (since v1)
///            minQty u32 @21                          (since v2, null = u32::MAX)
///   group  : blockLength u16 | numInGroup u16, then numInGroup entries,
///            each blockLength bytes
///   entry  : partyId u32 @0 (since v1) | role u8 @4 (since v2, null = 255)
///
/// All integers little-endian. Rules:
///   - read the whole header first (Short if < 8 bytes), then check
///     templateId, then schemaId
///   - root blockLength < 21, or < 25 when version >= 2 -> BlockTooShort;
///     entry blockLength < 4, or < 5 when version >= 2 -> BlockTooShort
///   - a field exists iff version >= its sinceVersion
///   - the group starts at HEADER_LEN + blockLength (the sender's!)
///   - entries are blockLength apart; a partial entry -> Short
///   - null sentinels decode to None
pub fn decode(buf: &[u8]) -> Result<NewOrder, DecodeError> {
    todo!()
}
Rust

The drill decodes into an owned NewOrder with a Vec so the tests are easy to read. Your capstone decoder must not do that, because the allocation alone would blow the latency target. Tomorrow is about the zero-copy shape.

Recall#old-decoder-new-message

A v2 decoder receives a v3 message whose root block is 29 bytes and whose party entries are 7 bytes. What exactly does it do, and what bug do you get if it uses its own sizes (25 and 5) instead?

Design note#version-support-policy

Write your exchange's version-support policy: how many schema versions the gateway accepts at once, how a client learns which versions are supported, what happens to a client on a retired version, and how you'd roll out a breaking change if you ever truly needed one.

0 chars

Day 4 — Generating Rust from a schema

Day 04

build.rs, macro_rules!, or a derive

The generated code should look like what you'd write by hand on a good day

What should a generated decoder look like? The SBE reference implementation answers with a flyweight: a thin view over the bytes that knows the acting version and the block length and decodes fields on access. In Rust:

pub struct NewOrderDecoder<'a> {
    buf: &'a [u8],     // validated once: header + root block fit
    version: u16,
    block_len: u16,
}

impl<'a> NewOrderDecoder<'a> {
    pub fn wrap(buf: &'a [u8]) -> Result<Self, DecodeError> { /* one length check, header checks */ }

    #[inline]
    pub fn price(&self) -> i64 {
        i64::from_le_bytes(self.buf[HDR + 8..HDR + 16].try_into().unwrap())
    }

    #[inline]
    pub fn min_qty(&self) -> Option<u32> {
        if self.version < 2 { return None; }
        match u32::from_le_bytes(self.buf[HDR + 21..HDR + 25].try_into().unwrap()) {
            u32::MAX => None,
            v => Some(v),
        }
    }

    pub fn parties(&self) -> PartiesIter<'a> { /* starts at HDR + block_len */ }
}

Each accessor is a load from a constant offset. Validate the lengths once in wrap (header, root block, and if you can, the whole message including groups), and per-field slicing becomes a bounds check the optimiser can often remove. If it can't, generated get_unchecked behind a validated constructor is the one place unsafe earns its keep. Check the assembly (week 37 is all about this).

Three ways to produce that code:

  • build.rs + a schema file. The build script parses the schema (XML, TOML, or your own format), writes Rust into OUT_DIR, and the crate pulls it in with include!(concat!(env!("OUT_DIR"), "/messages.rs")). It's the most flexible: the same parse can emit docs, test vectors or a C header for clients. Remember cargo:rerun-if-changed=schema/…, or edits won't trigger rebuilds.
  • macro_rules!. The schema becomes Rust tokens: message! { NewOrder = 1 { cl_ord_id: u64, price: i64, … } }. Nothing to parse, and offsets can come from const fn arithmetic over the field list. Error messages and versioning metadata get awkward, though, and nobody else can read the schema.
  • A derive / procedural macro. You annotate a Rust struct and generate the codec. It reads naturally, but compiles slowest, and the struct definition quietly becomes the wire contract.

For an exchange whose clients are written in other languages, a schema file that isn't Rust wins: you hand it to clients. macro_rules! is a fine way to start, and the spine allows either.

Docs
Build Scripts (The Cargo Book)

OUT_DIR, rerun-if-changed, and the code-generation example. Generated code should be formatted and readable: you'll be stepping through it in a debugger one day.

Docs
The Little Book of Rust Macros

If you go the macro_rules! route: repetitions, the incremental TT muncher pattern for walking a field list, and how to accumulate offsets. Use cargo expand to see what you generated.

Recall#validate-once

Why should a generated decoder validate buffer lengths once in its constructor instead of in every accessor, and what does that buy at the assembly level?

Day 5 — Conformance, and the alternatives

Day 05

Pin the bytes, then try to break them

Golden vectors, version matrices, fuzzing

A codec is correct when its bytes are right, not when encode-then-decode round-trips. A buggy encoder paired with the matching buggy decoder round-trips perfectly. A conformance suite has four layers:

  1. Golden vectors. Checked-in hex files, one per message and version, produced once and reviewed by a human, ideally cross-checked against an independent implementation such as the reference SBE tool. Your encoder must produce them byte for byte, and your decoder must decode them to known values.
  2. The version matrix. For each pair of (encoder version, decoder version) you support, encode with one and decode with the other. Include a synthetic future version with extra root and group bytes, as in the drill, so old-decoder-new-message is tested before a future version exists.
  3. Property tests. For random valid messages (week 12's generators), decode(encode(m)) == m, and the encoded length equals what the layout predicts.
  4. Fuzzing. Feed the decoder arbitrary bytes (cargo fuzz) and require that it never panics, never reads out of bounds and never loops. Your gateway parses bytes from the internet, so this isn't optional.

It's also worth knowing what you didn't choose, because interviewers ask:

SBEProtocol BuffersFlatBuffers / Cap'n Proto
Field locationfixed offsettag + varint, any orderoffset table (vtable / pointers)
Read one fieldone loadparse the message up to ita couple of indirections
Evolutionappend + sinceVersion + blockLengthfield numbers, unknown fields skippedvtable slots / pointer sections
Sizefixed, sometimes paddedcompact (varints)larger (offsets, alignment)
Fitsfixed-shape, latency-critical messagesgeneral RPC and storagelarge documents read partially

Protobuf's varints and tag-per-field parsing cost tens to hundreds of nanoseconds per message. That's fine for configuration and admin RPCs, and wrong for a gateway's hot path. SBE wins there by refusing flexibility: no optional reordering, no sparse fields, fixed shapes.

Docs
Encoding (Protocol Buffers documentation)

Read it to be able to say precisely why protobuf decoding is sequential and branchy: varints, wire types, unknown-field handling. Then compare with your drill's decoder, which has no loop over fields at all.

Docs
Cap'n Proto: Encoding

The zero-copy alternative with pointers and 8-byte words. Worth knowing as the 'random access into large messages' end of the design space, and for its clear discussion of evolution.

Recall#roundtrip-not-enough

Why is an encode→decode round-trip property test insufficient as a conformance test for a wire protocol, and what catches the bugs it misses?

Design note#schema-for-exchange

List the messages your exchange's schema needs (order entry: new, cancel, replace, acks, fills, rejects; market data: add, modify, delete, trade, snapshot). For each, mark the fields you expect to change within a year and where you'd leave room. Which message is most likely to need a repeating group?

0 chars

Day 6 — Plan the generator

Day 06

Decide the moving parts

Schema format, generator, generated API, tests

Before you write the generator, decide:

  • Schema format. XML that mirrors SBE (familiar to anyone who has seen CME's schemas), a TOML file, or a macro_rules! DSL. If clients will ever see it, avoid the DSL.
  • What gets generated. At least: constants (template ids, offsets, block lengths per version), a decoder flyweight per message with Option accessors for versioned fields, group iterators, an encoder that writes into &mut [u8] and returns the length, and a Message enum to dispatch on template id.
  • Migration of week 11. Your existing messages should be expressible in the schema and produce identical bytes as version 1. Make that the first golden test. (The repo chose not to. Its schema messages have their own 4-byte header and packed fields, and week 11's codec stays as it is beside them. See the capstone.)
Design note#generated-api

Write, by hand, the code you want the generator to emit for one message with one group: the decoder struct, two accessors (one versioned), the group iterator, and the encoder signature. This file becomes the generator's expected output in a snapshot test.

0 chars

Capstone

In crates/wire, replace hand-written layouts with a schema and a generator, move the week 11 messages into it as version 1, add at least one message with a version-2 field and one with a repeating group, and build the conformance suite.

The repo's version (src/schema.rs) is smaller than that, and differs in a few places:

  • Generator: macro_rules!. The module docs give the reasons. The schema, the generator and the generated items sit in one file in plain Rust syntax. cargo expand shows what was generated, errors point at the schema line, and every offset is a compile-time constant. The template ships a stub wire_schema! with todo!() bodies; the real macro you write after it shadows it.
  • Format. Every message starts with a 4-byte header: template u8, version u8 and block_len u16, little-endian. There's no schema id. The root block follows, packed in schema order with no padding.
  • Messages. NewOrder (template 1), Cancel (2), Reduce (3) and Trade (4). Each has v1 fields plus Option<T> v2 fields, whose null values are u64::MAX, i64::MIN and so on. NewOrder is 39 bytes as v1 and 55 as v2. These are the schema's twins of the week 11 messages, not byte-identical to them: week 11's NewOrder is 40 bytes with a one-byte tag. Week 11's hand-written codec stays alongside.
  • No repeating groups. Adding one group type to wire_schema! is the natural extension.
  • What's generated. Each message gets an owned struct, TEMPLATE, V1_BLOCK, V2_BLOCK, the FIELDS layout table, encoded_len(version), encode_into(&self, version, buf) and decode(buf). It also gets a zero-copy view, NameView<'a>, with new(buf) (the wrap of Day 4), version(), message_len(), one accessor per field and to_msg(). decode_message(buf) dispatches on the template and returns the message with its length.
  • Versioning. Decoders accept v1, v2 and any later version, skipping trailing fields they don't know by using block_len. Version 0 is invalid. A block shorter than its version requires is BlockTooShort, and an enum byte outside its domain is InvalidField.

The w35_ tests cover:

  • golden vectors for v1 and v2, as byte arrays in the test file;
  • decoding both versions;
  • newer versions skipped, not rejected;
  • bad headers and fields rejected;
  • random round trips through frames;
  • the generated layout table matching the schema;
  • the null value meaning absent.

Pitfalls worth designing out:

  • Silent offset drift. Commit a generated layout table (message, field, offset, size, sinceVersion) as a snapshot. Any change to it fails review loudly, and the only acceptable diff is appended rows.
  • Allocating decoders. A decoder that builds a Vec for groups or a String for symbols blows the latency budget and moves allocation into the gateway's hot path (week 27). Generated decoders are views, and anything owned is an explicit, separate call.
  • Validation in the wrong place. Validate lengths and template once in wrap, and keep accessors branch-free except for version checks. Assert in tests that accessors on a truncated buffer are unreachable, because wrap rejects it.
  • Build-script hygiene. Use rerun-if-changed for the schema and the generator, deterministic output (sorted iteration, no timestamps), and formatted generated code checked into a snapshot test so reviewers see what changed.

Reading the benchmark. The target is the p50 cost of decoding one message with the generated decoder. At that scale you're measuring a header check and a few loads. Anything that allocates, copies the whole message or loops over fields won't make it. Benchmark the realistic access pattern: wrap the message and read the fields the gateway actually uses, not just wrap. Make sure the optimiser can't delete the work (std::hint::black_box on the input and the results), otherwise you're measuring nothing at 0.3 ns. The repo's w35.decode calls NewOrder::decode on a 55-byte v2 NewOrder. That's NewOrderView::new, which checks the header, template, block length and enumerated fields once, followed by to_msg, which reads every field into the owned Copy struct with no allocation.

Capstone · in your exchange · crates/wire

Schema in, codecs out

A schema for your exchange's messages, generated zero-copy encoders and decoders in crates/wire, versioned messages, and a conformance suite that pins the bytes.

cargo xtask test --week 35      # tests named w35_*
cargo xtask bench --week 35     # p50 / p99 / p99.9 on your machine
cargo xtask report --upload  # update this page and your lab
  • All week 35 tests pass w35_*
  • Generated decoder, one message w35.decodetarget p50 ≤ 10 ns
Design note#week-reflection

Pick one message and plan its v3: what you'd add, the sinceVersion, the layout change, and the exact conformance tests that prove a v2 client and a v3 client can both still trade. What would you have to do if a client demanded a field be removed?

0 chars

Feeds into

No later week lists this one as a prerequisite. In the repo, nothing outside crates/wire uses the schema codecs yet. Week 46's feed packets (marketdata::feed) have their own hand-laid layout, built on the same ideas: a versioned header, fixed little-endian fields and strict decoding. Making the schema the gateway's and the publisher's wire layer is yours to do, and the version matrix is part of what you'll show in week 51's portfolio.