Raoh
Java decoder library for turning untyped boundary input into typed domain values
Install / Use
/learn @kawasima/RaohREADME
Raoh
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
Tutorials
Requirements
- Java 25
- Maven
Build and run tests:
mvn clean test
Package Layout
Core (raoh)
net.unit8.raoh: core abstractions and error modelnet.unit8.raoh.builtin: built-in primitive and collection decodersnet.unit8.raoh.combinator: applicative combinator internalsnet.unit8.raoh.map: decoders forMap<String, Object>
JSON extension (raoh-json)
net.unit8.raoh.json: decoders for JacksonJsonNode
jOOQ extension (raoh-jooq)
net.unit8.raoh.jooq: decoders for jOOQRecord
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,DomainConstructionGuardExceptionraoh-gsh-weaver: bytecode weaver (ClassFile API), Java Agent, CLIraoh-gsh-maven-plugin: Maven plugin for build-time weaving
See raoh-gsh README for usage.
Core Model
Result<T>
Decoding returns a value instead of throwing:
Ok<T>for successErr<T>for failure
Result<T> supports:
map(...)flatMap(...)fold(...)orElseThrow(...)
Issue, Issues, and Path
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.
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.JsonDecodersnet.unit8.raoh.map.MapDecoders
What It Feels Like
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.
Quick Start
Decode JSON into a domain object
import com.fasterxml.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": {}
}
Decode a Map<String, Object>
import java.util.Map;
import net.unit8.raoh.map.MapDecoder;
import static net.unit8.raoh.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>
Built-in Decoders
Raoh includes the following built-in decoders in net.unit8.raoh.builtin.
Value decoders:
StringDecoderIntDecoderLongDecoderBoolDecoderDecimalDecoder
Collection/value-container decoders:
ListDecoderRecordDecoder
String Capabilities
StringDecoder supports:
nonBlank()allowBlank()minLength(...)maxLength(...)fixedLength(...)pattern(...)startsWith(...)endsWith(...)includes(...)oneOf(...)email()url()ipv4()ipv6()ip()cuid()ulid()trim()toLowerCase()toUpperCase()uuid()uri()iso8601()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()
Numeric Capabilities
IntDecoder and LongDecoder support:
min(...)max(...)range(...)positive()negative()nonNegative()nonPositive()multipleOf(...)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(...)unique()toSet()
RecordDecoder supports:
nonempty()minSize(...)maxSize(...)fixedSize(...)
Object Decoding
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.
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 com.fasterxml.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"
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
