Architecture
Layer overview
Section titled “Layer overview”Fleans follows Clean Architecture / DDD layering:
fleans-api; Core and worker silos form one Orleans cluster whose state, clustering, reminders and streams plug into any Orleans-supported storage or queue — SQLite, PostgreSQL, Redis, Kafka and Azure Queue ship in the box. Open interactive diagram ↗| Layer | Project | Responsibility |
|---|---|---|
| Domain | Fleans.Domain | Aggregates, value objects, domain events. Pure C#, no infrastructure. |
| Application | Fleans.Application | Orleans grains, command/query services, orchestration. |
| Infrastructure | Fleans.Infrastructure | BPMN parsing (Camunda XML), expression evaluation (DynamicExpresso). |
| Persistence | Fleans.Persistence.* | EF Core storage — SQLite (dev) and PostgreSQL (production). |
| API | Fleans.Api | ASP.NET Core REST API with rate limiting. |
| Admin UI | Fleans.Web | Blazor Server admin UI — calls grains directly, no HTTP hop. |
| Host | Fleans.Aspire | .NET Aspire host that wires everything together for local dev. |
The central grain is WorkflowInstance — an Orleans JournaledGrain that uses event sourcing for workflow state, with a read-side projection written to EF Core (CQRS).
What Orleans gives us
Section titled “What Orleans gives us”Orleans is Microsoft’s virtual actor framework. Every workflow instance in Fleans is an Orleans grain — a lightweight, addressable object that lives in memory on one silo (server) at a time. You never manage threads, locks, or instance routing yourself. Orleans handles activation, placement, and failover transparently.
This means Fleans inherits Orleans’ distributed systems guarantees without building them from scratch: automatic cluster membership, location-transparent grain references, single-threaded grain execution (no concurrency bugs inside a workflow), and grain-level failure isolation.
Scalability
Section titled “Scalability”Linear horizontal scaling. Each WorkflowInstance grain is an independent unit with no shared coordinator. Adding silos to the Orleans cluster increases capacity linearly — grains redistribute automatically via cluster membership protocol.
Location transparency. A grain reference is stable regardless of which silo hosts the grain. Orleans routes calls to the correct silo. Your code never needs to know where a workflow instance lives.
Lazy activation. Grains activate on first access and deactivate after idle timeout. Cluster memory scales with active workflows, not total historical instances. A cluster with 10 million completed workflows and 100 active ones uses memory for ~100 grains.
CQRS database split. Writes go through FleanCommandDbContext; reads go through FleanQueryDbContext (with NoTracking by default). PostgreSQL deployments can route reads to a replica via ConnectionStrings:fleans-query — no code changes, just configuration. The workflow journal and snapshots have a context of their own (FleanJournalDbContext), so they can move to a separate or sharded database (ConnectionStrings:fleans-journal, Persistence:Journal:Provider=Citus); they are only ever accessed by instance id.
Fan-out via child grains. Embedded sub-processes and call activities spawn their own WorkflowInstance grains. A parent workflow fans work across the cluster automatically.
Direct grain access from admin UI. The Blazor Server admin panel (Fleans.Web) calls Orleans grains directly via the WorkflowEngine service — no HTTP API hop between the operator and workflow state.
Reliability
Section titled “Reliability”Event-sourced workflow state. WorkflowInstance extends JournaledGrain with an append-only event log. State is reconstructed deterministically from events. After a silo crash, Orleans reactivates the grain on a surviving silo and replays the log — no workflow state is lost. Appends are idempotent: each one carries a batch id, so when a write is stored but its reply is lost (a timeout after the commit), the retry finds its own batch and does not store the events twice.
Atomic state transitions. Events drain via DrainAndRaiseEvents() + ConfirmEvents() in a single transaction. A crash mid-execution never leaves a workflow half-advanced — either the full batch of events persists, or none do.
Per-workflow failure isolation. Each grain has its own event stream and lifecycle. A bug or exception in one workflow instance cannot corrupt another.
Automatic silo failover. When a silo leaves the cluster (crash, scale-down, rolling deploy), Orleans detects the membership change and reactivates affected grains on surviving silos. No manual intervention required.
Typed recovery paths. BPMN error boundary events, escalation events, and compensation handlers provide structured, spec-compliant recovery — not ad-hoc try/catch.
Structured audit trail. Every state-mutating grain method has a [LoggerMessage] log call with documented EventId ranges. No silent state mutations.
Automatic schema management. PostgreSQL uses MigrateAsync() on startup; SQLite uses EnsureCreated(). No manual migration steps on deploy.
Performance
Section titled “Performance”In-memory grain state. Mid-workflow steps read and write in-memory state on the hosting silo. Zero database round-trips until the next ConfirmEvents() flush.
Conditions in process, scripts one call away. A gateway’s conditions — like complex-gateway activation, multi-instance completion and conditional-event conditions — are evaluated inside the workflow instance, in the same call that reaches the gateway; each expression is compiled once per silo and reused, and every one is compiled at deploy, so a syntax error rejects the deploy instead of failing instances. A script task is sent straight to the script executor, a stateless worker that runs locally on a Combined silo and on a Worker silo otherwise; the executor reports the result back, and the instance does not wait for it in the meantime. Neither passes through a stream, so neither waits for a queue poll: a step takes a few milliseconds instead of about a hundred. If a script’s result does not come back in time (Fleans:Worker:ScriptResultTimeout, 30 s), the instance sends the script again, and fails the task after three tries.
ConfirmEvents transaction persists the batch, and lists and finished instances are read from a materialized projection instead of event replay. A running instance answers from its grain. Open interactive diagram ↗Materialized read-side projection, built off the hot path. An instance only appends to its event log. When an append changes what the read side shows, it publishes a small InstanceAdvanced(instanceId, version) notification on the cluster’s stream provider (Redis by default, Kafka optional), keyed by the instance id. A projector grain — on the worker silos, next to the script executor, so it stays off the silos that run workflows — reads the instance’s events itself — latest snapshot plus the events after it, the same replay the instance uses — and writes each instance’s row (status, timestamps, business id), so lists and filters never replay events. A burst of notifications for one instance becomes one projection, and a repeated one changes nothing: each row stores the event-log version it reflects. Lists therefore lag the instance by a moment. A finished instance also gets its details — activities, variables, condition results — so GET /instances/{id}/state and the admin UI read it straight from the database. Until its row says finished, they ask the instance’s grain, which holds the live state, so a caller always sees its own changes. The admin UI’s instance page says which one you are looking at: “vN · live” (from the grain) or “vN · read model”, and instance lists show each row’s state version. If a projection fails, the stream redelivers the notification. If it still fails, or after a silo crash, the instance asks for a new projection on its next activation.
Snapshots bound replay. Every 100 events (Persistence:SnapshotInterval) the engine stores the instance’s full state. Reactivation loads the latest snapshot and replays only the events after it. The event log stays the source of truth: snapshots and the read model are both derived from it.
NoTracking reads by default. The query DbContext skips EF Core change-tracking overhead on every read.
Event batching. All events from a single grain call persist in one transaction via ConfirmEvents(). A workflow step that emits 5 domain events makes 1 database write, not 5. The batch is stored as a single compact binary row, and variables are recorded as the values that changed, not as the whole scope.
Binary intra-cluster protocol. Orleans silos communicate via a binary protocol, not HTTP. Grain-to-grain calls within the cluster have minimal serialization overhead.
Dynamic variables without schema migration. Workflow variables use ExpandoObject + Newtonsoft.Json. Adding new variables to a BPMN process never requires a database migration.
Deployment matrix
Section titled “Deployment matrix”| Layout | Journal + read model | Membership, timers | Streams | Guide |
|---|---|---|---|---|
| Local dev | SQLite | Redis (Aspire) | Redis | dotnet run --project Fleans.Aspire |
| Simplest production — one engine silo | one PostgreSQL | same PostgreSQL | Memory | Simplest start: one PostgreSQL |
| Recommended for scale — roles scaled apart | Citus journal; PostgreSQL primary + replica | own PostgreSQL | Kafka | Recommended for scale |
Switching providers is configuration-only — set Persistence:Provider and the appropriate connection string. No code changes needed. See the Persistence reference for details.
Core and Worker silos
Section titled “Core and Worker silos”Fleans ships as a single binary (Fleans.Api) that can run in four roles, selected via the Fleans:Role configuration key:
| Role | What the silo hosts |
|---|---|
Combined (default) | Everything — WorkflowInstance, ProcessDefinition, timers, correlations, user tasks, and the StatelessWorker script executor. Right choice for single-node deployments. |
Core | Workflow coordination grains and event-sourcing storage. Evaluates conditions itself, inside each workflow instance; sends script tasks to a Worker silo. |
Worker | The StatelessWorker evaluators (ScriptExecutorGrain, and ConditionExpressionEvaluatorGrain for conditional start events) and the read-model projector, which builds instance lists and finished-instance views from the event log. Isolates CPU-bound script execution and projection writes from workflow state. Each Worker silo runs many scripts in parallel, and dispatches are spread across all Worker silos, so adding Worker silos adds script capacity. |
The role is stamped onto the silo name as {role}-{machine}-{guid}, so the Orleans Dashboard (and other silos via membership gossip) can see which role each silo is running. Set the role via the Fleans__Role environment variable, an appsettings.json entry, or a command-line argument.
A small deployment stays on Combined. Splitting to Core + scaled Worker instances is an operational choice you can make later without code changes.
A fourth value, Api, runs Fleans.Api as an Orleans client instead of a silo: it hosts no grains and serves only the REST API, so REST capacity scales separately from the silos that run workflows. It reaches the grains through the cluster’s gateways, like the Web admin UI.