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 use JSON Pointer-like notation, for example:
/email/address/city/items/0/name
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.json.JsonDecoder;
import static net.unit8.raoh.json.JsonDecoders.*;
record Email(String value) {}
record Age(int value) {}
record User(Email email, Age age) {}
JsonDecoder<Email> email() {
return string().trim().toLowerCase().email().map(Email::new);
}
JsonDecoder<Age> age() {
return int_().range(0, 150).map(Age::new);
}
JsonDecoder<User> user() {
return combine(
field("email", email()),
field("age", age())
).map(User::new);
}Use it like this:
Result<User> result = user().decode(jsonNode);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.map.MapDecoder;
import static net.unit8.raoh.decode.map.MapDecoders.*;
record Config(String host, int port) {}
MapDecoder<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()— validates http/https and returnsURIipv4()ipv6()ip()cuid()ulid()trim()toLowerCase()toUpperCase()uuid()uri()— accepts any scheme, returnsURIiso8601()date()time()localDateTime()offsetDateTime()toInt()toLong()toDecimal()toBool()StringDecoder.from(...)
Temporal decoders (iso8601(), date(), time(), localDateTime(), offsetDateTime()) return a TemporalDecoder that supports:
before(...)after(...)between(...)past()future()pastOrPresent()futureOrPresent()
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(...)unique()toSet()
RecordDecoder supports:
nonempty()minSize(...)maxSize(...)fixedSize(...)
Each constraint above (except containsAll / toSet) also takes an optional trailing custom
message, e.g. list(string()).minSize(1, "select at least one").
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.json.JsonDecoder;
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) {}
JsonDecoder<Email> email() {
return string().trim().toLowerCase().email().map(Email::new);
}
JsonDecoder<UserId> userId() {
return string().uuid().map(UserId::new);
}
JsonDecoder<Money> money() {
return combine(
field("amount", decimal()),
field("currency", enumOf(Currency.class))
).flatMap(Money::parse);
}
JsonDecoder<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.withDefault(...)Uses a fallback when decoding fails only withrequirederrors.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 case-insensitively.literal(...)Matches one exact string value.
Example:
var dec = combine(
field("name", string()),
field("age", int_())
).strict(Person::new);Use lazy(...) for recursive structures:
record Comment(String body, List<Comment> replies) {}
JsonDecoder<Comment>[] self = new JsonDecoder[1];
self[0] = combine(
field("body", string().nonBlank()),
withDefault(field("replies", 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(Pattern.compile("^\\d+$")))
).map((kind, value) -> new PhoneContact(value))
);If all candidates fail, Raoh returns one_of_failed and keeps candidate-specific errors in meta.candidates.
These are often used as small building blocks inside larger decoders:
field("currency", enumOf(Currency.class))
field("kind", literal("email"))enumOf(...) is case-insensitive. literal(...) is exact.
These two are similar in shape but different in intent.
Use withDefault(...) when a value is conceptually optional and you want a fallback for missing/null-like cases:
field("role", withDefault(enumOf(Role.class), Role.MEMBER))Use recover(...) when you want to tolerate any decoding failure:
recover(field("pageSize", int_().range(1, 100)), 20)recover(...) is more permissive. withDefault(...) is stricter.
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
combine(
field("start", int_()),
field("end", int_())
).flatMap(Period::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)
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.
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.