Raoh is a Java decoder library for turning untyped boundary input into typed domain values.
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
- 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.
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>- Java 25
- Maven
mvn clean testmvn 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.
net.unit8.raoh: core abstractions and error modelnet.unit8.raoh.decode: decoder core (Decoder,Decoders,ObjectDecoders,FieldDecoder)net.unit8.raoh.decode.builtin: built-in primitive and collection decodersnet.unit8.raoh.decode.combinator: applicative combinator internalsnet.unit8.raoh.decode.map: decoders forMap<String, Object>net.unit8.raoh.encode: encoder core (Encoder,ObjectEncoders,MapEncoders)
net.unit8.raoh.json: decoders for JacksonJsonNode
net.unit8.raoh.jooq: decoders for jOOQRecord
Test/CI-time guard that detects accidental new construction of domain objects outside of Decoder.decode().
raoh-gsh: runtime —DomainConstructionScope,DomainConstructionGuardExceptionraoh-gsh-weaver: bytecode weaver (ClassFile API), Java Agent, CLIraoh-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.
Decoding returns a value instead of throwing:
Ok<T>for successErr<T>for failure
Result<T> supports:
map(...)flatMap(...)fold(...)orElseThrow(...)
Each error includes:
pathcodemessagemeta
Paths are written as RFC 6901 JSON Pointers, for example:
/email/address/city/items/0/name/a~1bfor a field nameda/b(~is written~0,/is written~1)
Issues can be merged, rebased, flattened, formatted, or converted to JSON-like data.
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.JsonDecodersnet.unit8.raoh.decode.map.MapDecoders
The normal Raoh workflow looks like this:
- Start from raw input such as JSON or
Map<String, Object>. - Define small decoders for domain primitives such as
Email,Age, orUserId. - Combine them into object decoders.
- If decoding succeeds, you get a fully-typed value.
- 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.
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": {}
}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>
Raoh includes the following built-in decoders in net.unit8.raoh.decode.builtin.
Value decoders:
StringDecoderIntDecoderLongDecoderDoubleDecoderFloatDecoderBoolDecoderDecimalDecoder
Collection/value-container decoders:
ListDecoderRecordDecoder
Other:
ObjectDecoders.bytes()— decodesbyte[](for JDBC binary columns such as BYTEA/VARBINARY)
StringDecoder supports:
nonBlank()minLength(...)maxLength(...)fixedLength(...)pattern(...)startsWith(...)endsWith(...)includes(...)oneOf(...)email()url()— an http/https URI accepted byuri(), with a non-empty RFC 3986 host; returnsURIipv4()ipv6()ip()cuid()ulid()trim()toLowerCase()toUpperCase()normalize(...)— Unicode normalization, NFC by defaultuuid()uri()— an RFC 3986 URI of any scheme thatjava.net.URIcan hold (see its Javadoc); returnsURIiso8601()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.
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(...)
BoolDecoder supports:
isTrue()isFalse()
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").
Raoh distinguishes these cases:
field(name, dec): required fieldoptionalField(name, dec): missing field is allowednullable(dec):nullvalue is allowed
There is also tri-state presence handling:
optionalNullableField("email", string())This returns one of:
Presence.AbsentPresence.PresentNullPresence.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.
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
idas UUID" - "read
emailas a trimmed lowercased email" - "read
balancestructurally, then apply domain rules" - "construct
Useronly if everything succeeded"
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.
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"]
// }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 aone_of_failedissue 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(...).
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);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.
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.
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.
Raoh ships three boundary modules for different input types:
JsonDecoders— JacksonJsonNode(raoh-json)JooqRecordDecoders— jOOQRecord(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.
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.
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.
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)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);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)— writeskey: nullwhen the getter returnsnullpropertyWithDefault(key, getter, enc, default)— writes a default (value orSupplier) when the getter returnsnulloptionalProperty(key, getter, enc)— omits the key entirely when the getter returnsnullpresenceProperty(key, getter, enc)— round-trips the tri-statePresence(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.
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.
For mapping tables between Raoh and other libraries (Zod, Elm), see docs/comparisons.md.
The intended workflow is:
- Read dirty external input at the boundary.
- Decode it into domain values.
- 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 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.shIt writes the runner result and the verifier's report to conformance/target/.
Breaking changes and the migration for each are recorded in the Compatibility section of CHANGELOG.md for the release that introduces them.
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.
