with_conversion vs with_domain_type
Do not call both for the same selector — domain type already enables conversion.
A with_conversion | B with_domain_type | |
|---|---|---|
| Idea | Generic convert API; you plug any app type | Always use this Rust path |
| build.rs | .with_conversion(named_type("Decimal")) | .with_domain_type(…, "rust_decimal::Decimal") |
| You write | TryFromSbe<Decimal> / TryToSbe<Decimal> for your type | Usually nothing for bool / rust_decimal / chrono |
| Decode | let p: Cents = dec.price_as()? | let p: rust_decimal::Decimal = dec.price()? |
| Encode | enc.price_from(¢s)? | enc.price(rust_decimal::Decimal::new(12345, 2))? |
| Raw wire | price_value() / price_wire(...) | same when conversion is active |
| Sample | exchange-example · demo_conversion_only | l3-book |
Which fields a selector reaches, and what shape you get
A selector matches by precedence — exact FieldPath, then SemanticType, then
NamedType — so a blanket mapping plus one per-field override behaves the way
it reads, whatever order you register them in. Paths are "Message.field",
extended with group names inside a group ("Order.legs.price").
What the accessor gives you depends on the field’s shape, because it mirrors the raw accessor it delegates to:
| Field | with_conversion | with_domain_type |
|---|---|---|
Required, sinceVersion=0 | price_as::<T>() -> Result<T, _> | price() -> Result<T, _> |
sinceVersion > 0 | Result<Option<T>, _> | Result<Option<T>, _> |
presence="optional" scalar | Result<Option<T>, _> | Result<Option<T>, _> |
The Option layer means “no value on the wire” — absent at this version, or
the schema null value. It is never used to report a bad value; an invalid wire
value is the Err.
Composites, enums, and sets all take domain types. Fixed arrays and optional
scalars keep their wire type in a domain DTO even when a selector matches
them — the flyweight still gets the converted accessor, but the owned struct
stores [u8; N] / Option<i64>.
Option A — you choose the app type (Cents)
#![allow(unused)]
fn main() {
use ergo_sbe::{ConversionSelector, GenerationConfig};
// A — generic converter: one wire type, many app types
let _cfg =
GenerationConfig::new("msgs").with_conversion(ConversionSelector::named_type("Decimal"));
}
(From book/examples/conversion-config.rs — a self-contained program that compiles against ergo-sbe.)
#![allow(unused)]
fn main() {
use rust_decimal::Decimal as Rd;
// App adapter: wire Decimal ↔ rust_decimal::Decimal
struct FixedPrice { mantissa: i64, exponent: i8 }
impl TryFromSbe<Decimal> for FixedPrice {
type Error = &'static str;
fn try_from_sbe(wire: Decimal) -> Result<Self, Self::Error> {
Ok(FixedPrice {
mantissa: wire.mantissa(),
exponent: wire.exponent(),
})
}
}
impl TryToSbe<Decimal> for FixedPrice {
type Error = &'static str;
fn try_to_sbe(&self) -> Result<Decimal, Self::Error> {
Ok(Decimal::new(self.mantissa, self.exponent))
}
}
}
(From book/examples/conversion-app-code.rs — app adapter pattern, compiles against tour_codec.)
// Encode using the generic conversion API:
let mut buf = [0u8; QuoteEncoder::compute_length_with_header()];
let price = Rd::new(12345, 2); // 123.45
let len = QuoteEncoder::try_wrap_and_apply_header(&mut buf, 0)?
.price_from(&price)?
.size_from(&Rd::new(10, 0))?
.encoded_length_with_header();
// Decode — generic `_as::<T>()` picks your adapter:
let dec = QuoteDecoder::try_from(&buf[..len])?;
let p: Rd = dec.price_as()?;
assert_eq!(p, Rd::new(12345, 2));
// Same buffer, different app type — only possible with with_conversion:
let fixed: FixedPrice = dec.price_as()?;
assert_eq!(fixed.mantissa, 12345);
assert_eq!(fixed.exponent, -2);
(Same file — generic _from/_as encode/decode with with_conversion.)
Option B — one fixed app type
#![allow(unused)]
fn main() {
use ergo_sbe::{ConversionSelector, GenerationConfig};
// B — concrete mapping: one Rust type per wire type (already enables conversion)
let _cfg = GenerationConfig::new("msgs").with_domain_type(
ConversionSelector::named_type("Decimal"),
"rust_decimal::Decimal",
);
}
(Same source file — book/examples/conversion-config.rs.)
// Encode using the generic conversion API:
let mut buf = [0u8; QuoteEncoder::compute_length_with_header()];
let price = Rd::new(12345, 2); // 123.45
let len = QuoteEncoder::try_wrap_and_apply_header(&mut buf, 0)?
.price_from(&price)?
.size_from(&Rd::new(10, 0))?
.encoded_length_with_header();
// Decode — generic `_as::<T>()` picks your adapter:
let dec = QuoteDecoder::try_from(&buf[..len])?;
let p: Rd = dec.price_as()?;
assert_eq!(p, Rd::new(12345, 2));
// Same buffer, different app type — only possible with with_conversion:
let fixed: FixedPrice = dec.price_as()?;
assert_eq!(fixed.mantissa, 12345);
assert_eq!(fixed.exponent, -2);
Both styles on different fields:
pub fn demo_conversion_only() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let mut buf = [0u8; QuoteEncoder::compute_length_with_header()];
let price = Rd::new(12345, 2); // 123.45
let size = Rd::new(10, 0);
let mut enc = QuoteEncoder::try_wrap_and_apply_header(&mut buf, 0)?;
enc.price_from(&price)?;
enc.size_from(&size)?;
let len = QuoteEncoder::compute_length_with_header();
let dec = QuoteDecoder::try_from(&buf[..len])?;
let wire = dec.price_value();
assert_eq!(wire.mantissa(), 12345);
assert_eq!(wire.exponent(), -2);
let price2: Rd = dec.price_as()?;
let size2: Rd = dec.size_as()?;
assert_eq!(price2, price);
assert_eq!(size2, size);
// Same buffer, different app type — only possible with with_conversion.
let fixed: FixedPrice = dec.price_as()?;
assert_eq!(
fixed,
FixedPrice {
mantissa: 12345,
exponent: -2
}
);
let dto = QuoteDomain::try_from_decoder(dec)?;
assert_eq!(dto.price.mantissa(), 12345);
let mut re = [0u8; QuoteEncoder::compute_length_with_header()];
let n = dto.encode(&mut re)?;
assert_eq!(&re[..n], &buf[..len]);
Ok(buf[..len].to_vec())
}
(This code comes from the sbe-feature-tour sample crate.)
Option B, manual impl — concrete signatures, your own conversion logic
with_domain_type(selector, path) is the common case: ergo-sbe also generates
the TryFromSbe/TryToSbe impl for bool / rust_decimal::Decimal /
chrono::DateTime<Utc>. If you need different conversion behaviour for one of
those exact three types — a custom rounding rule, stricter validation,
different null handling — call additive with_manual_domain_type
instead: same generated price(...)? / price()? signatures, but you
write the impl:
let (_schema, src) = generate_domain_with(&path, "manual_impl_dt", |c| {
c.with_manual_domain_type(
ergo_sbe::ConversionSelector::named_type("Decimal"),
"rust_decimal::Decimal",
)
});
use rust_decimal::Decimal;
// Caller-supplied impl: deliberately scales mantissa by 10 on the way
// in/out, so a value only round-trips correctly through THIS impl —
// proof ergo-sbe didn't quietly generate its own.
impl TryFromSbe<self::Decimal> for rust_decimal::Decimal {
type Error = &'static str;
fn try_from_sbe(wire: self::Decimal) -> Result<Self, Self::Error> {
Ok(rust_decimal::Decimal::new(wire.mantissa() / 10, (-wire.exponent()) as u32))
}
}
impl TryToSbe<self::Decimal> for rust_decimal::Decimal {
type Error = &'static str;
fn try_to_sbe(&self) -> Result<self::Decimal, Self::Error> {
Ok(self::Decimal::new(self.mantissa() as i64 * 10, -(self.scale() as i8)))
}
}
let mut buf = [0u8; 256];
let mut enc = OrderEncoder::wrap_and_apply_header(&mut buf, 0)
.fixed(&OrderFixedFields { price: self::Decimal::new(0, 0), size: self::Decimal::new(0, 0) });
enc.price(Decimal::new(12345, 2))?;
enc.size(Decimal::new(100, 0))?;
let encoded = enc.as_bytes_with_header().to_vec();
let dec = OrderDecoder::try_decode(&encoded, 0)?;
assert_eq!(dec.price()?, Decimal::new(12345, 2));
assert_eq!(dec.size()?, Decimal::new(100, 0));
(From domain_type_manual_impl_uses_callers_own_impl in
sbe/tests/baseline_test.rs — a real generated-and-compiled test. Any
rust_type string that isn’t one of the three built-ins never gets an
auto-generated impl regardless of DomainImpl — it only matters for opting
those three in or out.)
Forgot the impl? Two things soften it. First, the compile error names the missing impl directly instead of the default trait-bound message:
error[E0277]: `rust_decimal::Decimal` has no `TryFromSbe<Decimal>` impl
|
| missing `impl TryFromSbe<Decimal> for rust_decimal::Decimal`
|
= note: if this field uses DomainImpl::Manual, the generated domain
accessor's doc comment has a ready-to-paste starting point
Second, for the three built-ins, that pointer is real: the generated
price method’s own doc comment (visible on hover, or in cargo doc)
carries the exact impl DomainImpl::Generated would have written — copy it
out and adjust. sbe/tests/baseline_test.rs’s
domain_type_manual_impl_doc_comment_has_generated_snippet asserts this
snippet is present in the generated source.
What your domain type must implement
Only Debug. The generated Display/Debug bodies format domain values with
{:?}, so a plain #[derive(Debug)] struct works — Display is never
required. Note that TryFromSbe::Error / TryToSbe::Error is a separate
associated type and does need Debug + Display; &'static str satisfies it.
Option C — generated mappings for enums and composites
When the application type mirrors the SBE type — an enum with the same
variants, a struct with the same members — with_enum_mapping and
with_composite_mapping generate the TryFromSbe / TryToSbe impls
(exhaustive, with renames) and register the domain type in one call. The
accessors look exactly like Option B’s. See
App Types on Composites.