Why the Wire Model Exists
Every API client has to turn a Rust value into bytes on the way out and bytes back into a Rust value on the way in. Rust already has a superb answer to that problem in Serde, so a new one needs to justify itself. This chapter is that justification — what the wire model is, the two things it does that Serde’s design cannot, and why it is nonetheless optional.
What it is
The wire model is a model-first (de)serialization family. A type describes what
it is on the wire through a derive-emitted &'static Binding — a static table of its
name, fields, tagging, and format knobs — and every codec, value tree, and diagnostic
is a thin consumer of that one description.
It lives in its own workspace (capi_wire) and has no dependency on Capi.
It is a standalone library you could use for anything; a client names it as a
dependency of its own, because the capi_rs facade re-exports the Capi framework
workspace and nothing else. What ties the two together is priority rather than
coupling: the wire model is designed around the needs of API clients first and other
use cases second, so its conveniences are the ones API work keeps demanding.
If you know Serde, most of the surface will feel familiar on purpose — rename,
rename_all, default, flatten, skip, the four enum tagging modes, untagged,
even #[wire(crate = "...")] all mean what their #[serde(...)] counterparts mean.
Some ideas are borrowed from the ecosystem around Serde rather than Serde itself:
the proxy-type conversions and field-level transforms will read as familiar to anyone
who has used serde_with.
The two structural reasons
Familiar surface, different machine. Two properties of Serde’s design are load-bearing for it and disqualifying here.
1. A type cannot describe itself without being instantiated. Serde’s model is
value-driven: Serialize::serialize pushes a value’s fields into a Serializer as it
walks them. There is no way to ask a type what its wire shape is. For a
self-describing format that’s fine — JSON writes names as it goes. For a
non-self-describing format it isn’t: protobuf addresses fields by number, not
name, and a decoder must know the number→field mapping, the wire types, and the
proto2/proto3 presence rules before it has any value in hand. The wire model’s
Binding is exactly that missing description, available as T::BINDING without an
instance. It’s why protobuf is an ordinary format here rather than a separate derive
ecosystem — and the same static description is what lets the test framework compare
fixtures semantically, by reading a type’s shape rather than guessing from bytes.
2. The core trait is not object-safe. fn serialize<S: Serializer>(&self, s: S)
is generic over S, so dyn Serialize is illegal and “a codec chosen at runtime”
needs a crate like erased_serde to bridge the gap. Runtime format selection is the
entire point of a Codec handle, so the wire model defines an object-safe split
instead of papering over a generic one. The result is no erased_serde on the path
and no unsafe anywhere in the family’s own code; the wire workspace itself is
no_std + alloc throughout (of the codecs, only capiw_quick_xml links the
standard library). Codecs and Format Contracts shows the split.
There’s a third, softer reason. Serde has no room for a format to contribute its own
vocabulary — #[serde(...)] is a fixed set. XML needs to say “this field is an
attribute, not an element”; protobuf needs field numbers. The wire model reserves an
extension bag in the binding that any format can write into and read back through
typed helpers, which is how XML, urlencoded, and protobuf each add their own
annotations without the core knowing anything about them. That mechanism gets its own
chapter.
Why not a reflection crate
A reflection library such as Facet attacks the
same first problem — making a type’s shape inspectable — and it is a genuinely
interesting approach. The trade-off is implementation machinery: building a runtime
picture of arbitrary Rust types means raw pointer work, and that means unsafe.
The wire model reaches a comparable place from the other direction. Because a derive
already sees the type at compile time, it can emit the description as a plain
&'static table, and reading that table is ordinary safe Rust. For a library that
handles credentials and request bodies, “no unsafe anywhere” is worth more than the
generality full reflection would buy.
What it adds for API work
Beyond the structural reasons, the wire model ships the conveniences that API integration keeps needing and that are otherwise re-invented per client:
| Convenience | The problem it answers |
|---|---|
| Tri-state fields | PATCH bodies must distinguish unchanged, set, and explicitly null |
| Open string enums | a documented value set that the service will grow later |
| Number laning | APIs that quote numbers as strings, and money that must never be f64 |
| Datetime contracts | epoch vs RFC 3339 vs a bespoke format — where a wrong unit is the costliest bug |
| Sentinel mapping | services that encode absence as "", -1, or 9999-12-31 |
| Value classes and redaction | marking a field secret or non-deterministic, and a typed transform so a request is born without its secrets |
| Constant entries | a "jsonrpc": "2.0" or an @odata.type the wire needs and no Rust field should carry |
Each has a Serde-ecosystem analogue you could assemble by hand. Having them in the model — machine-readable, in the binding — is what lets tooling act on them.
It is not required
The wire model is a convenience layer, and the boundary is worth stating plainly.
At bottom a Capi endpoint is a method, a URL, an optional body implementing
ToBody, and a function over response bytes. None of that mentions the wire model. A
client author who wants to use Serde in their own library can: derive
Serialize/Deserialize, call serde_json directly in the body and decoder, and
never write a #[wire(...)] attribute. Nothing in the framework prevents it. A client
that speaks a binary protocol can skip serialization libraries altogether and hand-roll
bytes — the icecast_connect reference client does exactly that, storing no Codec at
all.
What you give up is everything above: the swappable codec, the format markers that catch a JSON/XML mix-up at compile time, and the conveniences table. The relationship is the same one Serde has with the rest of Rust — technically optional, practically synonymous. The wire model is the default path because it earns the position, not because the framework depends on it. That argument is made in full in the design rationale.
How this part is organized
The next chapter is the one everyone needs; the rest are reference you can read when a type forces the question.
- Codecs and Format Contracts —
Codec<F>, the markers, and the object-safe machine underneath. Read this one. - Describing a Type — the three derives, the
Binding, naming, and presence. - Shaping Structures — wrapping, flattening, proxies, and the tri-state PATCH model.
- Scalars, Numbers, and Money — number lanes, byte encodings, decimals, and value classes.
- Dates, Times, and Durations — the largest attribute family, and the one most worth getting right.
- Enums on the Wire — the four taggings, open string enums, and catch-alls.
- Format Extensions — advanced: how a format contributes its own vocabulary, and how to write one.
- Hand-Written Impls and the Value Tree — when the attributes run out.