I built a state machine where invalid transitions can't compile

java dev.to

You know this bug.

You're building an OAuth flow. Ten states. Five callbacks. A token refresh that fires at exactly the wrong moment. And then, three hours into debugging:

"Wait — when we reach CALLBACK_RECEIVED, does authorizationCode definitely exist?"

You grep. You trace. You add a null check. You deploy. Two weeks later, a different state, a different missing field, the same three hours.

I got tired of this. So I built tramli.

What tramli does in 30 seconds

tramli is a constrained flow engine. You declare states, transitions, and — this is the key part — what data each processor needs and what it produces:

enum OrderState implements FlowState {
    CREATED(false, true),
    PAYMENT_PENDING(false, false),
    CONFIRMED(false, false),
    SHIPPED(true, false);
    // ...
}

var flow = Tramli.define("order", OrderState.class)
    .initiallyAvailable(OrderRequest.class)
    .from(CREATED).auto(PAYMENT_PENDING, new StartPayment())
    .from(PAYMENT_PENDING).external(CONFIRMED, new PaymentGuard())
    .from(CONFIRMED).auto(SHIPPED, new ShipOrder())
    .build();  // ← THIS is where the magic happens
Enter fullscreen mode Exit fullscreen mode

build() runs 8 validation checks at build time:

  1. Exactly one initial state
  2. At least one terminal state
  3. Every non-terminal state has outgoing transitions
  4. Every state is reachable from initial
  5. At least one terminal is reachable (unless perpetual)
  6. No duplicate transitions from the same state
  7. requires/produces chain is satisfied on every path
  8. No orphan states

Check #7 is what kills the OAuth bug. If ShipOrder.requires() declares PaymentResult.class, but no processor on any path to CONFIRMED produces it — build() throws at startup. Not at 2 AM. Not in production. At startup.

"Just use XState"

Fair question. XState is great — 29K GitHub stars, proper Statecharts with hierarchy and parallel states. I respect it.

But XState doesn't have requires/produces. You can freely read and write context from any state. The type system helps, but it can't prove "this field is always present when we reach this state across every possible path."

tramli trades expressiveness for that guarantee:

XState:  hierarchy + parallel  → can't verify data-flow (exponential paths)
tramli:  flat enum only        → every path is enumerable → full verification
Enter fullscreen mode Exit fullscreen mode

It's a deliberate tradeoff. If you need deep hierarchy and parallel regions, use XState. If you need proof that your data is always where you expect it, use tramli.

The data-flow graph

tramli doesn't just validate — it shows you the data flow:

flow.dataFlowGraph().toMermaid();
Enter fullscreen mode Exit fullscreen mode

This generates a Mermaid diagram showing exactly which processor produces which type, and which processor consumes it. Your flow definition IS the documentation. They can never drift apart.

graph LR
    StartPayment -->|produces PaymentIntent| PaymentGuard
    PaymentGuard -->|produces PaymentResult| ShipOrder
    ShipOrder -->|produces ShipmentInfo| SHIPPED
Enter fullscreen mode Exit fullscreen mode

Real example: OIDC auth flow

This is the flow that started it all. 9 states, 5 processors, and it looks like this:

var oidc = Tramli.define("oidc", AuthState.class)
    .initiallyAvailable(OidcConfig.class)
    .from(IDLE).auto(REDIRECTING, new BuildAuthUrl())
    .from(REDIRECTING).external(CALLBACK_RECEIVED, new CallbackGuard())
    .from(CALLBACK_RECEIVED).auto(EXCHANGING, new ExchangeCode())
    .from(EXCHANGING).auto(VALIDATING, new ValidateTokens())
    .from(VALIDATING).branch(new TokenValidator())
        .to(AUTHENTICATED, "valid")
        .to(REFRESH_NEEDED, "expired")
        .endBranch()
    .from(REFRESH_NEEDED).auto(EXCHANGING, new RefreshToken())
    .from(AUTHENTICATED).external(SESSION_EXPIRED, new ExpiryGuard())
    .from(SESSION_EXPIRED).auto(IDLE, new Cleanup())
    .onAnyError(AUTH_ERROR)
    .build();
Enter fullscreen mode Exit fullscreen mode

50 lines. Every data dependency verified. The build() call proves that ExchangeCode will always have the authorizationCode that CallbackGuard produces. Every. Single. Time.

A procedural version of this was 1,800 lines and had the token bug I mentioned. This version has zero state-related bugs because they're structurally impossible.

Not just Java

tramli has implementations in three languages with a shared test suite:

  • Java — reference implementation, 3,000 lines
  • TypeScript — 2,200 lines, same API shape
  • Rust — 2,200 lines, same guarantees

Same validation rules, same requires/produces contracts, same Mermaid output. A shared test suite in YAML ensures all three behave identically.

Plugin system

tramli core is deliberately frozen — it's a verification kernel. Everything else is a plugin:

Plugin What it does
eventstore Append-only transition log + replay + compensation
audit Produced-data diff capture per transition
hierarchy Harel-style hierarchical authoring → flat enum code generation
lint Policy-based design checks
observability Telemetry sink integration
idempotency Command-ID duplicate suppression
diagram Mermaid + data-flow bundle
docs Markdown catalog generation

The hierarchy plugin is interesting — it lets you author in Harel Statechart style, then compiles down to flat enums so data-flow verification still works. Best of both worlds.

Who is this for?

Honestly? Not most people.

If your state management is "button clicked → modal opens → modal closes", use Zustand. It's 3KB and it's great.

tramli is for the people who:

  • Built an OAuth/OIDC flow and lost hours to "which fields exist in which state?"
  • Wrote a payment processing pipeline and found out in production that a race condition skipped a validation step
  • Maintained a 2,000-line workflow handler and couldn't tell what would break if they changed line 847

If you've debugged a state transition bug at 2 AM and thought "why can't the compiler just catch this?", that's exactly what tramli does.


GitHub · API Cookbook · Why tramli Works · OIDC Example

Zero dependencies. MIT license. Built by someone who lost too many hours debugging state transitions.

Read Full Tutorial open_in_new
arrow_back Back to Tutorials