SolTechnology.Core.Story 0.8.0

dotnet add package SolTechnology.Core.Story --version 0.8.0
                    
NuGet\Install-Package SolTechnology.Core.Story -Version 0.8.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.Story" Version="0.8.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SolTechnology.Core.Story" Version="0.8.0" />
                    
Directory.Packages.props
<PackageReference Include="SolTechnology.Core.Story" />
                    
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.Story --version 0.8.0
                    
#r "nuget: SolTechnology.Core.Story, 0.8.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.Story@0.8.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.Story&version=0.8.0
                    
Install as a Cake Addin
#tool nuget:?package=SolTechnology.Core.Story&version=0.8.0
                    
Install as a Cake Tool

SolTechnology.Core.Story

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 Story?

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

  • ๐Ÿ“– Tale Code philosophy โ€” Tell() 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 story when the user replies.
  • ๐Ÿ”Œ Pluggable persistence โ€” in-memory by default, or bring your own (IStoryRepository) for SQLite / Postgres / Cosmos / EF Core / whatever. See DreamTravel.SQLite for a production-ready SQLite reference implementation.
  • ๐Ÿ›ก Typed lifecycle errors โ€” StoryPausedError, StoryCancelledError โ€” never parse strings to detect state.
  • ๐Ÿ†” Idempotency built-in โ€” Idempotency-Key header / idempotencyKey parameter deduplicates retries automatically.
  • ๐ŸŒ Opt-in REST API โ€” inherit StoryController, get start / resume / cancel / state endpoints with the right HTTP semantics out of the box.

Installation

dotnet add package SolTechnology.Core.Story

Registration

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

// Scan additional assemblies for chapters & handlers.
services.RegisterStories(
    configure: opts => opts.StoryIdPrefix = "ORDER",
    assemblies: typeof(MySaveCityStory).Assembly);

// Durable SQLite persistence โ€” provided by the DreamTravel sample (DreamTravel.SQLite).
// Copy the sample project into your app and reference it, then:
services.RegisterStories(assemblies: typeof(MySaveCityStory).Assembly)
    .UseStoryRepository<SQLiteStoryRepository>();

// Bring your own backend โ€” Postgres, Cosmos, EF Core, anything implementing IStoryRepository:
services.RegisterStories()
    .UseStoryRepository<MyPostgresStoryRepository>(ServiceLifetime.Scoped);

RegisterStories registers:

  • All concrete IChapter<> implementations as transient.
  • All concrete StoryHandler<,,> implementations as transient.
  • StoryHandlerRegistry (singleton) โ€” name-to-type whitelist used by StoryController.
  • StoryManager (scoped) โ€” the orchestrator.
  • IStoryRepository (singleton) โ€” in-memory by default; swapped via UseStoryRepository<T>().

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

StoryOptions โ€” engine-level policies:

Option Default Effect
StoryIdPrefix "STR" Prefix for generated Auid story identifiers.
RestrictControllerToRegisteredHandlers true Whitelist enforcement on StoryController.

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 story

public class ProcessOrderStory
    : StoryHandler<OrderInput, OrderContext, OrderOutput>
{
    public ProcessOrderStory(IServiceProvider sp, ILogger<ProcessOrderStory> 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.RegisterStories();

var story = sp.GetRequiredService<ProcessOrderStory>();
var result = await story.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

StoryHandler<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 RegisterStories().

Tell() must be deterministic. It is re-invoked on every Handle call โ€” including each resume of a paused story โ€” 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 story 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.

StoryManager

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 story pauses to collect customer details from the user; payment and confirmation run after the resume.

public class OrderProcessingStory
    : StoryHandler<OrderInput, OrderContext, OrderOutput>
{
    public OrderProcessingStory(IServiceProvider sp, ILogger<OrderProcessingStory> 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<StoryManager>();

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

if (start.IsSuccess && start.Data!.Status == StoryStatus.WaitingForInput)
{
    var storyId = start.Data.StoryId;

    // 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<OrderProcessingStory, OrderInput, OrderContext, OrderOutput>(
        storyId, userInput);

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

Between start and resume, pull a snapshot of the story state any time โ€” audit logs, dashboards, "resume later" links in an email:

var state = await manager.GetStoryState(storyId);
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 story in turn. Same mechanism, different shape โ€” two interactive chapters in sequence.

public class ExpenseApprovalStory
    : StoryHandler<ExpenseInput, ExpenseContext, ExpenseOutput>
{
    public ExpenseApprovalStory(IServiceProvider sp, ILogger<ExpenseApprovalStory> 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 story 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 UserOnboardingStory
    : StoryHandler<OnboardingInput, OnboardingContext, OnboardingOutput>
{
    public UserOnboardingStory(IServiceProvider sp, ILogger<UserOnboardingStory> 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 story you can skip StoryManager and call the handler directly โ€” it is a plain CQRS handler:

public class OrderController : ControllerBase
{
    private readonly ProcessOrderStory _story;
    public OrderController(ProcessOrderStory story) { _story = story; }

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

Pause as state (NOT pause as failure)

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

if (result.IsFailure && result.Error is StoryPausedError paused)
{
    // Story paused at paused.ChapterId inside paused.StoryId
}

Analogous: StoryCancelledError.

Persistence

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

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

// Production: SQLite โ€” provided by the DreamTravel sample (DreamTravel.SQLite).
// Copy it into your app, reference it, then:
services.RegisterStories()
    .UseStoryRepository<SQLiteStoryRepository>();

// Bring your own backend โ€” Postgres, Cosmos, EF Core, anything that implements IStoryRepository:
services.RegisterStories()
        .UseStoryRepository<MyPostgresStoryRepository>(ServiceLifetime.Scoped);

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

Idempotency

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

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

Cancellation

await manager.CancelStory(storyId);

Status becomes Cancelled. Subsequent resume attempts fail cleanly.

Error handling

Chapters return Result.Success() / Result.Fail("reason"). The story runs on two tracks โ€” won or lost. The first failed chapter switches the story to the lost track and the remaining chapters are skipped; the failing Error becomes the story 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
StoryPausedError Story is waiting for user input at an interactive chapter.
StoryCancelledError Story was cancelled by token or CancelStory.

Best practices

  • Use is StoryPausedError / is StoryCancelledError 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 story.

REST API

Inherit StoryController and add your auth attributes:

public class OrderStoryController : StoryController
{
    public OrderStoryController(
        StoryManager manager,
        StoryHandlerRegistry registry,
        StoryOptions options,
        ILogger<StoryController> logger)
        : base(manager, registry, options, logger) { }
}

Endpoints:

Method Path Purpose
POST /api/story/{handlerName}/start Start a new story (whitelisted handlers only)
POST /api/story/{storyId} Resume a paused story
DELETE /api/story/{storyId} Cancel a running / paused story
GET /api/story/{storyId} Current state
GET /api/story/{storyId}/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.WaitingForInput โ†’ 202 Accepted
  • Status.Completed โ†’ 200 OK
  • Status.Failed / not found โ†’ 4xx

Only handlers registered through RegisterStories() are exposed (whitelist via StoryHandlerRegistry).

Security notes

  • Only handlers reachable through StoryHandlerRegistry 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 SQLiteStoryRepository validates the supplied path. Do not interpolate user-controlled strings into connection strings.

Observability

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

logging.AddFilter("SolTechnology.Core.Story.Orchestration.StoryEngine", 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 stories.

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 Story 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-story โ€” decide when a handler becomes a Story, keep the Tell() Tale logic-free, name chapters one-verb-per-file, flow state through the Context, and choose where the Story lives (Commands/Queries vs a domain-model DomainServices Story vs a persisted interactive Workflows Story).

It points at the binding rules in the Coding Guide โ€” ยง4 โ€” Story 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
0.8.0 42 6/23/2026
0.7.0 113 5/8/2026
0.6.0 155 12/26/2025