EvalApp manual

A resource-gated, self-tuning async pipeline runtime for .NET. You describe a process as a plain typed data record and a sequence of steps; EvalApp compiles that into a single, reusable execution path, gates every resource it touches at a real bound, and — when you turn tuning on — discovers the concurrency that actually works on the hardware it's running on. This page is the practical guide: install it, design a pipeline, wire up steps, gates, branches, sagas, and tuning.

Install

dotnet add package EvaluatedApplications.EvalApp

Targets net8.0 and has zero dependencies — adding the package reference is the whole installation. The package ships binary-only (no source, no XML docs): author against the EvalApp namespace and let IntelliSense on the public types be your reference.

The fluent builder and AddStep<TStep>()/AddBranch<TStep>() step-construction path are verified trim- and AOT-safe (publish-trimmed smoke-tested). WithTuning's JSON file persistence and a couple of internal reflection caches are not fully trim-clean yet — skip WithTuning if you're publishing trimmed and that matters to you.

This page tracks the root EvalApp namespace (2.0 and later) — check the live NuGet listing for the exact current version.

The one import that matters

using EvalApp;

Everything you need — Eval, IAppBuilder, ResourceKind, Tunable, PipelineResult<T>, ICompiledPipeline<T>, the step base classes, licensing — lives in this one root namespace. The internal engine lives under a separate EvalAppEngine.* root (e.g. EvalAppEngine.Fluent, EvalAppEngine.Core): not part of the supported public surface, and not covered here.

Your first pipeline

Design comes before code (see "Design before you build" below), but here's the shape you're working toward — a three-step order pipeline, built once and run many times:

using EvalApp;

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New");

Eval.App("Orders")
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .AddStep("Validate", d =>
            {
                if (d.Total <= 0) throw new InvalidOperationException("Total must be positive");
                return d with { Status = "Validated" };
            })
            .AddStep("ApplyDiscount", d => d with { Total = d.Total * 0.9m })
            .AddStep("Complete", d => d with { Status = "Complete" })
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();   // no license key needed: nothing is license-gated today; runs at full concurrency

// Build once (e.g. at startup, register `pipeline` as a singleton). Run many times:
var result = await pipeline.RunAsync(new OrderData("Alice", 99.99m));

switch (result)
{
    case PipelineResult<OrderData>.Success success:
        Console.WriteLine($"{success.Data.Status}: {success.Data.Total}");
        break;
    case PipelineResult<OrderData>.Failure failure:
        Console.WriteLine($"Failed: {failure.Exception.Message}");
        break;
    case PipelineResult<OrderData>.Skipped skipped:
        Console.WriteLine($"Skipped: {skipped.Reason}");
        break;
}

Read the fluent chain top to bottom and it reads like the process: define an app, define a domain, define a task, add steps, run it, build it. That's deliberate — the builder chain is the architecture, and unlike a design document it can't drift out of sync with the code, because it is the code.

A few things to notice:

Design before you build

The single biggest lever on whether an EvalApp pipeline reads cleanly is answering these questions before touching the fluent API. The order matters — each answer constrains the next.

  1. What is the unit of work? One sentence. One order, one invoice, one request, one batch?
  2. What does it start with, carry, and produce? List every piece of information that exists at any point in the process, then encode it as a record: non-nullable = required input, nullable = an output that appears once its step runs, bool flags = control (loop/branch/skip). If you can't describe the full lifecycle by reading the record's fields alone, the design isn't finished yet.
  3. What resource does each operation touch? Classify every operation: Network / DiskIO / Cpu / Database / a custom ResourceKind.Of("name") / or none (pure). This decides which operations need a Gate — see below.
  4. What needs to run per-item in parallel? Independent items in a collection (ones that must not read each other's in-progress state) are ForEach boundaries.
  5. What conditional branches exist? Every If/Else: the condition, what each branch does, whether both branches write the same fields.
  6. What needs to be rolled back if something fails? Walk the steps again asking what has externally happened by the time each completes (money moved, email sent, record written). Only external effects need compensation — see Sagas, below.
  7. Wire it together. Only now write the fluent declaration. If it doesn't read like a plain-language description of the process, the data model or topology needs more work, not more code.

This degrades gracefully: a single-item pipeline with no branches and no external effects skips straight to a flat sequence of steps, and that's a sign the problem was simple, not that the method failed.

Steps: pure, async, and side-effecting

public sealed class ApplyTaxStep : PureStep<OrderData>
{
    private readonly decimal _rate;
    public ApplyTaxStep(decimal rate) => _rate = rate;

    public override OrderData Execute(OrderData data)
        => data with { Total = data.Total * (1 + _rate) };
}

public sealed class FetchPriceStep : AsyncStep<OrderData>
{
    private readonly ICatalog _catalog;
    public FetchPriceStep(ICatalog catalog) => _catalog = catalog;

    public override async ValueTask<OrderData> ExecuteAsync(OrderData data, CancellationToken ct)
    {
        var price = await _catalog.GetPriceAsync(data.CustomerName, ct);
        return data with { Total = price };
    }
}

Add an instance directly: .AddStep("Tax", new ApplyTaxStep(0.08m)).

For a step whose job is a side effect (write to a database, publish an event, send an email), subclass SideEffectStep<T> and declare the resource it touches:

public sealed class SendConfirmationEmailStep : SideEffectStep<OrderData>
{
    public override ResourceKind? ResourceKind => EvalApp.ResourceKind.Network;

    public override async ValueTask<OrderData> ExecuteAsync(OrderData data, CancellationToken ct)
    {
        await EmailClient.SendAsync(data.CustomerName, ct);
        return data with { Status = "Confirmed" };
    }
}
SideEffectStep<T> cannot be added with the plain AddStep(name, step) overload — passing a SideEffectStep<T> instance to AddStep binds to a [Obsolete(error: true)] overload and fails to compile (this applies everywhere AddStep is offered, including inside a Gate). The supported way to run a SideEffectStep<T> is AddStep<TStep>(), which auto-gates it on its declared ResourceKind.
// Auto-gated on the declared ResourceKind (needs a public parameterless constructor,
// or a registered IStepFactory — see "Dependency injection", below):
.AddStep<SendConfirmationEmailStep>("SendConfirmation")

If you instead gate the work explicitly, the Gate already supplies the resource, so you don't need SideEffectStep<T> there at all — write the side effect as an async step inside the gate:

.Gate(ResourceKind.Network, onWaiting: null, g => g
    .AddStep("SendConfirmation", async (OrderData d, CancellationToken ct) =>
    {
        await EmailClient.SendAsync(d.CustomerName, ct);
        return d with { Status = "Confirmed" };
    }))

Either way, there is no path to an ungated side effect — that's enforced at compile/build time, not by convention.

Context-aware steps

If a step needs data that's constant for a whole app run (a config, a connection factory) or for a whole domain (a tenant ID, a region), don't thread it through every record — subclass GlobalContext / DomainContext and wire them in at the top:

public sealed class AppGlobal : GlobalContext { public required IEmailClient EmailClient { get; init; } }
public sealed class OrderDomain : DomainContext { public required string Region { get; init; } }

public sealed class SendConfirmationStep : ContextPureStep<AppGlobal, OrderDomain, OrderData>
{
    protected override ValueTask<OrderData> TransformAsync(
        OrderData data, AppGlobal global, OrderDomain domain, CancellationToken ct)
        => ValueTask.FromResult(data with { Status = $"Confirmed:{domain.Region}" });
}

Eval.App("Orders")
    .WithContext(new AppGlobal { EmailClient = emailClient })
    .DefineDomain("Orders", new OrderDomain { Region = "US" })
        .DefineTask<OrderData>("ProcessOrder")
            .AddStep<SendConfirmationStep>("SendConfirmation")   // NOT AddStep("name", new SendConfirmationStep())
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

Global/Domain are populated as properties on the step before it runs — read them directly, no parameter threading needed. Two things that don't work the way they look like they should:

Gates and resource kinds

A Gate declares that everything inside it contends for a named, shared resource:

.Gate(ResourceKind.Database, onWaiting: null, g => g
    .AddStep("CheckInventory", d => d))

Built-in kinds: ResourceKind.Network, .DiskIO, .Cpu, .Database. Define your own pool with ResourceKind.Of("my-pool-name"). Declare a resource's tunable bounds at the app level:

Eval.App("Orders")
    .WithResource(ResourceKind.Database, Tunable.Between(min: 2, max: 16, @default: 4))
    ...

Tunable factory methods: Between(min, max, default), FixedAt(value), ForCpu() (scales with Environment.ProcessorCount), ForItems() / ForItems(default), ModelOps() (fixed at 1 — exclusive access, e.g. GPU training), DiskIo(), Network(). These are starting points, not final answers — when tuning is enabled the adaptive tuner adjusts within the declared [Min, Max] bounds at runtime; without tuning a gate holds at its declared default. Nothing here is license-gated (see Licensing, below).

Parallel collections: ForEach

.DefineTask<OrderData>("CalculateLineTotals")
    .ForEach(
        select: d => d.Items,
        merge: (order, items) => order with { Total = items.Sum(i => i.LineTotal) },
        collectionName: "Items",
        parallelism: Tunable.ForItems(),
        configure: sub => sub
            .AddStep("CalcLineTotal", item => item with { LineTotal = item.Price * item.Quantity }))

select extracts the collection, the per-item pipeline runs on each item independently, merge combines the results back into the parent. Parallelism can be a plain int, a Func<int>, or a TunableConfig (the tuner-friendly form, via Tunable.*). There's also a ForEachFailureMode (CollectAndThrow / FailFast / ContinueOnError) overload for controlling how per-item failures are handled.

Conditional branches

.If(
    d => d.Total > 100,
    then: b => b.AddStep("VipDiscount", d => d with { Total = d.Total * 0.8m, Status = "VIP" }),
    @else: b => b.AddStep("StandardProcess", d => d with { Status = "Standard" }))

Parallel branches

Not per-item — a fixed set of concurrent operations:

.AddParallelGroup(pg => pg
    .AddBranch("CalcTax", d => d with { Tax = d.Total * 0.08m })
    .AddBranch("CalcShipping", d => d with { Shipping = 9.99m }))

Each branch writes different fields and both merge cleanly by default (FirstChangeWins); pass a custom IMergeStrategy<T> (one of the four public extension seams — see Architecture below) if two branches might legitimately write the same field and you want that treated as a conflict rather than silently resolved.

Sagas: compensation on failure

.BeginSaga()
    .AddStepWithCompensation("ReserveInventory",
        forward:    d => d with { Status = "Reserved" },
        compensate: d => d with { Status = "Unreserved" })
    .AddStepWithCompensation("ChargePayment",
        forward:    d => throw new InvalidOperationException("Payment declined"),
        compensate: d => d with { Status = "Refunded" })
.EndSaga()

If a later step fails, every prior step's compensation runs in reverse (LIFO) order. Only .BeginSaga()...EndSaga() compiles into a valid saga — the compiler enforces that a saga you start gets closed before .Run().

Dependency injection

AddStep<TStep>(name) resolves TStep through an IStepFactory. By default that's DefaultStepFactory (Activator.CreateInstance — the step needs a public parameterless constructor). To resolve steps from your own container instead:

Eval.App("Orders")
    .WithStepFactory(new ServiceProviderStepFactory(serviceProvider))
    ...
        .AddStep<ValidateOrderStep>("Validate")   // resolved from DI

ServiceProviderStepFactory falls back to Activator.CreateInstance for any type not registered in the container.

Licensing

No feature in EvalApp is gated behind a tier or a license key — that is intentional. Every pipeline shape above runs at full declared concurrency whether or not you pass a license key. The adaptive/Bayesian tuner attaches whenever you call WithTuning(...), independent of any license. So Build(null) and Build(key) behave identically at runtime; a license key changes no capability today. The license system below still exists — it's the sanctioned wiring every Evaluated Applications product shares, kept ready for a possible future advanced tier — but it caps nothing now. This is a proprietary, commercial-licence package: evaluation and non-commercial use are permitted without a licence; commercial, production, or revenue-generating use requires a separate commercial licence from the owner.

Eval.App("Orders")
    ...
    .Build(licenseKey);   // null/empty/invalid/expired key all degrade to Unlicensed — never throws

To check license status yourself (e.g. to show a UI banner) rather than just letting Build degrade silently:

var validator = LicenseValidator.ForEvalApp();      // or LicenseValidator.For("your-product")
LicenseMode mode = validator.Check(licenseKey);       // Unlicensed | Licensed
// or, in a hot loop (e.g. a 60fps game loop):
LicenseMode mode = validator.CheckPeriodic(licenseKey, TimeSpan.FromSeconds(5));

License verification is entirely offline (ECDsa P-256) — there is no network call, ever.

Tuning modes

Two opt-in modes (enable either by calling it — neither is license-gated):

.WithTuning()            // reactive: rediscovers the optimum from scratch each restart —
                          // best for a long-running, stable, single-instance deployment
.WithBayesianTuning()     // predictive: a persisted performance model survives restarts —
                          // best for frequently-restarting or multi-instance workloads

Both take an optional store path; both share the same underlying phase machine, so switching between them doesn't reset state.

You don't configure the phase machine to get good behaviour — the defaults are derived from a simulation harness and are what .WithTuning() uses out of the box. For reference, the current built-in defaults are:

These are informational, not a stability contract — they're re-derived from first principles when the harness is re-run, and a change to them is a change to the default tuning path. To override them explicitly, load your own set with .WithTunerSettings(path).

Inspecting a pipeline

ICompiledPipeline<T> is intentionally opaque: its entire public surface is Name (the name given at build time) and RunAsync(data, ct). There is no exposed internal step tree and no visualization/export API in the public EvalApp namespace — any tree/graph rendering lives in the internal EvalAppEngine.* engine.

Known gap (does not block these docs): if a public visualizer surface (ToTextTree/ToMermaid/JSON export) is ever wanted, that's a deliberate facade addition to weigh — not a capability to assume exists today.

Anti-patterns to avoid

EvalApp's tuner and gates can only account for work they can see — anything that happens outside the declared pipeline topology is invisible to both. Watch for:

Architecture note: the four public extension seams

Beyond the fluent API and the step base classes, exactly four extension points are deliberately kept public for advanced integration, all in the same root EvalApp namespace as everything else on this page: GlobalContext / DomainContext (subclass for WithContext/ DefineDomain(..., context)), IMergeStrategy<T> (custom AddParallelGroup conflict handling), and IPressureResource (a soft, non-blocking budget signal for .Pressure()/.WithPressure()). Everything else lives under the separate internal EvalAppEngine.* root — don't reach for it; if something you need isn't reachable from root EvalApp, that's a gap to report, not a reason to bypass the facade.

← Back to EvalApp  ·  EvalApp.Neural →