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)