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, doesauthorizationCodedefinitely 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
build() runs 8 validation checks at build time:
- Exactly one initial state
- At least one terminal state
- Every non-terminal state has outgoing transitions
- Every state is reachable from initial
- At least one terminal is reachable (unless perpetual)
- No duplicate transitions from the same state
- requires/produces chain is satisfied on every path
- 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
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();
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
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();
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.
Top comments (0)