SkillAgentSearch skills...

Raoh

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

Install / Use

/learn @kawasima/Raoh
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

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
  • Maven

Build and run tests:

mvn clean test

Package Layout

Core (raoh)

  • net.unit8.raoh: core abstractions and error model
  • net.unit8.raoh.builtin: built-in primitive and collection decoders
  • net.unit8.raoh.combinator: applicative combinator internals
  • net.unit8.raoh.map: decoders for Map<String, Object>

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

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 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.JsonDecoders
  • net.unit8.raoh.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 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:

  • StringDecoder
  • IntDecoder
  • LongDecoder
  • BoolDecoder
  • DecimalDecoder

Collection/value-container decoders:

  • ListDecoder
  • RecordDecoder

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 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 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 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
View on GitHub
GitHub Stars19
CategoryDevelopment
Updated1d ago
Forks0

Languages

Java

Security Score

90/100

Audited on Apr 7, 2026

No findings