BPM_.Core 3.0.2

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

<p align="center"> <img src="assets/logo.png" alt="BPM.Core Logo" width="200" /> </p>

<h1 align="center">BPM.Core</h1>

<p align="center"> A lightweight, code-first Business Process Management engine for .NET.<br/> Define stateful workflows as directed graphs using a fluent builder API, execute them step-by-step with event sourcing, and let the engine enforce ordering, branching, and validation at runtime. </p>

<p align="center"> <a href="https://www.nuget.org/packages/BPM_.Core"><img src="https://img.shields.io/nuget/v/BPM_.Core.svg" alt="NuGet" /></a> <a href="https://www.nuget.org/packages/BPM_.Core"><img src="https://img.shields.io/nuget/dt/BPM_.Core.svg" alt="NuGet Downloads" /></a> <a href="https://github.com/Langusia/BPM_/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a> <img src="https://img.shields.io/badge/.NET-8.0%20%7C%209.0%20%7C%2010.0-512BD4" alt=".NET 8 | 9 | 10" /> </p>

Built on Marten (PostgreSQL event store) and MediatR.

Table of Contents

Installation

Install from NuGet:

dotnet add package BPM_.Core

Or via the Package Manager Console:

Install-Package BPM_.Core

Supported frameworks: .NET 8.0 | .NET 9.0 | .NET 10.0

Dependencies (included automatically):

Quick Start

1. Define events

Every command produces one or more events. Events inherit from BpmEvent:

public record OrderPlaced(string CustomerId, decimal Total) : BpmEvent;
public record OrderApproved() : BpmEvent;
public record OrderShipped(string TrackingNumber) : BpmEvent;

2. Define commands

Commands are MediatR requests decorated with [BpmProducer] to declare which events they emit:

[BpmProducer(typeof(OrderPlaced))]
public record PlaceOrder(string CustomerId, decimal Total) : IRequest<Guid>;

[BpmProducer(typeof(OrderApproved))]
public record ApproveOrder(Guid ProcessId) : IRequest;

[BpmProducer(typeof(OrderShipped))]
public record ShipOrder(Guid ProcessId) : IRequest;

3. Define the aggregate

The aggregate holds the current state, rebuilt from events via Apply() methods:

public class Order : Aggregate
{
    public string CustomerId { get; set; } = "";
    public decimal Total { get; set; }
    public bool IsApproved { get; set; }

    public void Apply(OrderPlaced e)   { CustomerId = e.CustomerId; Total = e.Total; }
    public void Apply(OrderApproved e) { IsApproved = true; }
    public void Apply(OrderShipped e)  { }
}

4. Define the process

Subclass BpmDefinition<T> and use the fluent builder to describe the workflow:

public class OrderDefinition : BpmDefinition<Order>
{
    public override ProcessConfig<Order> DefineProcess(IProcessBuilder<Order> builder)
    {
        return builder
            .StartWith<PlaceOrder>()
            .Continue<ApproveOrder>()
            .Continue<ShipOrder>()
            .End();
    }
}

5. Register at startup

builder.Services.AddBpm("bpm", connectionString, config =>
{
    config.AddAggregateDefinition<Order, OrderDefinition>();
});

6. Write command handlers

// Starting a process
public class PlaceOrderHandler(IProcessStore store) : IRequestHandler<PlaceOrder, Guid>
{
    public async Task<Guid> Handle(PlaceOrder req, CancellationToken ct)
    {
        var process = store.StartProcess<Order>(
            new OrderPlaced(req.CustomerId, req.Total));
        await store.SaveChangesAsync(ct);
        return process.Id;
    }
}

// Continuing a process
public class ApproveOrderHandler(IProcessStore store) : IRequestHandler<ApproveOrder>
{
    public async Task Handle(ApproveOrder req, CancellationToken ct)
    {
        var process = await store.FetchProcessAsync(req.ProcessId, ct);
        process.AppendEvent(new OrderApproved());
        await store.SaveChangesAsync(ct);
    }
}

Core Concepts

Concept Description
Aggregate Domain object whose state is rebuilt from an event stream. Subclass Aggregate and add Apply(TEvent) methods.
BpmEvent Base record for all process events. Carries a NodeId used internally for graph traversal.
Command A MediatR IRequest decorated with [BpmProducer(typeof(TEvent))]. Represents an action that advances the process.
BpmDefinition Abstract class where you wire up the process graph using the fluent builder API.
Node A vertex in the process graph. Each node maps to a command and knows its successors and predecessors.
IProcess Runtime handle to a process instance. Append events, validate commands, query next steps.
IProcessStore Creates, fetches, and persists process instances.

Defining a Process

All definitions start with BpmDefinition<TAggregate> and use the builder returned by IProcessBuilder<T>.

Sequential Steps

The simplest flow: one step after another.

builder
    .StartWith<InitiateRegistration>()
    .Continue<VerifyEmail>()
    .Continue<SetupProfile>()
    .Continue<CompleteRegistration>()
    .End();
InitiateRegistration -> VerifyEmail -> SetupProfile -> CompleteRegistration

Alternative Paths (Or)

Allow the user to take one of several paths at a given step:

builder
    .StartWith<SubmitApplication>()
    .Continue<ApproveViaManager>()
        .Or<ApproveViaDirector>()
        .Or<AutoApprove>()
    .Continue<FinalizeApplication>()
    .End();
                       +--> ApproveViaManager --+
                       |                        |
SubmitApplication -----+--> ApproveViaDirector -+--> FinalizeApplication
                       |                        |
                       +--> AutoApprove --------+

Conditional Branches (If / Else)

Branch based on the current aggregate state. The predicate is evaluated at runtime:

builder
    .StartWith<SubmitClaim>()
    .Continue<ReviewClaim>()
    .If(x => x.ClaimAmount > 10_000,
        branch => branch.Continue<ManagerApproval>())
    .Else(
        branch => branch.Continue<AutoApproval>())
    .Continue<IssuePayout>()
    .End();
                                  +--> ManagerApproval --+
SubmitClaim -> ReviewClaim -> IF -|                      +--> IssuePayout
                                  +--> AutoApproval ----+

Parallel Groups

Execute multiple steps in any order. The group completes when all members are done:

builder
    .StartWith<OpenAccount>()
    .Group(g =>
    {
        g.AddStep<VerifyIdentity>();
        g.AddStep<VerifyAddress>();
        g.AddStep<RunCreditCheck>();
    })
    .Continue<ActivateAccount>()
    .End();
                    +--> VerifyIdentity --+
                    |                     |
OpenAccount -> GROUP+--> VerifyAddress ---+--> ActivateAccount
                    |                     |
                    +--> RunCreditCheck --+

Optional Steps

Unlock a step conditionally. It becomes available but is not required to proceed:

builder
    .StartWith<PlaceOrder>()
    .Continue<ProcessPayment>()
    .If(x => x.IsPaid,
        branch => branch.UnlockOptional<ShipOrder>())
    .Continue<CompleteOrder>()
    .End();

AnyTime Steps

Steps that can be executed at any point once they become available (not bound by strict ordering):

builder
    .StartWith<StartOnboarding>()
    .ContinueAnyTime<UploadDocuments>()   // can be done at any point from here on
    .Continue<ScheduleInterview>()
    .End();

Guest Process (JumpTo)

Delegate to another aggregate's process before continuing:

builder
    .StartWith<CreateLoan>()
    .Continue<SubmitForApproval>()
    .JumpTo<CreditCheckAggregate>(sealedSteps: true)
    .Continue<DisburseFunds>()
    .End();

When sealedSteps: true, the guest process steps are hidden from the parent's available steps after the guest process completes.

Process Configuration

Pass options via .End(config => ...):

builder
    .StartWith<Begin>()
    .Continue<Finish>()
    .End(config =>
    {
        config.ExpirationSeconds = 3600; // process expires after 1 hour
    });

Running a Process

Start a new process

var process = store.StartProcess<Order>(new OrderPlaced("cust-1", 99.99m));
await store.SaveChangesAsync(ct);
Guid processId = process.Id;

Fetch and continue

var process = await store.FetchProcessAsync(processId, ct);

// Validate before executing
var validation = process.Validate<ApproveOrder>();
if (!validation.IsSuccess) return BadRequest(validation.Code);

// Append event and save
process.AppendEvent(new OrderApproved());
await store.SaveChangesAsync(ct);

Read aggregate state

var process = await store.FetchProcessAsync(processId, ct);
var order = process.AggregateAs<Order>();
// order.IsApproved == true

Query available next steps

var result = process.GetNextSteps();
var availableCommands = result.Data; // List<INode> - each node has .CommandType

Handle failures

process.AppendFail<Order>("Payment declined", new { Reason = "Insufficient funds" });
await store.SaveChangesAsync(ct);

Complex Branching Example

Here is a complete example of a loan application process that uses every branching feature:

                                          +--> ManualKycReview -----+
                                          |                         |
                      +---> IF(highRisk) -+                         |
                      |                   +--> AutoKycApproval ----+|
                      |                                             |
SubmitApplication --> CreditCheck --+--> IF(score > 700) ----------++--> GROUP +--> SignContract
                      |             |                               |          |
                      |             +--> DenyCreditApplication      |          +--> UploadDocuments
                      |                                             |          |
                      +---> RequestCoSigner(AnyTime)                |          +--> SetupAutoPayment
                                                                    |
                                                          FinalApproval

Events

public record ApplicationSubmitted(string ApplicantId, decimal Amount) : BpmEvent;
public record CreditChecked(int Score, bool IsHighRisk) : BpmEvent;
public record CoSignerRequested(string CoSignerName) : BpmEvent;
public record ManualKycCompleted(bool Passed) : BpmEvent;
public record AutoKycCompleted() : BpmEvent;
public record CreditDenied(string Reason) : BpmEvent;
public record FinalApprovalGranted() : BpmEvent;
public record ContractSigned(DateTime SignedAt) : BpmEvent;
public record DocumentsUploaded(string[] FileNames) : BpmEvent;
public record AutoPaymentConfigured(string AccountNumber) : BpmEvent;

Commands

[BpmProducer(typeof(ApplicationSubmitted))]
public record SubmitApplication(string ApplicantId, decimal Amount) : IRequest<Guid>;

[BpmProducer(typeof(CreditChecked))]
public record RunCreditCheck(Guid ProcessId) : IRequest;

[BpmProducer(typeof(CoSignerRequested))]
public record RequestCoSigner(Guid ProcessId, string CoSignerName) : IRequest;

[BpmProducer(typeof(ManualKycCompleted))]
public record ManualKycReview(Guid ProcessId) : IRequest;

[BpmProducer(typeof(AutoKycCompleted))]
public record AutoKycApproval(Guid ProcessId) : IRequest;

[BpmProducer(typeof(CreditDenied))]
public record DenyCreditApplication(Guid ProcessId, string Reason) : IRequest;

[BpmProducer(typeof(FinalApprovalGranted))]
public record GrantFinalApproval(Guid ProcessId) : IRequest;

[BpmProducer(typeof(ContractSigned))]
public record SignContract(Guid ProcessId) : IRequest;

[BpmProducer(typeof(DocumentsUploaded))]
public record UploadDocuments(Guid ProcessId, string[] Files) : IRequest;

[BpmProducer(typeof(AutoPaymentConfigured))]
public record SetupAutoPayment(Guid ProcessId, string AccountNumber) : IRequest;

Aggregate

public class LoanApplication : Aggregate
{
    public string ApplicantId { get; set; } = "";
    public decimal Amount { get; set; }
    public int CreditScore { get; set; }
    public bool IsHighRisk { get; set; }
    public bool IsCreditApproved { get; set; }
    public bool IsKycPassed { get; set; }

    public void Apply(ApplicationSubmitted e) { ApplicantId = e.ApplicantId; Amount = e.Amount; }
    public void Apply(CreditChecked e)        { CreditScore = e.Score; IsHighRisk = e.IsHighRisk; }
    public void Apply(CoSignerRequested e)    { }
    public void Apply(ManualKycCompleted e)   { IsKycPassed = e.Passed; }
    public void Apply(AutoKycCompleted e)     { IsKycPassed = true; }
    public void Apply(CreditDenied e)         { IsCreditApproved = false; }
    public void Apply(FinalApprovalGranted e) { IsCreditApproved = true; }
    public void Apply(ContractSigned e)       { }
    public void Apply(DocumentsUploaded e)    { }
    public void Apply(AutoPaymentConfigured e){ }
}

Process Definition

public class LoanApplicationDefinition : BpmDefinition<LoanApplication>
{
    public override ProcessConfig<LoanApplication> DefineProcess(
        IProcessBuilder<LoanApplication> builder)
    {
        return builder
            .StartWith<SubmitApplication>()

            // Credit check with a co-signer request available at any time
            .Continue<RunCreditCheck>()
                .OrAnyTime<RequestCoSigner>()

            // Branch on credit score
            .If(x => x.CreditScore > 700,

                // Good credit: KYC depends on risk level
                approved => approved
                    .If(x => x.IsHighRisk,
                        highRisk => highRisk.Continue<ManualKycReview>())
                    .Else(
                        lowRisk => lowRisk.Continue<AutoKycApproval>())
                    .Continue<GrantFinalApproval>())

            // Bad credit: deny
            .Else(denied => denied
                .Continue<DenyCreditApplication>())

            // Parallel closing tasks
            .Group(g =>
            {
                g.AddStep<SignContract>();
                g.AddStep<UploadDocuments>();
                g.AddStep<SetupAutoPayment>();
            })
            .End(config =>
            {
                config.ExpirationSeconds = 7 * 24 * 3600; // 7 days to complete
            });
    }
}

Handler Examples

public class SubmitApplicationHandler(IProcessStore store)
    : IRequestHandler<SubmitApplication, Guid>
{
    public async Task<Guid> Handle(SubmitApplication req, CancellationToken ct)
    {
        var process = store.StartProcess<LoanApplication>(
            new ApplicationSubmitted(req.ApplicantId, req.Amount));
        await store.SaveChangesAsync(ct);
        return process.Id;
    }
}

public class RunCreditCheckHandler(IProcessStore store)
    : IRequestHandler<RunCreditCheck>
{
    public async Task Handle(RunCreditCheck req, CancellationToken ct)
    {
        var process = await store.FetchProcessAsync(req.ProcessId, ct);

        // Call external credit service...
        int score = 750;
        bool highRisk = false;

        process.AppendEvent(new CreditChecked(score, highRisk));
        await store.SaveChangesAsync(ct);
    }
}

Registration

builder.Services.AddBpm("bpm", connectionString, config =>
{
    config.AddAggregateDefinition<LoanApplication, LoanApplicationDefinition>();
});

API Reference

Builder Methods

Method Description
StartWith<TCmd>() Begin the process with a required command
StartWithAnyTime<TCmd>() Begin with a flexible-order command
Continue<TCmd>() Add the next sequential step
ContinueAnyTime<TCmd>() Add a step executable at any point from here on
Or<TCmd>() Add an alternative to the previous step
OrAnyTime<TCmd>() Alternative as an any-time step
OrJumpTo<TAggregate>() Alternative that delegates to another process
If(predicate, branch) Conditional branch evaluated against aggregate state
Else(branch) Else branch for a preceding If
Group(configure) Parallel execution group (all must complete)
UnlockOptional<TCmd>() Optional step unlocked by a condition
JumpTo<TAggregate>(sealed) Delegate to a guest process
End(configure?) Finalize the definition

IProcess Methods

Method Description
AggregateAs<T>() Rebuild aggregate from events
AggregateOrNullAs<T>() Safe version returning null
TryAggregateAs<T>(out T?) Try-pattern aggregate rebuild
AppendEvent(BpmEvent) Validate and queue an event
ForceAppendEvents(params object[]) Queue events without validation
AppendFail<T>(desc, data) Record a process failure
Validate<T>() Check if a command can execute now
GetNextSteps() Get currently available commands
SaveChangesAsync() Persist to event store

IProcessStore Methods

Method Description
StartProcess<T>(BpmEvent) Create a new process with an initial event
StartProcess<T>() Create a new process without an initial event
FetchProcessAsync(Guid, CancellationToken) Load a process by ID
SaveChangesAsync(CancellationToken) Persist all pending changes

Result Types

BpmResult          // { IsSuccess, Code }
BpmResult<T>       // { IsSuccess, Code, Data }

enum Code { Success, NoSuccess, ProcessFailed, InvalidEvent, Expired }

Infrastructure

BPM.Core uses PostgreSQL via Marten for event storage. The included docker-compose.yml sets up the required database:

docker compose up -d

Connection string format:

Host=localhost;Port=5432;Database=bpm_db;Username=bpm_user;Password=bpm_password

License

BPM.Core is licensed under the MIT License.

Third-Party Licenses

This project depends on the following open-source packages:

Package License Link
Marten MIT License
MediatR.Contracts MIT License
Microsoft.Extensions.DependencyInjection MIT License
Microsoft.Extensions.Logging MIT License

Note: BPM.Core depends only on MediatR.Contracts (interfaces-only, MIT-licensed) — not the full MediatR package. Your application brings in whichever MediatR version it prefers.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  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
3.0.2 143 2/24/2026
3.0.1 107 2/23/2026
3.0.0 101 2/23/2026
2.6.9 115 2/20/2026