π’ Going Real-World with Meridian Workflow (Part 2)
Leave Approval System with Fluent API β Hooks, Validation, and REST API
Overview for the Workflow Engien: What Merdian Workflow
Part 1: Create a Leave Request Part 1 / choosing the right workflow-engine
In Part 1, we introduced the challenge of building business workflows and compared open-source engines. Among them was Meridian Workflow, a developer-first, type-safe engine for modeling approval flows in .NET 8.
In Part 2, we build a real-world Leave Request workflow using pure Fluent API β no templates or extensions (yet). This is the raw power of Meridian DSL.
π§ In Part 3, weβll refactor this using reusable
TemplateExtensions
and add attachment support for medical reports.
π§Ύ The Workflow
- Employee submits a request
- Supervisor reviews:
- If Days > 15, escalate to Section Head
- Section Head reviews (if applicable)
- HR gives final approval or rejection
- Rejections trigger notifications
- All transitions are auditable
π¦ 1. Workflow Data Model
public class LeaveRequestData : IWorkflowData
{
public string EmployeeName { get; set; } = string.Empty;
public LeaveTypes LeaveType { get; set; }
public int Days { get; set; }
public DateTime From { get; set; }
public DateTime To { get; set; }
public string Reason { get; set; } = string.Empty;
}
public enum LeaveTypes
{
Annual,
Sick,
Unpaid,
TimeOff
}
πͺ 2. Hooks for Notifications and Logging
public class NewLeaveRequestCreated : IWorkflowHook<LeaveRequestData>
{
public Task ExecuteAsync(WorkflowContext<LeaveRequestData> context)
{
Console.WriteLine($"π New request by {context.InputData?.EmployeeName}");
return Task.CompletedTask;
}
}
public class SendNotification(string source) : IWorkflowHook<LeaveRequestData>
{
public Task ExecuteAsync(WorkflowContext<LeaveRequestData> context)
{
Console.WriteLine($"π Notify {context.InputData?.EmployeeName} on {source}");
return Task.CompletedTask;
}
}
π§± 3. Workflow Definition Using Fluent API
public sealed class LeaveRequestWorkflow : IWorkflowBootstrapper
{
public void Register(IWorkflowDefinitionBuilder builder)
{
builder.Define<LeaveRequestData>("LeaveRequestWorkflow", workflow =>
{
workflow.AddHook(new NewLeaveRequestCreated(), WorkflowHookType.OnRequestCreated);
// State 1: Supervisor Review
workflow.State("UnderDirectManagerReview", state =>
{
state.IsStart();
state.Action("Approve", "UnderHRReview", action =>
{
action.AssignToRoles("supervisor");
action.When(data => data.Days > 15, "UnderSectionHeadReview");
action.WithValidation(data =>
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(data.Reason))
errors.Add("Reason is required.");
return errors;
});
});
state.Action("Reject", "Rejected", action =>
{
action.AssignToRoles("supervisor");
});
});
// State 2: Section Head Review
workflow.State("UnderSectionHeadReview", state =>
{
state.AddHook(new SendNotification("Section Head"), cfg =>
{
cfg.Mode = HookExecutionMode.Parallel;
}, StateHookType.OnStateEnter);
state.Action("Approve", "UnderHRReview", action =>
{
action.AssignToRoles("sectionHead");
});
state.Action("Reject", "Rejected", action =>
{
action.AssignToRoles("sectionHead");
});
});
// State 3: HR Review
workflow.State("UnderHRReview", state =>
{
state.Action("Approve", "Approved", action =>
{
action.AssignToRoles("hr");
action.AddHook(new SendNotification("HR Approval"), cfg =>
{
cfg.Mode = HookExecutionMode.Parallel;
cfg.IsAsync = true;
});
});
state.Action("Reject", "Rejected", action =>
{
action.AssignToRoles("hr");
action.AddHook(new SendNotification("HR Rejection"), cfg =>
{
cfg.Mode = HookExecutionMode.Parallel;
});
});
});
// Final States
workflow.State("Approved", state =>
{
state.IsCompleted();
state.AddHook(new SendNotification("Final Approval"), cfg =>
{
cfg.Mode = HookExecutionMode.Parallel;
}, StateHookType.OnStateEnter);
});
workflow.State("Rejected", state =>
{
state.IsRejected();
state.AddHook(new SendNotification("Final Rejection"), cfg =>
{
cfg.Mode = HookExecutionMode.Parallel;
}, StateHookType.OnStateEnter);
});
workflow.PrintToConsole();
});
}
}
π 4. Minimal REST API for Integration
builder.Services.AddMeridianWorkflow(options =>
{
options.ConfigureDb(cfg => cfg.Use(db => db.UseInMemoryDatabase("WorkflowTestDb")));
options.Workflows = [ new LeaveRequestWorkflow() ];
});
var currentUser = "admin";
var roles = new[] { "supervisor", "sectionHead", "hr" };
app.MapPost("/create", ([FromServices] IWorkflowService<LeaveRequestData> wf, [FromBody] LeaveRequestData req)
=> wf.CreateRequestAsync(req, currentUser));
app.MapPost("/action/{action}/{id}", ([FromServices] IWorkflowService<LeaveRequestData> wf, string action, Guid id)
=> wf.DoActionAsync(id, action, currentUser, roles, []));
app.MapGet("/request/{id}", ([FromServices] IWorkflowService<LeaveRequestData> wf, Guid id)
=> wf.GetRequestAsync(id));
app.MapPost("/tasks", ([FromServices] IWorkflowService<LeaveRequestData> wf)
=> wf.GetUserTasksAsync(currentUser, roles, []));
β Summary
In this article, we:
- β Modeled a leave request approval flow
- π Created multi-role state transitions with conditions
- π Used hooks for logs and notifications
- π§ͺ Added inline validations
- π Exposed REST APIs for frontend integration
This was built using raw Fluent API only β no abstraction, no templates.
π Up Next: Part 3
In Part 3, weβll:
- β»οΈ Refactor using
TemplateExtensions
for DRY workflow definitions - π Add attachment support (e.g., medical reports)
- πͺ Centralize validations, hooks, and task assignments
- π§± Prepare the workflow for scalable, multi-team usage
π¬ What do you think?
Have you implemented approval workflows before?
Would you prefer the raw fluent API approach, or do you lean toward reusable templates?
Let me know how you'd structure your own leave request flow β or what you'd like to see covered in Part 3!
Top comments (0)