rubo4e
Rust implementation of BO4E — Geschäftsobjekte für die Energiewirtschaft, the object model the German energy industry uses to exchange contracts, metering points, invoices, and the parties involved.
rubo4e generates the full object model from the official JSON Schema, then adds
what the schema cannot express: market identifiers that verify their own BDEW
check digits, enums you can parse strictly at an ingest boundary, and JSON that
stays byte-compatible with the Python, Go, and .NET implementations.
Independent implementation. Not affiliated with or endorsed by the BO4E project or BDEW; the reference implementation is BO4E-python.
Features
- Generated types from the official BO4E JSON Schema (v202607)
- Strong domain identifiers — the complete BDEW identifier family (
MaloId,MeloId,NeloId,NebeId,CrId,SgId,SrId,TrId,PaketId,EicCode,ObisCode,MarktpartnerId, …) with spec-accurate check digits and domain helpers - Three-layer validation — constructor checks,
gardestruct rules, cross-field business logic - Strict enum parsing & introspection —
from_wire(reject out-of-schema values),VARIANTS/COUNT/iter_known,Display/AsRef<str>,is_unknown, unified by theBo4eEnumtrait — all without thestrumfeature - Recursive strict decoding —
Bo4eStrict::ensure_known_enums()rejects anyUnknownenum value anywhere in a deserialized payload, with JSON-paths — one call replaces hand-written per-field checks - Typed builders — readable, diffable construction via
typed-builder; setters accept bothTandOption<T>(note: BO4E BO fields are schema-optional, so AHB-mandatory contracts are enforced by your ingest layer, not the type system) - German / snake_case / canonical JSON — BO4E wire format out of the box
- Ergonomic convenience API — extension traits, billing-period helpers, EDIFACT agency codes
- JSON Schema via
schemars, OpenAPI viautoipa, PostgreSQL viasqlx - Golden corpus and fuzz harnesses included; proptest round-trip tests run as dev tests
Installation
That gives you the identifier types only. Add the features you need:
Quick Start
use *; // identifiers, BetragExt, MengeExt, PreisExt, Bo4eJsonExt
use ;
Feature Gates
| Feature | Default | Description |
|---|---|---|
identifiers |
✓ | Identifier types (MaloId, EicCode, ObisCode, …) + serde — zero schema overhead |
serde |
✓ | Serde derives + extension-data map |
json |
serde_json helpers (to_json_german(), …) |
|
simd-json |
SIMD-accelerated JSON parsing backend | |
time |
time crate — Date for date fields, OffsetDateTime for timestamps |
|
decimal |
rust_decimal::Decimal for amounts and prices |
|
builder |
typed-builder derives on all BO/COM structs |
|
validate |
garde validation — constructor + cross-field rules |
|
schemars |
JSON Schema generation with patterns and examples | |
sqlx |
Type/Encode/Decode/PgHasArrayType for every identifier and every enum (PostgreSQL) |
|
utoipa |
ToSchema with pattern/example/description for OpenAPI |
|
strum |
Enum iteration and string conversion | |
versioned |
Versioned schema modules (v202607, current) |
|
tracing |
Structured diagnostics via the tracing crate |
|
metrics |
Counter export hooks (metrics ecosystem) |
Typical full setup:
Schema Versions
| Module | Schema tag | Status |
|---|---|---|
v202607 |
v202607.0.0 | Current stable |
use Marktlokation; // pin to v202607
use Marktlokation; // always the latest stable — advances with crate updates
Versioning contract. rubo4e::current re-exports the newest stable schema
series. A minor rubo4e bump can therefore change enum membership or codelist
coverage under current (a new variant, a new code) without a source change on
your side. Pin to the version module (rubo4e::v202607::…) for anything whose
shape you guard (SQL CHECK lists, exhaustive mappings, variant-count
assertions); use current for code that should always track the latest series.
Every release that changes schema-derived membership records it in the
CHANGELOG Schema deltas section. See
Schema Versioning for the full contract.
Enum Introspection & Strict Parsing
Every generated BO4E enum carries an Unknown forward-compatibility catch-all,
so the lenient serde / FromStr path never fails on an unrecognized wire value
— it maps to Unknown. That is the right default for forward-compatibility, but
the wrong default at an ingest boundary that must reject typos, legacy codes, or
values from a newer schema. Every enum therefore also exposes a uniform,
strum-free surface (also unified by the Bo4eEnum trait for generic use):
use ;
// Introspection without `strum` — drift-guard SQL CHECK lists & mappings:
assert_eq!;
for v in iter_known
// Strict parsing at the boundary — Err instead of a silent Unknown:
assert_eq!;
assert!; // legacy/typo rejected
assert!; // catch-all is not a real value
// Detect lenient-decode fall-through after a serde round-trip, in one call:
let z: Zaehlertyp = from_value.unwrap;
assert!;
| Member | Feature | Purpose |
|---|---|---|
T::VARIANTS |
none | &'static [T] of known variants (excludes Unknown) |
T::COUNT |
none | stable per-version variant count |
T::iter_known() |
none | iterator over known variants |
T::as_wire(&self) |
none | canonical BO4E wire string |
T::from_wire(s) |
none | strict parse → Result<T, UnknownVariant> |
T::is_known / is_unknown |
none | detect the Unknown catch-all |
Display, AsRef<str> |
none | canonical wire string — now available without strum |
Bo4eEnum trait |
versioned |
the above, generic over the enum type |
Display,AsRef<str>,as_wire,from_wire,VARIANTS,COUNT, anditer_knownare all feature-independent. Thestrumfeature now only addsFromStr,EnumIter, andInto<&'static str>.
Strict decoding of whole payloads (Bo4eStrict)
Per-enum from_wire is strict at the field level. But the common pattern is a
lenient whole-object decode (serde_json::from_value::<Rechnung>()) used as a
schema gate — and that decode silently turns every unrecognized enum value into
Unknown, anywhere in the tree. Bo4eStrict closes that gap: one call finds
every out-of-schema enum value in a nested value and reports its JSON-path.
use ;
let nelo: Netzlokation = from_value?; // lenient decode (never fails on enums)
nelo.ensure_known_enums?; // Err lists e.g. ["zaehler[1].zaehlertyp"]
ensure_known_enums() returns StrictError with the dotted, index-bracketed
paths of every Unknown enum value; unknown_enum_paths() returns them directly.
Implemented for every BO, COM, enum, and AnyBo. This replaces the hand-written
record.field == T::Unknown re-checks a strict ingest boundary would otherwise
need. Unlike Bo4eObject/Bo4eEnum, Bo4eStrict is not sealed, so you can
implement it on your own domain wrappers to extend the recursive check.
Identifiers
All domain identifiers validate their format at construction time. There are no panicking constructors.
| Type | Format / Rule |
|---|---|
MaloId |
11 digits, first 1–9, BDEW §8.1 check digit — Marktlokation / Tranche |
NeloId |
Codetyp 'E' + 9 [A-Z0-9] + §8.2 check digit — Netzlokation (BK6-22-128) |
NebeId |
Codetyp 'F' + 9 [A-Z0-9] + §8.2 check digit — Netzbereich (BK6-22-300) |
CrId |
Codetyp 'A' + 9 [A-Z0-9] + §8.2 check digit — Cluster Ressource |
SgId |
Codetyp 'B' + 9 [A-Z0-9] + §8.2 check digit — Steuergruppe |
SrId |
Codetyp 'C' + 9 [A-Z0-9] + §8.2 check digit — Steuerbare Ressource |
TrId |
Codetyp 'D' + 9 [A-Z0-9] + §8.2 check digit — Technische Ressource |
PaketId |
Codetyp 'P9' + 8 [A-Z0-9] + §8.2 check digit — Netzbetreiberwechsel |
MeloId |
33 chars: 2-char ISO country code + 31 alphanumeric |
EicCode |
16-char EIC with ENTSO-E check character and object type |
BilanzkreisId |
16-char EIC restricted to object type 'X' (Party) — Bilanzkreis, MaBiS / GaBi Gas |
BilanzierungsgebietId |
16-char EIC restricted to object type 'Y' (Area) — Bilanzierungsgebiet, MaBiS |
ObisCode |
[A-B:]C.D[.E][*F], value groups are octets; C=0 permitted (IEC 62056-61 general metering group) |
MarktpartnerId |
13 decimal digits — BDEW (99), DVGW (98), or GS1 GLN; check digit opt-in |
AkivId |
1–36 printable ASCII chars — Aktivierungsidentifikator Redispatch 2.0 (BK6-24-174) |
TranchennummerId |
1–6 decimal digits, no leading zeros — MABIS Bilanzkreisabrechnung (PID 13003) |
Section numbers refer to the BDEW Anwendungshilfe "Identifikatoren in der
Marktkommunikation" v1.2 (7 February 2025). Chapter 8 defines a single
check-digit arithmetic — sum the mapped values at odd positions, add twice the sum
at even positions, take the difference to the next multiple of 10 — in two flavours:
§8.1 for numeric IDs and §8.2 (the "ASCII-Verfahren", where A–Z map to their
ASCII codes) for alphanumeric ones. Both are implemented once and pinned to the
worked examples printed in the specification.
// Build from base — the check digit is computed, never typed by hand.
let malo = from_base?; // → "41373559241" (BDEW §8.1 example)
let c = check_digit?; // → 1u8
assert_eq!;
// Every §8.2 identifier shares the same API and enforces its own Codetyp.
let nelo = from_base?; // → "E0000000019"
let tr = from_base?; // → "D0000000010"
let paket = from_base?; // → "P9000000010"
assert!; // Codetyp mismatch — that is a TrId
// Country code extraction (MeloId)
let melo = new?;
assert_eq!;
assert!;
// EDIFACT agency codes (MarktpartnerId) — eliminates duplicate mapping tables
let mp = new?;
assert_eq!;
assert_eq!; // EDIFACT NAD DE3055
assert_eq!; // EDIFACT UNB DE0007
// MP-IDs carry either a BDEW (§8.1) or a GS1/EAN-13 check digit; opt in explicitly.
assert!;
assert!;
// Integer round-trip for legacy systems
assert_eq!;
// Serde as integer (opt-in, field-level)
pub partner_id: MarktpartnerId,
EIC codes and object types
Position 3 of an EIC carries the ENTSO-E object type, and the German market
leans on it: a Bilanzkreis is a market party (11X…), while a Bilanzierungsgebiet
is an area (11Y…). EicType exposes all seven types, and the two restricted
newtypes make the roles unswappable at a call site.
use ;
let area = new?; // TenneT control area
assert_eq!; // 'Y'
let party = new?; // a Bilanzkreis
assert_eq!; // 'X'
// The restricted types pin position 3, so the two cannot be confused.
let bk = new?;
assert!;
let bg = new?;
assert!;
// The check character is derived, never typed by hand.
assert_eq!;
// Widening is infallible; narrowing is checked.
let eic: EicCode = bk.into;
assert!;
OBIS codes (EDIFACT support)
ObisCode parses once at construction and stores a canonical form, so two
spellings of the same code are equal and hash alike. Value groups are single
octets, as IEC 62056-61 specifies.
// Standard OBIS codes
let obis = new?; // active energy total
let obis = new?; // C=0 — general metering group (IEC 62056-61)
// Canonicalisation: `&` becomes `*`, and leading zeros are dropped.
assert_eq!;
assert_eq!;
assert_eq!;
// Value groups are octets — 256 is not an OBIS value.
assert!;
// Components are stored, so this neither re-parses nor allocates.
let parts = new?.components;
assert_eq!;
// PIA item-number form drops the F component.
assert_eq!;
Multi-version Dispatch
When a storage layer (e.g. PostgreSQL JSONB) writes a bo4e_version column alongside
BO4E JSON, the idiomatic dispatch pattern is a plain match:
use ;
This pattern:
- Requires no new rubo4e API —
schema_version()is already on every BO type viaBo4eObject - Is trivially extensible: each new schema version is one
matcharm - Localises migration to the storage layer; business logic only handles the current version
- Avoids over-engineering (
traitobjects,Any*enums) for a straightforward branch
See Schema Versioning for the full upgrade workflow.
Convenience API
Extension traits — flatten Option<Com> to Option<Decimal>
use *; // brings BetragExt, MengeExt, PreisExt into scope
// Before (v0.3 — two levels of unwrap):
let net = pos.gesamtpreis.as_ref.and_then;
// After (v0.4):
let net = pos.gesamtpreis.wert_decimal; // Option<Decimal> via BetragExt
let qty = pos.positions_menge.wert_decimal; // Option<Decimal> via MengeExt
let unit = pos.einzelpreis.wert_decimal; // Option<Decimal> via PreisExt
Billing and validity helpers
use ;
use date;
// Rechnung — closed billing period
if let Some = rechnung.billing_period
// Navigate rechnungsperiode fields directly
let start: = rechnung.period_start;
let end: = rechnung.period_end;
// Iterate line items
for pos in rechnung.positions
// Decimal totals — direct access
let net = rechnung.gesamtnetto_decimal; // Option<Decimal>
let tax = rechnung.gesamtsteuer_decimal; // Option<Decimal>
let gross = rechnung.gesamtbrutto_decimal; // Option<Decimal>
let pay = rechnung.zu_zahlen_decimal; // Option<Decimal> — final amount due
let disc = rechnung.rabatt_netto_decimal; // Option<Decimal> — net discount
let next = rechnung.zukuenftiger_abschlag_decimal; // Option<Decimal>
let adv = rechnung.vorauszahlungen_summe; // Option<Decimal> — sum of advance payments
// Invoice flags — unwrap_or(false), no Option juggling
if rechnung.is_storno
if rechnung.is_original
// Date fields
let due: = rechnung.faelligkeitsdatum_date;
// Rechnungsposition — delivery period from embedded Zeitraum
let von: = pos.lieferung_von_date; // reads lieferungszeitraum.startdatum
let bis: = pos.lieferung_bis_date; // reads lieferungszeitraum.enddatum
let in_period: bool = pos.lieferungszeitraum_contains;
// PreisblattNetznutzung — point-in-time validity check
let valid = preisblatt.is_valid_at;
// Zeitraum — open/closed range helpers
let closed = z.as_closed_range; // Option<(Date, Date)>
let half_open = z.as_half_open_range; // Option<(Date, Option<Date>)>
let contains = z.contains; // bool — [start, end) half-open
JSON Handling
use Bo4eJsonExt;
use Marktlokation;
let malo: Marktlokation = todo!;
// Serialize
let german = malo.to_json_german?; // {"marktlokationsId":"…","sparte":"…",…}
let snake_case = malo.to_json_snake_case?; // {"marktlokations_id":"…","sparte":"…",…}
let canonical = malo.to_json_canonical?; // sorted keys, stable for hashing/signing
// Deserialize
let restored = from_json_german?;
Unknown JSON fields are preserved through round-trips via the _additional
extension-data map (requires json feature). This allows forward-compatible
handling of new BO4E fields without library updates.
The snake_case mapping is an exact table emitted by the code generator, not a
runtime heuristic, so from_json_snake_case(to_json_snake_case(x)) == x holds
for every generated type. That is not achievable algorithmically: BO4E names like
hoechstpreisHT, kundengruppeKA, and Sigmoidparameter's A/B/C/D
render to a snake form a heuristic maps back to a different camelCase name,
which silently diverts the value into _additional. BO4E metadata keys (_typ,
_version, _id) and unknown extension keys pass through byte-for-byte in both
directions. See Serialization.
Parsing untrusted input
Preserving unknown fields is a memory-growth surface, so every deserialization path — hardened or not — caps extension fields at 128 per struct and extension keys at 256 bytes, and rejects documents nested deeper than 128 levels.
For payloads from outside your trust boundary, the _hardened entry points add
four opt-in budgets on top:
use ;
let malo = from_json_german_hardened?;
All four limits are enforced during parsing and at every nesting level —
extension data buried in a nested COM is charged to the same budget as extension
data on the root — so an oversized payload is rejected while it is being read,
not after the object tree has been allocated. Every limit that fires bumps a
process-wide counter readable via json_limit_hit_counters(), exported to the
metrics ecosystem when the metrics feature is on.
See Serialization for the exact scope of each limit.
Validation
use Validate as _;
use Validated;
use Marktlokation;
// Direct validation — returns garde::Report on failure
let malo: Marktlokation = todo!;
malo.validate?;
// Type-safe wrapper — only constructible via validation
let validated = new?; // Err(garde::Report) if invalid
let inner: &Marktlokation = &validated; // Deref to inner type
Cross-field rules (e.g. exactly one of lokationsadresse / geoadresse /
katasterinformation must be set) run automatically via #[garde(custom(...))]
attributes on the generated types.
OpenAPI / JSON Schema
// schemars — JSON Schema (requires `schemars` feature)
let schema = schema_for!;
// utoipa — OpenAPI 3.1 (requires `utoipa` feature)
// All identifier types emit pattern, description, and example values:
// MaloId → { type: string, pattern: "^[0-9]{11}$", example: "51238696781" }
SQLx Integration
// Requires the `sqlx` feature — implements Type, Encode, Decode and
// PgHasArrayType for every identifier and every generated enum.
// No `json` feature needed: everything round-trips through &str.
// Bind directly as a typed identifier
query
.bind
.execute.await?;
// Decode directly — runs the same validation as new()
let id: MaloId = row.try_get?;
// Vec<Id> binds to a TEXT[] column
query
.bind
.fetch_all.await?;
// Works in FromRow structs too
Identifiers reject invalid values on decode. Enums decode leniently —
an out-of-schema string becomes Unknown, mirroring the serde path — so use
from_wire on a String column where that must be an error instead.
Documentation
hupe1980.github.io/rubo4e — guides and design notes. docs.rs/rubo4e — per-item API reference. CHANGELOG — release history and upgrade notes.
| Guide | Covers |
|---|---|
| Architecture | Workspace layout, module tree, feature-gate reference |
| Identifiers | Every identifier type, its validation rules, and the BDEW check-digit procedures |
| Serialization | JSON output modes, extension data, hardened parsing |
| Validation | The three validation layers and Validated<T> |
| Schema Versioning | Version modules, current, and the upgrade workflow |
| Ecosystem | sqlx, schemars, utoipa, strum integrations |
| Code Generator | How generation works and how to re-run it |
| Testing | The seven testing layers and how to run each |
The site sources live in site/ and are built with Zola.
MSRV
The minimum supported Rust version is 1.88, declared as rust-version in
Cargo.toml and verified in CI on every push. MSRV advances only when the
current floor is two stable releases behind, and a bump is a minor version
change, never a patch.
The floor is set by the dependency tree rather than by this crate's own source:
time, simd-json, and home (via sqlx) all require 1.88. Because Cargo's
default resolver picks the newest semver-compatible dependency without regard to
rust-version, a toolchain below the floor fails at resolution time with
rustc 1.87.0 is not supported by the following packages rather than at compile
time. On an older toolchain, either pin those dependencies back with
cargo update <crate> --precise <version> or enable Cargo's MSRV-aware resolver.
License
Licensed under either of Apache License 2.0 or MIT License, at your option.