DEV Community

rinat kozin
rinat kozin

Posted on

redb.Route 3.0.1 — flat DSL navigation, CRTP refactor, and a silent null fix

Series: redb ecosystem


Before 3.0.1, deep nested scopes required closing in exact reverse order — tedious and easy to get wrong. Three things changed.

Flat End*() navigation

A typed closer walks the Parent chain and exits to the right level in one call:

From("direct://demo-cascade-endchoice")
    .Choice()
        .When(e => true)
            .Split(e => new object?[] { 1, 2, 3 })
                .Process(e => { /* per-item work */ })
                .Log("item=${body}")
            .EndChoice()   // walks past Split → When → lands at route root

    .SetHeader("post-cascade", "ok")
    .Log("cascade done");
Enter fullscreen mode Exit fullscreen mode

Universal .End() exits to the nearest scope without naming it:

.Choice()
    .When(e => true)
        .Split(e => new object?[] { "a", "b" })
            .Log(LogLevel.Information)
                .Message("inside")
            .End()   // closes RichLog → returns Split body
        .End()        // closes Split   → returns When body
    .EndChoice()      // closes Choice  → returns route root
Enter fullscreen mode Exit fullscreen mode

Sibling branches open naturally after a closed sub-scope — .When() and .Otherwise() walk up to the enclosing ChoiceDefinition via the Parent chain, so this compiles as-is:

.Choice()
    .When(e => e.In.Body is IEnumerable<string> && e.In.Body is not string)
        .Split(...)
            .Process(...)
        .EndSplit()
        .Log("list branch done [${routeId}]")   // still on the When body, not on Split
    .When(e => e.In.Body is string s && s.Length > 0)   // ← sibling, works after EndSplit
        .Process(e => { /* ... */ })
    .Otherwise()
        .Process(e => { /* fallback */ })
.EndChoice()
Enter fullscreen mode Exit fullscreen mode

Three logging styles, same pipeline step

The updated demo shows all three forms side by side — useful to see the tradeoffs:

// (A) Lambda — arbitrary C# at runtime
.Log(e => $"[lambda] item={e.In.Body} branch={e.In.Headers["branch"]}")

// (B) String template — compiled by the expression engine, zero alloc when level is off
.Log("[tmpl] item=${body} branch=${header.branch} [${routeId}]")

// (C) Rich-log scope — structured, multi-message, headers/properties as separate fields
.Log(LogLevel.Information)
    .Message("[rich-tmpl]   item=${body}")
    .Message(e => $"[rich-lambda] upper={((string)e.In.Body!).ToUpperInvariant()}")
    .Header("branch")
    .Property("item-index")
    .ShowRouteId(true)
.EndLog()
Enter fullscreen mode Exit fullscreen mode

${body}, ${header.x}, ${property.y}, ${routeId}, ${exception.type}, ${exception.message} — all resolved by the compiled expression engine (Tokenizer → Parser → AST → IL). The template compiles once, runs as a cached delegate. .Message() in a rich-log scope accepts both forms simultaneously.

CRTP base — 27 duplicate class bodies removed

Every leaf method (To, Process, SetBody, Filter, Split, Transaction, ~40 more) now lives once in RouteDefinitionBase<TSelf>. Each returns TSelf — chaining stays on the concrete scope type throughout. Public API and route AST identical to 3.0.0.

Fix: GetContext() silently returned null inside nested scopes

IRouteDefinition.GetContext() was casting to RouteDefinition, which only matched the route root. Inside When, Loop, Traced, Catch — it returned null without throwing. Now walks Parent to the owning route. Matters if you have extension methods that read context at DSL build time.


Full demo: DeepDslShowcaseRoutes.cs · Changelog: 3.0.1 · Apache 2.0


Top comments (0)