DEV Community

Cover image for Implementing a Real-World Approval Workflow with Meridian (Part 2)
Mohammad Anzawi
Mohammad Anzawi

Posted on

Implementing a Real-World Approval Workflow with Meridian (Part 2)

๐Ÿข 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
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿช 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿงฑ 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();
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

๐ŸŒ 4. Minimal REST API for Integration

builder.Services.AddMeridianWorkflow(options =>
{
    options.ConfigureDb(cfg => cfg.Use(db => db.UseInMemoryDatabase("WorkflowTestDb")));
    options.Workflows = [ new LeaveRequestWorkflow() ];
});
Enter fullscreen mode Exit fullscreen mode
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, []));
Enter fullscreen mode Exit fullscreen mode

โœ… 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!

๐Ÿ“Ž Resources


Top comments (0)