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.
On this page
- Install
- The one import that matters
- Your first pipeline
- Design before you build
- Steps: pure, async, side-effecting
- Context-aware steps
- Gates and resource kinds
- Parallel collections (ForEach)
- Conditional branches
- Parallel branches
- Sagas: compensation on failure
- Dependency injection
- Licensing
- Tuning modes
- Inspecting a pipeline
- Anti-patterns to avoid
- Architecture: the four extension seams
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 andAddStep<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 — skipWithTuningif 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:
AddSteptakes a name and any of: aFunc<T,T>lambda, an asyncFunc<T, CancellationToken, ValueTask<T>>lambda, anIStep<T>instance, or aPureStep<T>/AsyncStep<T>subclass.withexpressions, not mutation.OrderDatais arecord; every step returns a new value. This is what makes parallelForEach(below) safe without locks — no step can see another's in-progress state.Run(out ICompiledPipeline<T> pipeline)hands you the compiled, reusable pipeline; then.Build(licenseKey?)finalizes the whole app. Build the pipeline once at startup and callRunAsyncmany times — rebuilding per request throws away every bit of the tuner's learned state (see "Rebuilding the pipeline per request" in Anti-patterns, below).- Results are a discriminated union, not an exception.
PipelineResult<T>isSuccess(Data)/Failure(Data, Exception, Message?)/Skipped(Data, Reason). Pattern-match it, or use.GetData()/.IsSuccess/.IsFailure/.IsSkippedif you just need the value.
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.
- What is the unit of work? One sentence. One order, one invoice, one request, one batch?
- 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.
- What resource does each operation touch? Classify every operation:
Network/DiskIO/Cpu/Database/ a customResourceKind.Of("name")/ or none (pure). This decides which operations need aGate— see below. - 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
ForEachboundaries. - What conditional branches exist? Every
If/Else: the condition, what each branch does, whether both branches write the same fields. - 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.
- 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 plainAddStep(name, step)overload — passing aSideEffectStep<T>instance toAddStepbinds to a[Obsolete(error: true)]overload and fails to compile (this applies everywhereAddStepis offered, including inside aGate). The supported way to run aSideEffectStep<T>isAddStep<TStep>(), which auto-gates it on its declaredResourceKind.
// 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:
- Always add a context step with
AddStep<TStep>(), neverAddStep(name, instance).ContextPureStep<TGlobal,TDomain,T>/ContextSideEffectStep<TGlobal,TDomain,T>don't implement the plainIStep<T>the instance-overload expects (that's how context gets injected before the step runs), soAddStep("x", new MyContextStep())is a compile error (CS1503) — always use the genericAddStep<TStep>()form for these, same asSideEffectStep<T>above. - There is no
StepContextparameter, ever, on the public base classes, unlike the pipeline's internal engine. If you find yourself wanting to reference a type calledStepContextor aContextPureStep<TGlobal,TDomain,T>under theEvalAppEngine.*root instead of rootEvalApp, that's the internal engine, not this facade — it won't compile against the published package and never has, in any released version.
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:
- Improvement threshold
0.04, convergence std-dev0.04 - Converging runs needed
5 - Monitoring regression
0.19, monitoring improvement0.30 - Max probe interval
18, max history size50, exploration window size28 - Emergency degradation
0.36, emergency runs needed4 - Converging-instability threshold
1.50 - Minimum items for parallel
4
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:
Task.WhenAllinside a step body instead of a declared.ForEach(...).- A background
Taskstarted and stored on the data record — it races the pipeline, ungated. - Async work before/after
RunAsync, outside the pipeline — unthrottled, untuned. - A shared mutable collection written from inside a
ForEachitem — not thread-safe; let each item own its own result and collect them inmerge. - Mutating the data record instead of using
with— breaks the no-lock parallel-ForEachguarantee. - Instance fields on a step class holding per-run state — steps are singletons reused across every concurrent invocation; per-run state belongs on the data record only.
- Rebuilding the pipeline per request — discards all accumulated tuner state; build once, run many times.
- Blocking I/O inside a sync step — always use the async overload for I/O.
- Not propagating the
CancellationTokeninto every awaited call inside a step.
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.