SolTechnology.Core.Tale 1.0.0

dotnet add package SolTechnology.Core.Tale --version 1.0.0
                    
NuGet\Install-Package SolTechnology.Core.Tale -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SolTechnology.Core.Tale" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SolTechnology.Core.Tale" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="SolTechnology.Core.Tale" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SolTechnology.Core.Tale --version 1.0.0
                    
#r "nuget: SolTechnology.Core.Tale, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SolTechnology.Core.Tale@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SolTechnology.Core.Tale&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=SolTechnology.Core.Tale&version=1.0.0
                    
Install as a Cake Tool

SolTechnology.Core.Tale

Workflows that read like prose. A narrative-driven orchestration framework for multi-step business processes — automated pipelines, interactive sagas, durable long-running workflows. Pluggable persistence, typed lifecycle, zero magic.

NuGet

Why Tale?

Most workflow engines force you to learn a DSL, fight a state machine, or accept a runtime that hijacks your code. Tale does the opposite — your workflow is a Tale: a fluent table of contents the engine reads top-to-bottom.

  • 📖 Tale Code philosophyTell() returns a Tale that narrates what happens. Chapters are named as actions, chained with Open/Read. The flow is linear and obvious.
  • 🧩 First-class DI — chapters and handlers are registered transients; inject repositories, HTTP clients, mediators, anything Scoped — it just works.
  • ⏸ Pause & resume — interactive chapters declare a typed input schema, the engine persists state, your API resumes the tale when the user replies.
  • 🔌 Pluggable persistence — in-memory by default, or bring your own (ITaleRepository) for SQLite / Postgres / Cosmos / EF Core / whatever. See DreamTravel.SQLite for a production-ready SQLite reference implementation.
  • 🛡 Typed lifecycle errorsTalePausedError, TaleCancelledError — never parse strings to detect state.
  • 🆔 Idempotency built-inIdempotency-Key header / idempotencyKey parameter deduplicates retries automatically.
  • 🌐 Opt-in REST API — inherit TaleController, get start / resume / cancel / state endpoints with the right HTTP semantics out of the box.

Installation

dotnet add package SolTechnology.Core.Tale

Registration

// In-memory persistence (default). Ideal for dev, tests, and single-process apps.
services.AddSolTale();

// Scan additional assemblies for chapters & handlers.
services.AddSolTale(
    configure: opts => opts.TaleIdPrefix = "ORDER",
    assemblies: typeof(MySaveCityTale).Assembly);

// Durable SQLite persistence — provided by the DreamTravel sample (DreamTravel.SQLite).
// Copy the sample project into your app and reference it, then:
services.AddSolTale(assemblies: typeof(MySaveCityTale).Assembly)
    .UseTaleRepository<SQLiteTaleRepository>();

// Bring your own backend — Postgres, Cosmos, EF Core, anything implementing ITaleRepository:
services.AddSolTale()
    .UseTaleRepository<MyPostgresTaleRepository>(ServiceLifetime.Scoped);

AddSolTale registers:

  • All concrete IChapter<> implementations as transient.
  • All concrete TaleHandler<,,> implementations as transient.
  • TaleHandlerRegistry (singleton) — name-to-type whitelist used by TaleController.
  • TaleManager (scoped) — the orchestrator.
  • ITaleRepository (singleton) — in-memory by default; swapped via UseTaleRepository<T>().

If no assemblies are passed, the entry assembly and the calling assembly are scanned for IChapter<> and TaleHandler<,,> implementations.

TaleOptions — engine-level policies:

Option Default Effect
TaleIdPrefix "STR" Prefix for generated Auid tale identifiers.
RestrictControllerToRegisteredHandlers true Whitelist enforcement on TaleController.

Quick start

1. Define input, context and output

public class OrderInput  { public int OrderId { get; set; } }
public class OrderOutput { public string Status { get; set; } = ""; }

public class OrderContext : Context<OrderInput, OrderOutput>
{
    public string CustomerEmail { get; set; } = "";
    public decimal TotalAmount { get; set; }
}

2. Write chapters

public class ValidateOrderChapter : Chapter<OrderContext>
{
    public override Task<Result> Read(OrderContext context)
        => context.Input.OrderId <= 0
            ? Result.FailAsTask("Invalid order ID")
            : Result.SuccessAsTask();
}

3. Tell the tale

public class ProcessOrderTale
    : TaleHandler<OrderInput, OrderContext, OrderOutput>
{
    public ProcessOrderTale(IServiceProvider sp, ILogger<ProcessOrderTale> logger)
        : base(sp, logger) { }

    protected override Tale<OrderOutput> Tell() =>
        Open<ValidateOrderChapter>()
            .Read<ProcessPaymentChapter>()
            .Read<SendConfirmationChapter>()
            .Do(ctx => ctx.Output.Status = "Completed")
            .Finale(ctx => ctx.Output);
}

4. Register and run

services.AddSolTale();

var tale = sp.GetRequiredService<ProcessOrderTale>();
var result = await tale.Handle(new OrderInput { OrderId = 42 }, CancellationToken.None);

That's it. No DSL. No state machine. No [Activity] attributes. Just a Tale that reads top-to-bottom.

Core concepts

TaleHandler<TInput, TContext, TOutput>

The orchestrator. Describe your workflow as a Tale in Tell()Open<T>() reads the first chapter, .Read<T>() chains the next, .Finale(ctx => ctx.Output) concludes. Compatible with CQRS — the same handler is also a IQueryHandler / ICommandHandler. Auto-registered as Transient by AddSolTale().

Tell() must be deterministic. It is re-invoked on every Handle call — including each resume of a paused tale — and the engine replays the rebuilt plan against the persisted chapter history. Branch on context state via Expect / Otherwise, never on ambient inputs (clock, random, feature flags) that can differ between the original run and a resume.

Context<TInput, TOutput>

The state object that flows between chapters. Holds Input, Output, and any intermediate values you want to share. State flows through the Context, not through return values — chapters return only a Result to signal success or failure.

Chapter<TContext>

A unit of business logic. Returns Result.Success() or Result.Fail("reason"). Resolved from DI, so it can declare any dependencies in its constructor.

public class LoadExistingCity : Chapter<SaveCityContext>
{
    private readonly ICityRepository _repository;

    public LoadExistingCity(ICityRepository repository) => _repository = repository;

    public override async Task<Result> Read(SaveCityContext ctx)
    {
        ctx.ExistingCity = await _repository.FindByName(ctx.Input.CityName);
        return Result.Success();
    }
}

Best practices

  • Keep each chapter focused on one thing. If the name needs an "And", split it.
  • Inject what you need — chapters are Transient, constructor injection is free.
  • Return Result.Fail("reason") instead of throwing. Exceptions are caught and wrapped, but explicit failures produce cleaner error trails.
  • Don't mutate ctx.Input — treat it as read-only. Write intermediate data as new properties on the Context.
  • Don't populate ctx.Output until the final chapter — keeps partial failures from leaking half-baked results.

InteractiveChapter<TContext, TChapterInput>

A chapter that pauses the tale and waits for caller input. Declares its expected input shape so consumers (a SPA, a form generator, OpenAPI-driven clients) can render the right UI without hardcoding field lists:

public class RequestCustomerDetails
    : InteractiveChapter<OrderContext, CustomerDetails>
{
    public override List<DataField> GetRequiredInputSchema() => new()
    {
        new() { Name = "Name",    Type = "string", Required = true  },
        new() { Name = "Email",   Type = "string", Required = true  },
        new() { Name = "Address", Type = "string", Required = false },
    };

    public override Task<Result> ReadWithInput(OrderContext ctx, CustomerDetails input)
    {
        if (string.IsNullOrWhiteSpace(input.Name))
        {
            return Result.FailAsTask("Customer name is required");
        }

        ctx.CustomerName  = input.Name;
        ctx.CustomerEmail = input.Email;
        return Result.SuccessAsTask();
    }
}

Minimal variant — just the logic, schema inferred from TChapterInput via reflection:

public class CollectEmailChapter : InteractiveChapter<OrderContext, EmailInput>
{
    public override Task<Result> ReadWithInput(OrderContext ctx, EmailInput input)
    {
        if (!input.Email.Contains("@"))
        {
            return Result.FailAsTask("Invalid e-mail");
        }

        ctx.CustomerEmail = input.Email;
        return Result.SuccessAsTask();
    }
}

Best practices

  • Validate inside ReadWithInput — treat the paused input as untrusted. Return Result.Fail with a human-readable reason; the error surfaces in the HTTP response.
  • Keep TChapterInput narrow. One chapter, one conceptual step of user interaction.
  • Override GetRequiredInputSchema() only when you need hand-tuned metadata (hints, default values, richer types). Otherwise, let reflection derive it.

TaleManager

Orchestrates persisted workflows: StartStory, ResumeStory, CancelStory, GetStoryState. Creates a fresh DI scope per invocation, so Scoped dependencies (DbContext, EF Core, per-request services) work correctly across pause/resume boundaries.

Use cases

Order checkout with pause-for-customer-details

A classic e-commerce flow. Validation and inventory check run automatically; the tale pauses to collect customer details from the user; payment and confirmation run after the resume.

public class OrderProcessingTale
    : TaleHandler<OrderInput, OrderContext, OrderOutput>
{
    public OrderProcessingTale(IServiceProvider sp, ILogger<OrderProcessingTale> log)
        : base(sp, log) { }

    protected override Tale<OrderOutput> Tell() =>
        Open<ValidateOrder>()                  // automated
            .Read<ReserveInventory>()          // automated
            .Read<RequestCustomerDetails>()    // ⏸ pause — interactive
            .Read<ProcessPayment>()            // automated, runs on resume
            .Read<SendConfirmation>()          // automated
            .Finale(ctx => ctx.Output);
}

Driving the lifecycle from your application code:

var manager = sp.GetRequiredService<TaleManager>();

// 1. Start — runs up to the first interactive chapter.
var start = await manager.StartStory<OrderProcessingTale, OrderInput, OrderContext, OrderOutput>(
    new OrderInput { Cart = cart });

if (start.IsSuccess && start.Data!.Status == TaleStatus.WaitingForInput)
{
    var taleId = start.Data.TaleId;

    // 2. Inspect the schema required for the paused chapter — render a form from it.
    foreach (var field in start.Data.CurrentChapter!.RequiredData)
    {
        Console.WriteLine($"  {field.Name} ({field.Type}) {(field.Required ? "*" : "")}");
    }

    // 3. Later, after the user submits, resume with the typed payload.
    var userInput = JsonSerializer.SerializeToElement(new CustomerDetails
    {
        Name  = "John Doe",
        Email = "john@example.com",
    });

    var resume = await manager.ResumeStory<OrderProcessingTale, OrderInput, OrderContext, OrderOutput>(
        taleId, userInput);

    if (resume.IsSuccess && resume.Data!.Status == TaleStatus.Completed)
    {
        // Payment processed, confirmation sent.
    }
}

Between start and resume, pull a snapshot of the tale state any time — audit logs, dashboards, "resume later" links in an email:

var state = await manager.GetStoryState(taleId);
Console.WriteLine($"Status: {state.Data!.Status}");
Console.WriteLine($"Current: {state.Data.CurrentChapter?.ChapterId}");
Console.WriteLine($"History: {state.Data.History.Count} chapters executed");

Approval workflow with multiple pause points

A request-for-approval pipeline where each approver pauses the tale in turn. Same mechanism, different shape — two interactive chapters in sequence.

public class ExpenseApprovalTale
    : TaleHandler<ExpenseInput, ExpenseContext, ExpenseOutput>
{
    public ExpenseApprovalTale(IServiceProvider sp, ILogger<ExpenseApprovalTale> log)
        : base(sp, log) { }

    protected override Tale<ExpenseOutput> Tell() =>
        Open<ClassifyExpense>()                    // automated
            .Read<ManagerApprovalChapter>()        // ⏸ pause — manager signs off
            .Read<FinanceApprovalChapter>()        // ⏸ pause — finance signs off (only if > threshold)
            .Read<PostToLedger>()                  // automated
            .Read<NotifyRequester>()               // automated
            .Finale(ctx => ctx.Output);
}

Two real callers, two resumes — between them the tale sits persisted in the repository. Persistence survives process restarts, so the manager can approve on Monday and finance on Wednesday.

User onboarding with progressive disclosure

Long-form interactive flow — collect minimum info up front, pause, collect more, pause, etc. The Context accumulates data between pauses; each interactive chapter only cares about its slice.

public class UserOnboardingTale
    : TaleHandler<OnboardingInput, OnboardingContext, OnboardingOutput>
{
    public UserOnboardingTale(IServiceProvider sp, ILogger<UserOnboardingTale> log)
        : base(sp, log) { }

    protected override Tale<OnboardingOutput> Tell() =>
        Open<CollectBasicInfoChapter>()            // ⏸ name, email
            .Read<SendVerificationEmail>()         // automated
            .Read<VerifyEmailChapter>()            // ⏸ verification code
            .Read<CollectPreferencesChapter>()     // ⏸ preferences
            .Read<CompleteOnboardingChapter>()     // automated — creates account
            .Finale(ctx => ctx.Output);
}

The engine automatically skips chapters already recorded in History when resuming, so refreshing the browser or retrying the request is safe.

Direct handler usage (simple, no persistence)

For a fully automated tale you can skip TaleManager and call the handler directly — it is a plain CQRS handler:

public class OrderController : ControllerBase
{
    private readonly ProcessOrderTale _tale;
    public OrderController(ProcessOrderTale tale) { _tale = tale; }

    [HttpPost]
    public async Task<IActionResult> Post([FromBody] OrderInput input)
    {
        var result = await _tale.Handle(input, CancellationToken.None);
        return result.IsSuccess ? Ok(result.Data) : BadRequest(result.Error);
    }
}

Pause as state (NOT pause as failure)

When a tale pauses, Handle(...) returns Result<TOutput>.Fail(new TalePausedError(...)). TaleManager transparently converts that into Result<TaleInstance>.Success(...) with Status = WaitingForInput. Detect pause with a type test, never with a string match:

if (result.IsFailure && result.Error is TalePausedError paused)
{
    // Tale paused at paused.ChapterId inside paused.TaleId
}

Analogous: TaleCancelledError.

Persistence

Persistence providers plug in through the builder returned by AddSolTale(). The default is in-memory, so interactive tales work the moment you call AddSolTale() with no arguments.

// Default: in-memory (dev/test/single-process).
services.AddSolTale();
// equivalent to:
services.AddSolTale().UseInMemoryTaleRepository();

// Production: SQLite — provided by the DreamTravel sample (DreamTravel.SQLite).
// Copy it into your app, reference it, then:
services.AddSolTale()
    .UseTaleRepository<SQLiteTaleRepository>();

// Bring your own backend — Postgres, Cosmos, EF Core, anything that implements ITaleRepository:
services.AddSolTale()
        .UseTaleRepository<MyPostgresTaleRepository>(ServiceLifetime.Scoped);

Implementing a custom backend? ITaleRepository is a five-method interface (FindById, FindByIdempotencyKey, ListAsync, SaveAsync, DeleteAsync). See InMemoryTaleRepository (in-box) and the sample's SQLiteTaleRepository (DreamTravel.SQLite) for reference implementations.

Idempotency

await manager.StartStory<…>(input, idempotencyKey: "order-42");

Retries with the same key return the existing tale instead of starting a new one. Works through the HTTP Idempotency-Key header on TaleController.StartStory too.

Cancellation

await manager.CancelStory(taleId);

Status becomes Cancelled. Subsequent resume attempts fail cleanly.

Error handling

Chapters return Result.Success() / Result.Fail("reason"). The tale runs on two tracks — won or lost. The first failed chapter switches the tale to the lost track and the remaining chapters are skipped; the failing Error becomes the tale result. Recover from an acceptable failure with .Otherwise<FallbackChapter>() (or an inline .Otherwise(ctx => …)) to switch back to the won track.

Marker error types — detect by type, never by string:

Error Meaning
TalePausedError Tale is waiting for user input at an interactive chapter.
TaleCancelledError Tale was cancelled by token or CancelStory.

Best practices

  • Use is TalePausedError / is TaleCancelledError in callers. Never Message.Contains(...).
  • Prefer Result.Fail("business reason") over throw in chapter bodies — exceptions are wrapped but your reason string is clearer than a stack trace.
  • Recover from an acceptable failure with .Otherwise<FallbackChapter>() (or an inline .Otherwise(ctx => …)) instead of letting it abort the tale.

REST API

Inherit TaleController and add your auth attributes:

public class OrderTaleController : TaleController
{
    public OrderTaleController(
        TaleManager manager,
        TaleHandlerRegistry registry,
        TaleOptions options,
        ILogger<TaleController> logger)
        : base(manager, registry, options, logger) { }
}

Endpoints:

Method Path Purpose
POST /api/tale/{handlerName}/start Start a new tale (whitelisted handlers only)
POST /api/tale/{taleId} Resume a paused tale
DELETE /api/tale/{taleId} Cancel a running / paused tale
GET /api/tale/{taleId} Current state
GET /api/tale/{taleId}/result Deserialized output (Completed only)

Idempotency-Key header is honored on /start — retried calls with the same key return the existing instance instead of creating a new one.

HTTP semantics:

  • Status.WaitingForInput202 Accepted
  • Status.Completed200 OK
  • Status.Failed / not found → 4xx

Only handlers registered through AddSolTale() are exposed (whitelist via TaleHandlerRegistry).

Security notes

  • Only handlers reachable through TaleHandlerRegistry are exposed — add authorization attributes ([Authorize(...)]) to your derived controller before exposing it publicly.
  • Do not place secrets or PII in Context. For SQLite persistence, prefer filesystem-level encryption or store references to an external secret store and load them on demand.
  • The sample SQLiteTaleRepository validates the supplied path. Do not interpolate user-controlled strings into connection strings.

Observability

Every log entry emitted by the engine is scoped with TaleId and TaleHandler, so configure your logger filters accordingly:

logging.AddFilter("SolTechnology.Core.Tale.Orchestration.TaleEngine", LogLevel.Information);

Versioning

Handler versioning (compatibility checks on resume after redeploy) is not currently implemented — see ADR-002 ("Future extensions → Handler versioning") for the planned SemVer-based design. Today the engine accepts any persisted state regardless of how the handler has changed; you are responsible for keeping chapter sequences and context shapes backward-compatible when redeploying with in-flight tales.

Not supported (yet)

Parallel chapter execution, durable retries with backoff, cross-process sagas / compensation, distributed tracing via ActivitySource, handler versioning. Tracked in ADR-002.

Working with AI Agent

Writing a Tale with an AI assistant (GitHub Copilot, Claude Code)? The repository ships a skill — a narrow, file-cited procedure your agent can read on demand:

  • command-query-event-tale — decide when a handler becomes a Tale, keep the Tell() Tale logic-free, name chapters one-verb-per-file, flow state through the Context, and choose where the Tale lives (Commands/Queries vs a domain-model DomainServices Tale vs a persisted interactive Workflows Tale).

It points at the binding rules in the Coding Guide — §4 — Tale framework keeps the anatomy and chapter rules in one place.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 63 7/6/2026