rubo4e
Rust implementation of the BO4E energy-market data standard — the canonical data model for the German energy industry.
Not an official BO4E implementation. The reference implementation is BO4E-python. This crate aims for idiomatic Rust ergonomics, strong domain types, and ecosystem integration.
MSRV Policy
The minimum supported Rust version is 1.87, encoded in Cargo.toml as
rust-version = "1.87".
We target stable + 2 releases — MSRV advances when the current floor has been
superseded by two stable releases. MSRV bumps are treated as minor version
changes (not patch). The rust-version field in Cargo.toml is the authoritative
source; the README badge is kept in sync.
Features
- Generated types from the official BO4E JSON Schema (v202607)
- Strong domain identifiers —
MaloId,MeloId,EicCode,ObisCode,MarktpartnerId, … with embedded validation 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
[]
= "0.8"
Enable optional features as needed:
= { = "0.8", = ["versioned", "time", "decimal", "json", "validate"] }
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 for all identifier types (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:
= { = "0.8", = ["versioned", "time", "decimal", "json", "validate", "builder"] }
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
docs/versioning.md 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, BDEW alternating-weight check digit |
NeloId |
11 chars: Codetyp 'E' + 9 [A-Z0-9] + ASCII-Verfahren check digit (BDEW §4.2) |
SrId |
11 chars: Codetyp 'C' + 9 [A-Z0-9] + ASCII-Verfahren check digit (BDEW §6.6) |
TrId |
11 chars: Codetyp 'D' + 9 [A-Z0-9] + ASCII-Verfahren check digit (BDEW §6.6) |
MeloId |
33 chars: 2-char ISO country code + 31 alphanumeric |
EicCode |
16-char EIC with ENTSO-E check character |
BilanzkreisId |
16-char EIC restricted to type 'Z' (Bilanzierungszone) — GaBi Gas / MABIS |
ObisCode |
[A-B:]C.D[.E][*F]; C=0 permitted (IEC 62056-61 general metering group) |
MarktpartnerId |
13 decimal digits — BDEW (prefix 99), DVGW (prefix 98), or GS1 GLN |
AkivId |
1–35 printable ASCII chars — Aktivierungsidentifikator Redispatch 2.0 (BK6-24-174) |
TranchennummerId |
1–6 decimal digits, no leading zeros — MABIS Bilanzkreisabrechnung (PID 13003) |
// Build from base (check digit computed automatically)
let malo = from_base?; // → "51238696780"
let c = check_digit?; // → 0u8
// NeloId / SrId / TrId — same from_base pattern
let nelo = from_base?; // → "E0000000019" (ASCII-Verfahren check)
let sr = from_base?; // → "C0000000003"
let tr = from_base?; // → "D0000000002"
// Country code extraction (MeloId)
let melo = new?;
assert_eq!;
assert!;
// EDIFACT agency codes (MarktpartnerId) — eliminates duplicate mapping tables
let mp = new?;
assert!;
assert_eq!; // EDIFACT NAD DE3055
assert_eq!; // EDIFACT UNB DE0007
// Integer round-trip for legacy systems
assert_eq!;
// Serde as integer (opt-in, field-level)
pub partner_id: MarktpartnerId,
OBIS codes (EDIFACT support)
// Standard OBIS codes
let obis = new?; // active energy total
let obis = new?; // C=0 — general metering group (IEC 62056-61)
// F separator normalisation — & is accepted and stored as *
assert_eq!;
// Structured accessors
assert_eq!; // F stripped
assert_eq!; // F kept
Multi-version Dispatch (F4)
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 docs/versioning.md 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.
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: "51238696780" }
SQLx Integration
// Requires `sqlx` feature — implements Type, Encode, Decode for all identifiers
// Bind directly as typed identifier
query
.bind
.execute.await?;
// Decode directly — runs the same validation as new()
let id: MaloId = row.try_get?;
// Works in query_as! structs too
Documentation
- docs/architecture.md — Workspace layout, module tree, feature gate reference
- docs/generator.md — Internal code generator — running it, pipeline, inference rules
- docs/identifiers.md — All identifier types, validation rules, algorithms
- docs/validation.md — Cross-field business rules and
Validated<T> - docs/versioning.md — Schema versioning scheme and upgrade workflow
- docs/serialization.md — JSON format variants, extension-data map, round-trip guarantees
- docs/testing.md — Golden corpus, fuzz targets, proptest strategies
License
Licensed under either of Apache License 2.0 or MIT License, at your option.