Skip to content
raoh-projectPublic

About

Java decoder library for turning untyped boundary input into typed domain values

Resources

Contributing

Security policy

Stars

38 stars

Watchers

0 watching

Forks

Repository files navigation

Raoh

Maven Central Javadoc License Java 25

Raoh is a Java decoder library for turning untyped boundary input into typed domain values.

raoh logo

It is built around a parse-don't-validate approach:

  • decode at the boundary
  • keep invalid states out of the domain model
  • return failures as values instead of throwing
  • attach structured errors to precise paths

Raoh is closer to a parser/decoder library than to a traditional bean validation library.

If you are coming from a validator-oriented library, the main difference in feel is this:

  • you do not validate an already-constructed domain object
  • you decode raw input into a domain object
  • object construction happens only after decoding succeeds

Tutorials

Requirements

  • Java 25 (an LTS release). Raoh targets a modern Java LTS baseline deliberately: the API relies on records, sealed types, pattern matching, and JSpecify type-use nullness, so it does not attempt to support older baselines.

Installation

Raoh is published to Maven Central under the net.unit8.raoh group ID. Add the core module, plus whichever boundary module matches your input source. Define the version once as a property — use the latest shown on the Maven Central badge above.

<properties>
    <!-- Use the latest version from the Maven Central badge above -->
    <raoh.version>0.6.0</raoh.version>
</properties>

<dependencies>
    <!-- Core: decoders, encoders, error model -->
    <dependency>
        <groupId>net.unit8.raoh</groupId>
        <artifactId>raoh</artifactId>
        <version>${raoh.version}</version>
    </dependency>

    <!-- Optional: decode Jackson JsonNode -->
    <dependency>
        <groupId>net.unit8.raoh</groupId>
        <artifactId>raoh-json</artifactId>
        <version>${raoh.version}</version>
    </dependency>
    <!-- raoh-json scopes Jackson as 'provided', so add Jackson 3 yourself.
         Note the groupId is tools.jackson.core (Jackson 3), not com.fasterxml.jackson (Jackson 2). -->
    <dependency>
        <groupId>tools.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>3.1.0</version>
    </dependency>

    <!-- Optional: decode jOOQ Record (jOOQ is a provided dependency) -->
    <dependency>
        <groupId>net.unit8.raoh</groupId>
        <artifactId>raoh-jooq</artifactId>
        <version>${raoh.version}</version>
    </dependency>
</dependencies>

Building from source

  • Java 25
  • Maven
mvn clean test

mvn verify additionally runs JaCoCo and writes a per-module coverage report to target/site/jacoco/ (reporting only — no thresholds are enforced). CI publishes the same reports as a job-summary table and a downloadable artifact.

Package Layout

Core (raoh)

  • net.unit8.raoh: core abstractions and error model
  • net.unit8.raoh.decode: decoder core (Decoder, Decoders, ObjectDecoders, FieldDecoder)
  • net.unit8.raoh.decode.builtin: built-in primitive and collection decoders
  • net.unit8.raoh.decode.combinator: applicative combinator internals
  • net.unit8.raoh.decode.map: decoders for Map<String, Object>
  • net.unit8.raoh.encode: encoder core (Encoder, ObjectEncoders, MapEncoders)

JSON extension (raoh-json)

  • net.unit8.raoh.json: decoders for Jackson JsonNode

jOOQ extension (raoh-jooq)

  • net.unit8.raoh.jooq: decoders for jOOQ Record

Domain Construction Guard (raoh-gsh, raoh-gsh-weaver, raoh-gsh-maven-plugin)

Test/CI-time guard that detects accidental new construction of domain objects outside of Decoder.decode().

  • raoh-gsh: runtime — DomainConstructionScope, DomainConstructionGuardException
  • raoh-gsh-weaver: bytecode weaver (ClassFile API), Java Agent, CLI
  • raoh-gsh-maven-plugin: Maven plugin for build-time weaving

Stability: the raoh-gsh* modules are experimental / incubating. Their bytecode-weaving surface is built on the evolving JDK ClassFile API and may change outside the core library's SemVer promise; treat them as separate from the stability guarantees of raoh / raoh-json / raoh-jooq.

See raoh-gsh README for usage.

Core Model

Result<T>

Decoding returns a value instead of throwing:

  • Ok<T> for success
  • Err<T> for failure

Result<T> supports:

  • map(...)
  • flatMap(...)
  • fold(...)
  • orElseThrow(...)

Issue, Issues, and Path

Each error includes:

  • path
  • code
  • message
  • meta

Paths are written as RFC 6901 JSON Pointers, for example:

  • /email
  • /address/city
  • /items/0/name
  • /a~1b for a field named a/b (~ is written ~0, / is written ~1)

Issues can be merged, rebased, flattened, formatted, or converted to JSON-like data.

Decoder<I, T>

The core abstraction is:

public interface Decoder<I, T> {
    Result<T> decode(I in, Path path);
}

A decoder reads an input value of type I and produces either:

  • a typed value T
  • structured issues

Two boundary implementations are included:

  • net.unit8.raoh.json.JsonDecoders
  • net.unit8.raoh.decode.map.MapDecoders

What It Feels Like

The normal Raoh workflow looks like this:

  1. Start from raw input such as JSON or Map<String, Object>.
  2. Define small decoders for domain primitives such as Email, Age, or UserId.
  3. Combine them into object decoders.
  4. If decoding succeeds, you get a fully-typed value.
  5. If decoding fails, you get structured issues with paths.

That means the "happy path" looks like object construction, while the failure path looks like machine-readable diagnostics.

Quick Start

Decode JSON into a domain object

import tools.jackson.databind.JsonNode;

import net.unit8.raoh.decode.Decoder;

import static net.unit8.raoh.json.JsonDecoders.*;

record Email(String value) {}
record Age(int value) {}
record User(Email email, Age age) {}

Decoder<JsonNode, Email> email() {
    return string().trim().toLowerCase().email().map(Email::new);
}

Decoder<JsonNode, Age> age() {
    return int_().range(0, 150).map(Age::new);
}

Decoder<JsonNode, User> user() {
    return combine(
            field("email", email()),
            field("age", age())
    ).map(User::new);
}

Use it like this:

Result<User> result = user().decode(readTree(json));

readTree reads the JSON text into the tree the decoders are specified on. It keeps each number as written (the scale of 0.0001, every digit, the sign of -0) and refuses a member name that occurs twice; a tree from Jackson's ObjectMapper has already turned each number with a fraction into a double.

Success case:

switch (result) {
    case Ok<User>(var user) -> {
        // user is already typed and normalized
        // for example: email lowercased, age range-checked
    }
    case Err<User>(var issues) -> {
        // inspect issues
    }
}

Example failure shape:

{
  "path": "/email",
  "code": "invalid_format",
  "message": "not a valid email",
  "meta": {}
}

Decode a Map<String, Object>

import java.util.Map;

import net.unit8.raoh.decode.Decoder;

import static net.unit8.raoh.decode.map.MapDecoders.*;

record Config(String host, int port) {}

Decoder<Map<String, Object>, Config> config() {
    return combine(
            field("host", string().nonBlank()),
            field("port", int_().range(1, 65535))
    ).map(Config::new);
}

This is useful when the input is already materialized by another layer, for example:

  • form data converted into a map
  • deserialized YAML or TOML
  • database-like key/value rows
  • framework-specific request objects transformed into Map<String, Object>

Built-in Decoders

Raoh includes the following built-in decoders in net.unit8.raoh.decode.builtin.

Value decoders:

  • StringDecoder
  • IntDecoder
  • LongDecoder
  • DoubleDecoder
  • FloatDecoder
  • BoolDecoder
  • DecimalDecoder

Collection/value-container decoders:

  • ListDecoder
  • RecordDecoder

Other:

  • ObjectDecoders.bytes() — decodes byte[] (for JDBC binary columns such as BYTEA/VARBINARY)

String Capabilities

StringDecoder supports:

  • nonBlank()
  • minLength(...)
  • maxLength(...)
  • fixedLength(...)
  • pattern(...)
  • startsWith(...)
  • endsWith(...)
  • includes(...)
  • oneOf(...)
  • email()
  • url() — an http/https URI accepted by uri(), with a non-empty RFC 3986 host; returns URI
  • ipv4()
  • ipv6()
  • ip()
  • cuid()
  • ulid()
  • trim()
  • toLowerCase()
  • toUpperCase()
  • normalize(...) — Unicode normalization, NFC by default
  • uuid()
  • uri() — an RFC 3986 URI of any scheme that java.net.URI can hold (see its Javadoc); returns URI
  • iso8601()
  • date()
  • time()
  • dateTime()
  • offsetDateTime()
  • toInt()
  • toLong()
  • toDecimal()
  • toBool()
  • StringDecoder.from(...)

Temporal decoders (iso8601(), date(), time(), dateTime(), offsetDateTime()) return a TemporalDecoder that supports:

  • before(...)
  • after(...)
  • between(...)

There is no past() or future(): a decoder does not read the clock. Compare with a time you pass in, such as iso8601().before(now) built where now is known.

Numeric Capabilities

IntDecoder, LongDecoder, DoubleDecoder, and FloatDecoder support:

  • min(...)
  • max(...)
  • range(...)
  • positive()
  • negative()
  • nonNegative()
  • nonPositive()
  • multipleOf(...) (integer/long only — not available for double/float)
  • oneOf(...)

DecimalDecoder supports:

  • min(...)
  • max(...)
  • positive()
  • negative()
  • nonNegative()
  • nonPositive()
  • multipleOf(...)
  • scale(...)

Boolean Capabilities

BoolDecoder supports:

  • isTrue()
  • isFalse()

Collection Capabilities

ListDecoder supports:

  • nonempty()
  • minSize(...)
  • maxSize(...)
  • fixedSize(...)
  • contains(...)
  • containsAll(...) / containsAllOf(list, message)
  • unique()
  • toSet()

RecordDecoder supports:

  • nonempty()
  • minSize(...)
  • maxSize(...)
  • fixedSize(...)

Each constraint above except containsAll and toSet also takes an optional trailing custom message, e.g. list(string()).minSize(1, "select at least one"). containsAll takes its elements as varargs, so a trailing string would be one more element to require; containsAllOf takes them as a list, followed by the message: containsAllOf(List.of("a", "b"), "pick a and b").

Object Decoding

Raoh distinguishes these cases:

  • field(name, dec): required field
  • optionalField(name, dec): missing field is allowed
  • nullable(dec): null value is allowed

There is also tri-state presence handling:

optionalNullableField("email", string())

This returns one of:

  • Presence.Absent
  • Presence.PresentNull
  • Presence.Present

This distinction matters when "missing" and "explicitly null" have different meanings.

For example:

var dec = optionalNullableField("nickname", string());

This lets you distinguish:

  • no update requested
  • clear the existing value
  • set a new value

That is often useful for PATCH-like APIs.

A More Realistic Example

The following example shows the common Raoh shape:

  • decode primitive fields
  • decode a nested object
  • run domain-specific rules afterwards
import tools.jackson.databind.JsonNode;

import java.math.BigDecimal;

import net.unit8.raoh.Path;
import net.unit8.raoh.Result;
import net.unit8.raoh.decode.Decoder;

import static net.unit8.raoh.json.JsonDecoders.*;

record Email(String value) {}
record UserId(java.util.UUID value) {}
enum Currency { JPY, USD }

record Money(BigDecimal amount, Currency currency) {
    static Result<Money> parse(BigDecimal amount, Currency currency) {
        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
            return Result.fail(Path.ROOT, "out_of_range", "amount must be positive");
        }
        return Result.ok(new Money(amount, currency));
    }
}

record User(UserId id, Email email, Money balance) {}

Decoder<JsonNode, Email> email() {
    return string().trim().toLowerCase().email().map(Email::new);
}

Decoder<JsonNode, UserId> userId() {
    return string().uuid().map(UserId::new);
}

Decoder<JsonNode, Money> money() {
    return combine(
            field("amount", decimal()),
            field("currency", enumOf(Currency.class))
    ).flatMap(Money::parse);
}

Decoder<JsonNode, User> user() {
    return combine(
            field("id", userId()),
            field("email", email()),
            field("balance", money())
    ).map(User::new);
}

This reads naturally as:

  • "read id as UUID"
  • "read email as a trimmed lowercased email"
  • "read balance structurally, then apply domain rules"
  • "construct User only if everything succeeded"

Composition Patterns

Raoh offers four distinct composition patterns — combine(...).map(...), flatMap(...), Result.map2(...), and Result.traverse(...) / Decoder.list(). Choosing the right one keeps error accumulation correct.

See docs/composition-patterns.md for details and examples.

Error Accumulation Example

Given this decoder:

var dec = combine(
        field("email", string().email()),
        field("age", int_().range(0, 150))
).map((email, age) -> Map.of("email", email, "age", age));

And this input:

{
  "email": "not-an-email",
  "age": 300
}

Raoh returns both issues, for example:

issues.flatten()
// {
//   "/email": ["not a valid email"],
//   "/age": ["must be between 0 and 150"]
// }

Utility Combinators

The net.unit8.raoh.decode.Decoders class provides reusable combinators.

  • lazy(...) For recursive decoders.
  • recover(...) Uses a fallback for any decoding error.
  • oneOf(...) Tries multiple candidates and returns a one_of_failed issue if all fail.
  • strict(...) Rejects unknown fields.
  • enumOf(...) Matches enum constants ASCII case-insensitively.
  • literal(...) Matches one exact string value.

Example:

var dec = combine(
        field("name", string()),
        field("age", int_())
).strict(Person::new);

withDefault(...) is not among them: whether a value is null or absent depends on the input, so it comes from the boundary module (JsonDecoders.withDefault, or ObjectDecoders.withDefault for maps and jOOQ records). See withDefault(...) vs recover(...).

lazy(...)

Use lazy(...) for recursive structures:

record Comment(String body, List<Comment> replies) {}

@SuppressWarnings("unchecked")
Decoder<JsonNode, Comment>[] self = new Decoder[1];
self[0] = combine(
        field("body", string().nonBlank()),
        field("replies", withDefault(list(lazy(() -> self[0])), List.of()))
).map(Comment::new);

oneOf(...)

Use oneOf(...) for union-like decoding:

var contact = oneOf(
        combine(
                field("kind", literal("email")),
                field("value", string().email())
        ).map((kind, value) -> new EmailContact(value)),
        combine(
                field("kind", literal("phone")),
                field("value", string().pattern("^\\d+$"))
        ).map((kind, value) -> new PhoneContact(value))
);

If all candidates fail, Raoh returns one_of_failed and keeps candidate-specific errors in meta.candidates. resolve() and rebase() reach those errors too, so a resolved one_of_failed lists its candidates' errors in the same language.

enumOf(...) and literal(...)

These are often used as small building blocks inside larger decoders:

field("currency", enumOf(Currency.class))
field("kind", literal("email"))

enumOf(...) is ASCII case-insensitive. literal(...) is exact.

withDefault(...) vs recover(...)

These two are similar in shape but look at different things. withDefault(...) looks at the input before decoding it; recover(...) looks at the result after.

Use withDefault(...) when a value is optional: it gives the default for a null or absent value and otherwise returns what the inner decoder gives, failure included, without looking at it. Put it inside the field, where the value is the member's:

field("role", withDefault(enumOf(Role.class), Role.MEMBER))

In JSON a null is a JSON null and an absent value is a member the object does not have (JsonDecoders.withDefault). In a map both are null (ObjectDecoders.withDefault). In a jOOQ record a column holding SQL NULL is null, but a column the record does not have is refused with missing_field by field(...) before the value decoder runs. To default both a missing column and SQL NULL, write optionalField("role", withDefault(enumOf(Role.class), Role.MEMBER)).map(r -> r.orElse(Role.MEMBER)); optionalField alone passes SQL NULL to the value decoder, which refuses it with required.

A field(...) is a CombinePart, not a Decoder, so withDefault cannot wrap it. To default a whole input, wrap a decoder of the whole input: withDefault(combine(...).map(...), fallback) gives fallback for a null input, and an object with missing members still fails with their required.

Use recover(...) when you want to tolerate any decoding failure:

field("pageSize", recover(int_().range(1, 100), 20))

recover(...) gives the fallback for an invalid value too. withDefault(...) never does.

Boundary Modules

Raoh ships three boundary modules for different input types:

  • JsonDecoders — Jackson JsonNode (raoh-json)
  • JooqRecordDecoders — jOOQ Record (raoh-jooq)
  • MapDecoders — Map<String, Object> (raoh)

Each provides the same set of helpers (string(), field(...), combine(...), etc.) adapted to its input type.

Both integration modules scope their third-party library as provided: raoh-json for Jackson 3 (tools.jackson.core:jackson-databind) and raoh-jooq for jOOQ (org.jooq:jooq). Because each module exposes that library's type (JsonNode, Record) in its public API, a consumer already supplies it on the classpath — so provided avoids pinning a specific version transitively on downstreams. Add the matching library to your own build.

See docs/boundary-modules.md for the full API listing and examples.

Error Handling

You can use pattern matching:

switch (result) {
    case Ok<User>(var user) -> {
        // success
    }
    case Err<User>(var issues) -> {
        // inspect issues
    }
}

Useful helpers on Issues:

  • flatten()
  • format()
  • toJsonList()
  • groupByPath()
  • resolve(MessageResolver)
  • resolve(MessageResolver, Locale) — locale-aware message resolution

Two especially practical shapes are:

issues.flatten()

which is convenient for form-like UIs, and:

issues.toJsonList()

which is convenient for APIs.

Locale-Aware Message Resolution

Raoh supports locale-aware error messages via ResourceBundleMessageResolver. The locale is passed at resolution time, not baked into the decoder — so a single decoder can serve multiple locales.

See docs/locale-aware-messages.md for setup instructions and examples.

Supported Usage Patterns

The current implementation is already tested for:

  • decoding nested objects
  • decoding lists and maps
  • optional, nullable, and tri-state fields
  • custom constraints
  • cross-field validation
  • defaults and recovery
  • strict mode
  • recursive decoders
  • discriminated variants
  • single-value decoding
  • conditional validation using flatMap

Examples:

  • nested object decoding
combine(
        field("name", string()),
        field("address", address())
).map(User::new);
  • cross-field validation
// Range.parse(int start, int end) returns Result<Range>, failing when start > end
combine(
        field("start", int_()),
        field("end", int_())
).flatMap(Range::parse);
  • defaults
field("role", withDefault(enumOf(Role.class), Role.MEMBER))
  • strict mode
combine(
        field("id", userId()),
        field("email", email()),
        field("age", age())
).strict(User::new)
  • single value decoding
string().email().decode(node)

Encoders

The net.unit8.raoh.encode package provides the mirror image of decoders: converting domain objects to Map<String, Object> for JDBC binding or JSON serialization.

import static net.unit8.raoh.encode.MapEncoders.*;
import static net.unit8.raoh.encode.ObjectEncoders.*;

Encoder<Item, Map<String, Object>> ITEM_ENCODER = object(
        property("id",    Item::id,    long_().contramap(ItemId::value)),
        property("name",  Item::name,  string()),
        property("price", Item::price, decimal())
);

Map<String, Object> row = ITEM_ENCODER.encode(item);

Built-in Encoders

ObjectEncoders provides: string(), int_(), long_(), double_(), float_(), bool(), decimal(), bytes(), date(), time(), dateTime(), iso8601(), offsetDateTime(), uuid(), uri(), enumOf().

Null / optionality is handled in the property layer (encoders themselves are total, non-null functions):

  • nullableProperty(key, getter, enc) — writes key: null when the getter returns null
  • propertyWithDefault(key, getter, enc, default) — writes a default (value or Supplier) when the getter returns null
  • optionalProperty(key, getter, enc) — omits the key entirely when the getter returns null
  • presenceProperty(key, getter, enc) — round-trips the tri-state Presence (omit / null / value)

Each of these is a PropertyEncoder that owns one key and writes it at most once. object(...) throws IllegalArgumentException when two of its properties own the same key, whatever the values would be.

MapEncoders provides: property(), nullableProperty(), propertyWithDefault(), optionalProperty(), presenceProperty(), object(), nested(), list(), mapOf(), lazy() (for recursive encoders), and variant() / discriminate() for tagged unions.

Scope

Encoding targets the Map<String, Object> boundary by design. To produce JSON, encode to a map and bridge with Jackson (objectMapper.valueToTree(map)); to write with jOOQ, hand the map to the DSL. There is intentionally no separate JSON or jOOQ encoder, and no encode-side combine: an encoder is a total function with no failure channel, so the write path does not need the error accumulation that justifies the applicative combine and the boundary decoder modules on the decode side. See the Encoding notes for the reasoning.

Comparisons

For mapping tables between Raoh and other libraries (Zod, Elm), see docs/comparisons.md.

Design Direction

The intended workflow is:

  1. Read dirty external input at the boundary.
  2. Decode it into domain values.
  3. Either get a fully-typed object or a structured error value.

This avoids passing partially-valid data deeper into the application and keeps the domain model focused on valid states.

Raoh Specification

Raoh Specification defines the decoders across implementations and gives the cases each implementation is checked against. raoh-java is checked by its runner, the conformance module: it maps the specification's input model onto JsonDecoders.readTree and JsonDecoders, binds each feature of the decoder language to raoh-java's API, runs the cases, and leaves the judgement to the specification's verifier, raoh-verify. MapDecoders, raoh-jooq and the other adapters for values of a host language are outside what the specification covers.

conformance/conformance.json declares where raoh-java differs on purpose and what it does not support, and conformance/spec.lock pins the specification commit it is checked against. CI runs the check on every pull request; to run it locally (it needs git, jq and Go):

scripts/conformance.sh

It writes the runner result and the verifier's report to conformance/target/.

Upgrading

Breaking changes and the migration for each are recorded in the Compatibility section of CHANGELOG.md for the release that introduces them.

Contributing

See CONTRIBUTING.md for the build, the branch/PR workflow, and the conventions a change is expected to follow. For security reports, see SECURITY.md — please do not open a public issue.

About

Java decoder library for turning untyped boundary input into typed domain values

Resources

Contributing

Security policy

Stars

38 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages