Code Generator
How the internal generator turns pinned BO4E JSON Schema releases into Rust source, including type inference, identifier naming, and the drift checks that keep output honest.
The generator (generator/) is a standalone Rust binary that reads pinned BO4E JSON Schema files and emits Rust source code into src/generated/. It is a workspace member but is never published to crates.io.
Its output is committed to the repository, so nothing at build time forces it to match the schemas β the drift guards do that instead.
πRunning the Generator
just generate # generator + cargo fmt, the supported entry pointor, directly:
cargo run -p bo4e-generator -- --schema-version v202607.1.0
cargo fmt --all # the generator emits prettyplease outputThe generator reads schema files from generator/schemas/<TAG>/ relative to the workspace root and writes output to src/generated/<series>/ where <series> is the vYYYYMM prefix (e.g. v202607).
πUpdating to a New Schema Version
When BO4E releases a new schema:
- Download the schema tag into
generator/schemas/<NEW_TAG>/:# Uses scripts/download_schemas.sh which pulls the release archive from GitHub just download-schemas v202701.0.0 - Retire the previous snapshot of the same series. Exactly one snapshot per series is committed; the test suite reads the pinned tag off that directory rather than from a constant, and fails if it finds two.
git rm -r generator/schemas/v202607.1.0 # only when staying in the series - Run the generator for the new tag:
cargo run -p bo4e-generator -- --schema-version v202701.0.0 && cargo fmt --all - Inspect the diff in
src/generated/:- Added fields β inspect; update the convenience wrappers if needed
- Removed fields and removed types β check nothing in
src/convenience.rsorsrc/validation/still names them. The generator deletes the module of a type the release retired, so a stale reference is a compile error, not a silently orphaned file. - New identifier-bearing fields β add them to
generator/src/inference.rs - Enum membership β record additions and removals in the CHANGELOG's Schema deltas section; the removals are the half that breaks a downstream build
- Point
currentat the new series insrc/lib.rsif it supersedes the old one. - Run
just ci. The drift guards below fail loudly if anything is stale. - Commit both the new schema directory and the regenerated source.
There is no version constant to hand-edit. _typ and _version are read out of the schema files themselves (see Metadata comes from the schema), so a new release carries its own values through with no manual step.
πSchema Directory Layout
The generator expects schemas in the layout used by bo4e/BO4E-Schemas:
generator/schemas/v202607.1.0/
βββ bo/
β βββ Vertrag.json
β βββ Marktlokation.json
β βββ ...
βββ com/
β βββ Adresse.json
β βββ ...
βββ enum/
β βββ Sparte.json
β βββ ...
βββ ZusatzAttribut.json β root-level COM (no category subdirectory)πInternal Architecture
πPipeline
JSON Schema files
β
βΌ
parser.rs β serde_json β SchemaNode AST
β
βΌ
inference.rs β (struct, field) β domain identifier type
β
βΌ
naming.rs β BO4E wire values β Rust identifiers
β
βΌ
emitter.rs β SchemaNode AST β formatted Rust source
β
βΌ
src/generated/πSchemaNode AST
A BO and a COM are structurally identical β they differ only in which discriminant enum their _typ draws from and whether they implement Bo4eObject β so the parser produces one node type for both:
enum SchemaNode {
Struct(StructNode),
Enum(EnumNode),
}
struct StructNode {
name: String,
kind: StructKind, // Bo | Com
fields: Vec<Field>,
description: Option<String>,
typ_const: Option<String>, // the `_typ` wire value the schema pins
version_default: Option<String>,// the `_version` wire value the schema declares
}
struct Field {
name: String, // camelCase BO4E wire name
rust_name: String, // snake_case Rust name
is_optional: bool,
field_type: FieldType, // after semantic inference
description: Option<String>,
}π$ref Resolution
All $ref references are resolved within the same schema snapshot β the generator never makes network requests. A $ref to bo/ becomes FieldType::Bo, to enum/ becomes FieldType::BoEnum, and anything else a FieldType::Com. A field the generator cannot resolve to a concrete shape (an anyOf with more than one non-null branch, a bare object) falls back to serde_json::Value.
πMetadata comes from the schema
_typ and _version are read out of each schema file rather than derived from the type name or the release tag:
| Field | Source | Example |
|---|---|---|
_typ | the property's const, falling back to its default | "MARKTLOKATION" |
_version | the property's default | "202607.1.0" |
Note that _version is not the release tag: the tag is v202607.1.0, the wire value 202607.1.0.
The BoTyp / ComTyp variant a discriminant maps to is likewise looked up from the struct that discriminant names, so "AUFABSCHLAG" yields ComTyp::AufAbschlag, not the Aufabschlag a mechanical case conversion gives.
πIdentifier naming (naming.rs)
BO4E enum values are SCREAMING_SNAKE_CASE. screaming_to_camel splits each value into runs of letters and runs of digits, title-cases the letter runs, and keeps the separator at a digit-to-digit boundary β the one place where dropping it merges two distinct values (MESSPREIS_G2_5 is meter size G 2.5, MESSPREIS_G25 is G 25):
LEISTUNG_PAUSCHAL β LeistungPauschal
G2KOMMA5 β G2Komma5
MESSPREIS_G2_5 β MesspreisG2_5
MESSPREIS_G25 β MesspreisG25Enums that keep an underscore carry #[allow(non_camel_case_types)]. Two values that would collapse onto one identifier fail generation, naming both.
πSemantic Field Typing
BO4E declares every identifier as a bare string. generator/src/inference.rs is what turns Marktlokation.marktlokationsId into a MaloId that verifies its own BDEW check digit. Four rules govern the table:
- Keyed on
(struct, field)β never a bare name, never a suffix. BO4E reuses names, and typing the wrong one takes the whole enclosing object down: aGeschaeftspartnerwhosekontaktwertwill not parse loses its name, address, and VAT ID with it. - The schema wins. The table is consulted only for properties typed as a plain, unannotated
"string". A$ref, a"format", or"type": "number"is authoritative β a Rust type narrower than the schema cannot read what the rest of the ecosystem emits. - Type only what the schema names β "EIC-Nummer", "OBIS-Kennzahl", "Codenummer des Netzbetreibers" β not a field the schema calls merely a code. A missing newtype costs the caller one
EicCode::try_from(&s). - Weigh the blast radius even when rule 3 is satisfied. How much is lost when the newtype rejects a value depends on what encloses the field, and on how often a legitimate payload carries something the type will refuse.
Under rule 1, these pairs share a name and differ in meaning:
| Typed | Left as String |
|---|---|
Marktlokation.marktgebiet β "Code vom EIC" | MarktgebietInfo.marktgebiet β "Der Name des Marktgebietes" |
Marktlokation.regelzone β "Code vom EIC" | StandorteigenschaftenStrom.regelzone β "Der Name der Regelzone" |
Where BO4E splits a thing into a name and a code they are separate properties, and only the code half is typed: StandorteigenschaftenStrom.regelzoneEic is an EicCode, .regelzone is not.
A reference field is typed on the same rule, and only where the schema names which object it points at. TechnischeRessource.zugeordneteSteuerbareRessourceId is "Referenz auf die der technischen Ressource zugeordneten Steuerbaren Ressource" β one object, one format β so it is an SrId. Vertragsteil.lokation is "Der Identifier fΓΌr diejenigen Markt- oder Messlokation" β two formats behind one field β so it is a String, and no newtype could read it.
Under rule 2, Rechnungsposition.einzelpreis is Preis ($ref), Betrag.wert is Decimal ("type": "number"), and Rechnung.rechnungsdatum is OffsetDateTime ("format": "date-time") β none need an entry.
Under rule 3, MarktgebietInfo.marktgebietcode ("Die standardisierte Codenummer" β standardised by whom is not stated) and Fremdkostenposition.marktpartnercode ("Die Codenummer (z.B. BDEW-Codenummer)" β the "z.B." admits other families) stay String.
Under rule 4, two fields the schema does name stay String anyway:
Zahlungsinformation.iban/.bicβ named outright ("Eine IBAN-Nummer"), andIbanexists and verifies its MOD-97 check digits. ButZahlungsinformationhangs offRechnungand nothing else, so a masked IBAN βDE89 **** **** 3000, routine on an invoice β would destroy the whole invoice.iban_checked()runs the check on demand instead.Bilanzierung.bilanzkreisβ kept as the generalEicCoderather than the tighterBilanzkreisId. A German electricity Bilanzkreis is a party code (11Xβ¦), but the same field carries gas Bilanzkreise whose object type is not established here; narrowing it would turn an unverified assumption into a hard deserialization failure. Callers opt in viaBilanzkreisId::try_from(eic).Lokationszuordnung.lokationsbuendelcodeand the fivelokationsbuendelObjektcodefields β named outright ("Code, der angibt wie die LokationsbΓΌndelstruktur zusammengesetzt ist"), andLokationsbuendelcodeexists and verifies its Β§8.1 check digit. But the object code sits onMarktlokation,Messlokation,Netzlokation,SteuerbareRessourceandTechnischeRessourceβ five of the most-carried BOs in the model β so one mistyped code would fail the deserialization of the whole location, id and Netzbetreiber and all.lokationsbuendel_objektcode()andobjektrolle()run the check on demand instead, the same shapeiban_checked()takes.
πWhat is typed today
| Rust type | Fields |
|---|---|
MaloId | Marktlokation, Bilanzierung, Ausschreibungsdetail β marktlokationsId |
MeloId / NeloId | Messlokation.messlokationsId, Netzlokation.netzlokationsId |
SrId / TrId | SteuerbareRessource.steuerbareRessourceId, TechnischeRessource.technischeRessourceId, TechnischeRessource.zugeordneteSteuerbareRessourceId |
MaloId / MeloId (references) | TechnischeRessource.zugeordneteMarktlokationId, TechnischeRessource.vorgelagerteMesslokationId |
EicCode | Marktlokation.marktgebiet, Marktlokation.regelzone, Bilanzierung.bilanzkreis, StandorteigenschaftenStrom.regelzoneEic, Fremdkostenposition.gebietcodeEic |
BilanzierungsgebietId | StandorteigenschaftenStrom.bilanzierungsgebietEic |
MarktpartnerId | Marktteilnehmer.rollencodenummer and the six *Codenr / *Codenummer fields |
ObisCode | Energiemenge, Lastgang, Zaehlwerk β obisKennzahl; Netzlokation.obiskennzahl (upstream spells it with a lower-case k) |
And, under rule 4, what is deliberately not: Zahlungsinformation.iban / .bic, Bilanzierung.bilanzkreis (kept broad), and the six LokationsbΓΌndel code fields.
πAdding an entry
Add the (struct, field) pair to FIELD_TYPES in generator/src/inference.rs, then implement the newtype in src/identifiers/ and re-export it from identifiers/mod.rs. See Identifiers.
Two generator tests keep the table honest: every entry must name a property that exists and that the schema declares as a plain string, and the homonyms above must stay untyped.
Before adding one, ask rule 4's question: what else is lost when this newtype refuses a value, and how often will a legitimate payload carry one it refuses? If the answer is "a whole invoice" and "routinely", ship the type and an *_checked() accessor instead of an entry.
πDeterminism Guarantee
Running the generator twice on the same input produces byte-identical output:
- Schema files are read in sorted path order
- Struct fields are sorted by Rust name; enum variants keep schema declaration order
- The wire-key map is emitted from a
BTreeMap - Every file is formatted through
prettypleasebefore it is written - Files are written only when their content actually changed
- Files this run did not emit are deleted, so the output directory mirrors the schema snapshot exactly. A type BO4E retires leaves nothing behind: an orphan module would be unreferenced by
mod.rs, compiled nowhere, and would pass every drift check while looking exactly like a live type.
πDrift guards
Because src/generated/ is committed, it can go stale. What is checked:
| Guard | Where | Catches |
|---|---|---|
| Regenerate-and-diff | just check-docs-drift | any stale generated file |
| Schema β module coverage | tests/generated_contract.rs | a schema with no module, or a module with no schema |
_typ / _version stamping | tests/generated_contract.rs | metadata that disagrees with the schema |
| Variant injectivity | tests/generated_contract.rs | two wire values collapsing onto one Rust variant |
| Key-map completeness | src/json/key_transform.rs tests | a property that does not survive a snake_case round-trip |
| Known-field-key table | src/json/key_transform.rs tests | an unsorted or incomplete KNOWN_FIELD_KEYS, which would scope the key transform wrongly |
| Extension round-trip | tests/extension_round_trip.rs | the key transform renaming keys inside somebody else's JSON |
| Prelude completeness | tests/prelude_surface.rs | an identifier type the prelude forgot to re-export |
sqlx impl coverage | tests/prelude_surface.rs | an identifier missing from impl_sqlx_text!, which compiles fine and simply cannot be a column |
Borrow<str> contract | tests/prelude_surface.rs | an identifier whose Hash / Ord disagrees with the string it borrows as, so a map lookup by &str silently misses |
| Field-typing table | generator/tests/round_trip.rs | a dead entry, or one overriding a type the schema states |
| Emitter snapshot | generator/tests/round_trip.rs | any change to emitted shape, as a reviewable diff |
CI runs all of them.
πWhat the Generator Does NOT Do
- It does not generate identifier newtype implementations (those are hand-written in
src/identifiers/) - It does not generate validation logic (that lives in
src/validation/) - It does not generate the convenience methods (those live in
src/convenience.rs) - It does not make network requests
- It does not modify any file outside
src/generated/