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
- Maven
Build and run tests:
mvn clean testnet.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>
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
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.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 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": {}
}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>
Raoh includes the following built-in decoders in net.unit8.raoh.builtin.
Value decoders:
StringDecoderIntDecoderLongDecoderBoolDecoderDecimalDecoder
Collection/value-container decoders:
ListDecoderRecordDecoder
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()
IntDecoder and LongDecoder support:
min(...)max(...)range(...)positive()negative()nonNegative()nonPositive()multipleOf(...)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(...)
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 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"
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.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.
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)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.
