Flyweight vs Whole-Struct

You can work field-by-field (classic flyweight) or fill / materialise a whole struct. Use the style that matches how much of the message you touch.

StyleBest whenCostSchema evolution
Flyweight (per-field)You only read one or a few fields; hot pathZero-copy; no heapNew fields are optional at call sites (you simply don’t read them)
*FixedFields + .fixed(...)You always write the entire fixed blockOne struct write, still flyweight bufferAdding a required fixed field to the schema → compile error until you set it in the struct
*Domain DTO (.with_domain_objects(DomainVarData::…))Whole message as owned data; enum picks String vs Vec<u8> var-dataAllocates — never use on the hot path. Easier app code for tooling, logging, offline processingSame idea: regenerating after a schema change forces you to fill new struct fields

Encode — whole fixed block as a struct

When you always populate every fixed field, a struct is clearer and schema additions break at compile time:

#![allow(unused)]
fn main() {
let mut buf = [0u8; HeartbeatEncoder::compute_length_with_header()];
// Buffer is exact size from const compute_length_with_header — no bounds check needed.
let len = HeartbeatEncoder::try_wrap_and_apply_header(&mut buf, 0)?
    .fixed(&HeartbeatFixedFields { sequence: 7, timestamp: 0 })
    .encoded_length_with_header();
let dec = HeartbeatDecoder::try_from(&buf[..len])?;
assert_eq!(dec.sequence(), 7);
}

(From book/examples/heartbeat-encode.rs — compiled against the feature-tour codec.)

If the schema later adds a required field to the fixed block, this stops compiling until you add it to the struct literal — you cannot silently omit it.

Decode — individual fields (flyweight)

#![allow(unused)]
fn main() {
// Encode a sample car first (normally bytes come from the wire)
let complete_len = CarEncoder::compute_length()
    .fuel_figures_ragged(0, |_| Ok(()))?
    .performance_figures_ragged(0, |_| Ok(()))?
    .manufacturer(5)?
    .model(5)?
    .activation_code(3)?
    .encoded_length_with_header();
const PAD: usize = 256;
assert!(complete_len <= PAD, "car length {complete_len} exceeds pad {PAD}");
let mut storage = [0u8; PAD];
let buf = &mut storage[..complete_len];
let fields = CarFixedFields {
    serial_number: 1234,
    model_year: 2013,
    available: true.into(),
    code: Model::A,
    some_numbers: [10, 20, 30, 40],
    vehicle_code: *b"ABCDEF",
    extras: OptionalExtras::default(),
    engine: Engine::new(2000, 4, *b"123", 0i8, false.into(), Booster::new(BoostType::TURBO, 210)),
};
let n = CarEncoder::try_wrap_and_apply_header(buf, 0)?
    .fixed(&fields)
    .fuel_figures(0, |_| Ok(()))?
    .performance_figures(0, |_| Ok(()))?
    .manufacturer(b"Honda")?
    .model(b"Civic")?
    .activation_code(b"abc")?
    .encoded_length_with_header();
assert_eq!(n, complete_len);
// Now decode — read only the fields you need, no DTO allocation:
let car = CarDecoder::try_from(&buf[..n])?;
assert_eq!(car.serial_number(), 1234);
assert_eq!(car.model_year(), 2013);
assert_eq!(car.code(), Model::A);
assert_eq!(car.engine().capacity(), 2000);
}

(From book/examples/flyweight-access.rs — compiled against the feature-tour codec.)

Decode — whole message as a DTO

Do not use on the latency-sensitive path. DTO decode allocates Vec/String and copies every field. For the hot path, use the flyweight decoder instead.

When you always need (almost) everything, or want to pass a value across threads / into non-SBE code:

#![allow(unused)]
fn main() {
pub fn demo_car_domain_dto(wire: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
    let dec = CarDecoder::try_decode(wire, 0)?;
    // try_from_decoder (not TryFrom/From): two fallible sources — decoder vs
    // try_from_slice_with_header for framed bytes; materialisation can fail.
    let dto = CarDomain::try_from_decoder(dec)?;
    assert_eq!(dto.serial_number, 1234);
    assert!(dto.available); // bool domain field
    assert_eq!(dto.fuel_figures.len(), 2);
    assert_eq!(dto.fuel_figures[0].usage_description, "Urban");
    assert_eq!(dto.manufacturer, "Honda");

    const RE_PAD: usize = 512;
    assert!(wire.len() <= RE_PAD);
    let mut storage = [0u8; RE_PAD];
    let n = dto.encode(&mut storage[..wire.len()])?;
    assert_eq!(&storage[..n], wire, "DTO re-encode must be byte-identical");
    Ok(())
}
}

Rule of thumb: one field on the hot path → flyweight. Always fill or always consume the whole message → FixedFields / Domain for clarity and compile-time breakage on schema growth. More on DTOs in Recipes — Domain DTOs.