DEV Community

Cover image for How to Validate Complex Web Forms Using a Business Rules Engine
Alex
Alex

Posted on Originally published at codeeffects.com

How to Validate Complex Web Forms Using a Business Rules Engine

The Problem

You have a complex web form - an insurance claim, a loan application, a vendor registration - that internal or public users of your web app must fill out and submit to your server. Your server has to validate each submission against complex, hard-coded business logic that changes often, usually at the request of product owners or business analysts.

The Current Solution

Your team usually employs one of two common ways of evaluating complex business logic:

  • Your IT team maintains a separate business layer, or a separate internal .NET library, that declares the entire validation logic. It takes a C# source class that represents the web form and uses a series of if/else clauses, switch statements, recursive calls, and custom error handling to validate it against the business logic of the web form. There are two major problems with this approach:
  1. The business logic is part of your code base. That means your team must re-test and re-deploy the project every time a part of the validation logic changes.
  2. The business team does not fully own the business logic of that form - your development team must be heavily involved in each and every change.
  • Your team develops and maintains custom evaluation code for this web form using a collection of database tables, spreadsheets, config files, and so on. This approach works, especially if the validation logic doesn't change that often. But it also has two major problems:
  1. Same as above: your business team does not fully own the business logic - they still need developers to introduce each and every change.
  2. The developer who created this evaluation code takes most of that knowledge with them when they leave the company. It's custom code built for a very specific business need, so there's usually little or no documentation, and new hires spend a lot of time and resources trying to deploy logic updates, sometimes inadvertently causing serious business issues.

The Proposed Solution

Move the entire evaluation logic to a free or cost-effective business rules engine that lets product owners author and manage the business logic used by the company's systems and services, and evaluate it against web form submissions, without involving IT beyond the initial setup.

One of the benefits of this approach is that it can stay as simple as described in this article, or go well beyond a single web form validation if needed. As the company grows, the same decision engine can just as easily power an intelligent analysis of all the data the company receives from various sources - risk management and estimation, fraud detection, handling of malicious attempts, logging of everything happening across the company's infrastructure for later analysis, and even custom or common AI orchestrations.

This article discusses the implementation and use of the Code Effects decision automation platform. You can fork the GitHub repo of the ASP.NET Core demo project discussed here, compile and run it locally to see the web form validation in action. The demo uses the Engine Free edition, so you'll be able to run it without spending anything.

Full Disclosure: I work for Code Effects Software. This article is not so much a product promotion as a way to let developers and business teams learn about well-established, cost-effective approaches to business rules management and decision automation.

Briefly: What is a Business Rules Engine?

Imagine your company receives a loan application from an external source (in this example, a web form) that your project maps to a local Application C# class and evaluates using the following business logic:

if(application.AnnualIncome > 100000 &&
    CompanyAiEngine.GetCreditScore(application.ID) >= 620)
{
    application.Status = Statuses.Success;
    application.ApprovalDate = GetTodayDate();
    CompanyWorkflow.Approve(application);
    SendNotification(application.Contact.Email, "Your application has been approved");
}
else
{
    CompanyWorkflow.Declyne(application);
    SendNotification(contact.Email, "Your application has been denied");
}
Enter fullscreen mode Exit fullscreen mode

This setup has all the usual problems of hard-coded business logic listed above. Your team can transform it into a BA-friendly system by letting your business users type the same validation logic into a simple web-based UI as an easy-to-understand, language-like rule:

if
    Annual Income is greater than 100000 and
    Credit Score(Application ID) is greater or equal to 620
        then
            set Status to Success and
            set Approval Date to Today and
            Approve and
            Notify Applicant (Email, "Your application has been approved")

else
            Decline and
            Notify Applicant (Email, "Your application has been denied")
Enter fullscreen mode Exit fullscreen mode

Your system saves this rule as a simple XML string in a database or a text file. Product owners can use the same web-based UI to load it later for any updates. When your system receives the Application object again, it only needs to load that rule (or a series of rules as a ruleset) and evaluate it against that object:

var ruleXml = CompanyRuleStorage.GetRule(ruleID);
var evaluator = new Evaluator<Application>(ruleXml);
var success = evaluator.Evaluate(application);
Enter fullscreen mode Exit fullscreen mode

Note that all your data surfaces, mappings, services, and methods used throughout your code base - like GetCreditScore, SendNotification, CompanyWorkflow, and CompanyAiEngine in the example above - stay the same. Your business team can now define and manage the business logic they own without your involvement whatsoever.

This, in a nutshell, is what the Code Effects business rules engine is and how it operates.

Implementation

Code Effects consists of two major components: the free web-based rule editor that helps create and manage rules in any web app, including React, Angular, and ASP.NET Core, and the execution engine that can execute those rules in any .NET code.

Real-life systems usually manage rules in one system and execute them in another. For simplicity, our demo app does both using the same /wwwroot/index.html page, which hosts the rule editor on top and the form below it. The form is a user registration application, generated dynamically by /Services/API.cs using the public fields of /Models/User.cs.

/Services/API.cs declares endpoints that generate the web form and the initial settings for the editor. It also loads, saves, and deletes rules using the temporary RuleStorage dictionary, and evaluates the rule currently displayed in the editor against the User data from the web form.

The /wwwroot/index.html page declares two <div> containers for the editor and the form. It also declares the page's main script, which initializes the editor and defines the functions that handle rule loading, saving, deletion, and evaluation on the client side.

Note that platfom supports two types of rules: evaluation (only answers one question: True or False) and execution (can invoke public methods with params and set field values). Use the Rules menu to select either type - this demo project works with both out of the box.

To test this, compile and run the demo project, create a new rule in the editor, fill out the form with data that makes at least some sense in relation to the rule you just created, and click the Evaluate button. The "info" <div> will display the output of the evaluation. This demo uses the Engine Free edition, which holds the evaluation of any rule for 5 seconds - keep that in mind.

Here are the implementation steps in detail:

Step 1: The References

Create a new ASP.NET Core web application, or use an existing one.

Reference the CodeEffects.Rule.Engine and CodeEffects.Rule.Editor assemblies from NuGet. This also references CodeEffects.Rule.Common - the dependency that declares functionality and types common to both components.

Install the latest version of the codeeffects npm package. The editor needs the main JavaScript and styles from that package. The demo project automatically copies them from their package location to the /wwwroot folder when you build the project. These files are referenced in the header of the index.html page:

<link rel="stylesheet" href="/codeeffects.common.css" />
<link rel="stylesheet" href="/codeeffects.light.css" />
<script type="module" src="codeeffects.editor.js"></script>
Enter fullscreen mode Exit fullscreen mode

Notice the type="module" attribute of the <script> tag. The npm package provides a modularized codeeffects.editor.js file that can be used in client frameworks and plain web apps alike. It should therefore be added as a module to plain HTML documents.

Add two div containers to the <body> of index.html, for the rule editor and the form:

<div id="divEditor"></div>
<div id="divForm"></div>
Enter fullscreen mode Exit fullscreen mode

At this point, the platform is installed. Let's add two more containers: one for the info messages, and one for the Evaluate button that will trigger the form evaluation process.

<div id="divInfo">Create a rule, fill out the form with test values, and click Evaluate</div>
<div><input type="button" id="btnEvaluate" value="Evaluate"/></div>
Enter fullscreen mode Exit fullscreen mode

Next, we need to wire it all up.

Step 2: The Source Object

The data to be evaluated against your rules (let's call it data) may come to your project in many shapes and formats from all kinds of sources. But eventually, any decision engine requires a type the data can be mapped to. Such a type is often called Facts, Data Model, or Evaluation Context. Code Effects calls it a Source Object; the platform supports either declared .NET classes, dynamic sources, and meta descriptions that can be mapped to the source object, or deserialized into it, at evaluation time.

Depending on your business needs, you may declare many source objects and build rules for any number of them. To keep things simple, let's just use only one - the /Models/User.cs class:

using CodeEffects.Rule.Common.Attributes;

namespace CodeEffects.Demo.Asp.Models;

public class User
{
    [Field(DisplayName = "ID", Min = 0, Settable = false, IncludeInCalculations = false, AllowCalculations = false)]
    public int? Id { get; set; }

    [Field(DisplayName = "First Name", Max = 200, Filter = "name")]
    public string? FirstName { get; set; }

    [Field(DisplayName = "Last Name", Max = 200, Filter = "name")]
    public string? LastName { get; set; }

    [Field(DisplayName = "Date of Birth", DateTimeFormat = "MMM dd, yyyy")]
    public DateTime? Dob { get; set; }

    public Gender Gender { get; set; } = Gender.Undefined;

    [Field(DisplayName = "Total Savings", Min = 0, Max = 10000000)]
    public decimal? Savings { get; set; }

    [Field(DisplayName = "Total Debt", Min = 0, Max = 10000000)]
    public decimal? Debt { get; set; }

    [Field(DisplayName = "Mobile", Max = 12, Filter = "phone")]
    public string? CellPhone { get; set; }

    [Field(DisplayName = "Home Phone", Max = 12, Filter = "phone")]
    public string? HomePhone { get; set; }

    [Field(DisplayName = "Work Phone", Max = 12, Filter = "phone")]
    public string? WorkPhone { get; set; }

    [Field(DisplayName = "Home Email", Max = 300, Filter = "email")]
    public string? HomeEmail { get; set; }

    [Field(DisplayName = "Work Email", Max = 300, Filter = "email")]
    public string? WorkEmail { get; set; }

    [Field(DisplayName = "Admin")]
    public bool? IsAdmin { get; set; }

    [Field(DisplayName = "Home Address")]
    public Address Home { get; set; } = new Address();

    [Field(DisplayName = "Work Address")]
    public Address Work { get; set; } = new Address();

    [ExcludeFromEvaluation]
    public string? Output { get; set; }

    [Method(DisplayName = "Full Name")]
    public string FullName()
    {
        return $"{LastName ?? "--"}, {FirstName ?? "--"}";
    }

    public void Log(string message)
    {
        this.Output += $"{message} ";
    }

    [Action(DisplayName = "Send SMS")]
    public void Sms([Parameter(Filter = "phone")] string phone, string message)
    {
        // Send implementation goes here
        this.Output += $"SMS sent: {message} ";
    }

    [Action(DisplayName = "Send Email")]
    public void Email([Parameter(Filter = "email")] string email, string message)
    {
        // Send implementation goes here
        this.Output += $"Emailed: {message} ";
    }

}

public enum Gender
{
    Undefined = 0,
    Male = 1,
    Female = 2
}

public class Address
{
    [Parent("Home", "Home Street")]
    [Parent("Work", "Work Street")]
    [Field(Max = 200, Filter = "street")]
    public string? Street { get; set; }

    [Parent("Home", "Home City")]
    [Parent("Work", "Work City")]
    [Field(Max = 200, Filter = "city")]
    public string? City { get; set; }

    [Parent("Home", "Home Zip")]
    [Parent("Work", "Work Zip")]
    [Field(Max = 5, Filter = "postal")]
    public string? Postal { get; set; }

    [Parent("Home", "Home State")]
    [Parent("Work", "Work State")]
    public States State { get; set; } = States.Undefined;
}
public enum States
{
    Undefined = 0,

    Arizona = 1,
    California = 2,
    Georgia = 3,

    [EnumItem("North Carolina")]
    NorthCarolina = 4,
    [EnumItem("South Carolina")]
    SouthCarolina = 5,
    [EnumItem("South Dacota")]
    SouthDacota = 6
}
Enter fullscreen mode Exit fullscreen mode

Notice the heavy use of the Field, Method, Action, and other attributes declared in the CodeEffects.Rule.Common.Attributes namespace. They can be used to customize pretty much any important aspect of rule authoring. You can also use those attributes as guardrails, making sure users can't insert rule elements in places where they'd be invalid or irrelevant. The Code Effects platform is known for its "If you saved it, it's valid" approach to rule design, which doesn't require additional human validation after a rule is created and saved.

Step 3: The API

Besides the rule management functionality initiated on the client by the rule author and managed on the server, the rule editor needs the server to generate metadata for the source object's field menus, generate the form's HTML from the source object's fields, and provide the UI settings to instantiate itself on the client. The demo's /Services/Api.cs class uses Minimal APIs to provide the /api/settings endpoint for that:

using System.Collections.Concurrent;
using CodeEffects.Demo.Asp.Models;
using CodeEffects.Rule.Common.Models;
using CodeEffects.Rule.Editor;
using CodeEffects.Rule.Editor.Models;
using CodeEffects.Rule.Engine;
using CodeEffects.Toolkit;

namespace CodeEffects.Demo.Asp.Services;

public static class Api
{
    // Simple in-memory rule storage for the sake of this demo
    private static readonly ConcurrentDictionary<string, Models.Rule> RuleStorage = new();

    public static void MapEndpoints(this WebApplication app)
    {
        // Returns client settings for the editor
        // and HTML string of the web form
        app.MapGet("/api/settings", () =>
        {
            var settings = new Settings();

            // Get an instance of the editor
            var editor = GetControl();

            // Generate UI settings for the editor
            settings.GlobalData = editor.GetGlobalData();

            // Generate the source-related settings for the editor
            settings.EditorData = editor.GetEditorData();

            // Generate html labels and input controls for the web form
            // using Code Effects Toolkit
            settings.FormHtml = Html.GenerateForm(editor.SourceType);

            return Results.Ok(settings);
        });
    }

    private static Control GetControl()
    {
        // Use the ID of the div element that contains the editor
        var editor = new Control("divEditor");

        // Set the editor to use the User class as its source object
        editor.SourceType = typeof(User);
        editor.EvaluationMode = EvaluationMode.Execution;

        return editor;
    }
}

public class Settings
{
    public string? GlobalData { get; set; }
    public string? EditorData { get; set; }
    public string? FormHtml { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

A couple of important things are going on in this code example:

  1. The RuleStorage dictionary is a stand-in for rule storage. It's a demo, hence the simplicity - in a real-life application you'd likely store your rules in a database or file system. The platform doesn't enforce a vendor-defined rule storage, giving you the freedom to store your business logic using your own environment, data access, and security context.
  2. The /api/settings endpoint instantiates the editor and settings, then generates the UI and source settings for the editor. It also uses the Code Effects Toolkit to dynamically generate the form's HTML for all public fields of the User class. The Toolkit is an open-source .NET Standard 2.0 library that takes any .NET class and generates HTML <input> and <select> controls for each public field. It can also populate the generated form with a class instance's values - we'll use that functionality later in this article, when we submit our form for evaluation.

Step 4: The Client

The /wwwroot/index.html page declares the main script that drives all client-side functionality of the demo project. Here's the code that initializes the editor and the form:

// Declare editor as a global variable
var editor = null;

// Initializes and renders
// the editor and the form
async function settings()
{
    const res = await fetch(
        '/api/settings',
        {headers: {'Accept': 'application/json'}});

    const settings = await res.json();

    // Init the editor
    editor = $rule.init(settings.globalData);

    // Set the rule management callbacks
    editor.setCallbacks(load, remove, save);

    // Load the source data into the editor
    editor.loadSettings(settings.editorData);

    // Render the User form
    document.getElementById("divForm").innerHTML = settings.formHtml;

    // Evaluate the rule currently displayed in the rule area
    // when the user clicks the Evaluate button
    document.getElementById("btnEvaluate").onclick = function (e) { evaluate(); };
};

// Get everything going on page load
settings();
Enter fullscreen mode Exit fullscreen mode

The script fetches the Settings object from the /api/settings endpoint on the page's load event. The editor supports two types of client initialization: client centric and server centric. This demo uses server-centric initialization, which does the following:

  • Calls init(..) via the $rule global shortcut to get an instance of the editor.
  • Sets the callback functions the editor invokes when the rule author wants to save, load, or delete a rule (not shown here for simplicity).
  • Loads the source settings.
  • Assigns the HTML of the form generated by the Code Effects Toolkit on the server to its <div> container, rendering the entire web form on the page.
  • Subscribes to the click event of the Evaluate button using a handler that calls evaluate() (shown below).

If you run the demo project at this point, you'll get a UI that renders the editor on top and the form representing the User class below it (the demo uses styles and HTML elements omitted here for clarity; this screenshot only shows about half of the actual form):

User Web Form UI

Step 5: The Evaluation

To evaluate this form against any rule displayed in the rule area, we need two things:

  • First, an endpoint on the server that uses the Evaluator class to execute the rule against the data, set any values, and invoke any action methods required by the rule, then return the evaluation result.

/Services/Api.cs declares the /api/evaluate endpoint, which uses the Control class to take care of all the internal plumbing of rule evaluation:

app.MapPost("/api/evaluate", (Request req) =>
{
    var editor = GetControl();
    // Load the rule into the editor
    editor.LoadRuleData(req.Rule);

    var response = new Response();

    if(editor.IsEmpty)
    {
        // There was no rule in the request
        response.IsRuleEmpty = true;
        response.Output = "The rule is empty";
    }
    else if(!editor.IsValid)
    {
        // The submitted rule is invalid
        response.IsRuleValid = false;
        // Get invalid rule elements
        response.ClientInvalidData = editor.GetInvalidData();
    }
    else
    {
        // Get XML strng of the submitted rule
        var ruleXml = editor.GetRuleXml();
        // Create an instance of the Evaluator using the User class.
        // This compiles your rule into IL
        var ev = new Evaluator<User>(ruleXml);
        // Evaluate the rule against the instance of the User class
        // from the web form submitted by the user
        bool success = ev.Evaluate(req.Source); // The Source is the User
        // Output the evaluation result reusig
        // the just evaluated instance of the User
        response.Output = success ?
            $"The rule evaluated to TRUE {req.Source.Output}" :
            $"The rule evaluated to FALSE {req.Source.Output}";
        // Re-generate the form controls, this
        // time using the instance of the User
        response.FormHtml = Html.GenerateForm(req.Source);
    }

    return Results.Ok(response);
});

public class Request
{
    public string? Rule {  get; set; }
    public User Source { get; set; } = new User();
}

public class Response
{
    public bool IsRuleEmpty { get; set; } = false;
    public bool IsRuleValid { get; set; } = true;

    public string? Output { get; set; }
    public string? ClientInvalidData { get; set; }
    public string? FormHtml { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

The endpoint creates an instance of the Control class and loads the rule. It then checks whether the rule is empty or invalid. The client side has built-in functionality that notifies the rule author of an invalid rule:

Invalid Business Rule Elements

It then gets the XML string of the rule, creates an instance of the Evaluator class using User as its source object, and evaluates the rule against the submitted User instance.

Lastly, it regenerates the form, sets Response.Output, and returns the result. We need to regenerate the form using the User instance because it was just evaluated, and the engine may have updated its fields or invoked rule actions that could change the submitted values.

  • Second, a method on the client that extracts the current rule from the editor, collects the User data from the form, and submits both to the server. We invoke evaluate() from the onclick handler of Evaluate button declared earlier.
async function evaluate()
{
    // Generate a new instance of the User class and fill it with data
    // from the form to be evaluated against the currently displayed rule
    const data = serializeForm(document.getElementById("divForm"));
    // Post the rule and the form data to the /api/evaluate endpoint
    const ev = await fetch('/api/evaluate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ rule: editor.extract(), source: data })
    });

    const res = await ev.json();

    if (res.isRuleEmpty)
    {
        info("The rule is empty");
    }
    else if (!res.isRuleValid)
    {
        // Load the invalid rule elements into the editor
        editor.loadInvalids(res.clientInvalidData);
        info("&nbsp;");
    }
    else
    {
        // Reload the test form (values could be updated by the rule)
        document.getElementById("divForm").innerHTML = res.formHtml;
        // Display the evaluation output
        info(res.output);
    }
};

// Extracts user data from the form
function serializeForm(container)
{
    const obj = {};

    container.querySelectorAll("input, select").forEach(el =>
    {
        let value;

        if (el.type === "checkbox") value = el.checked;
        else if (el.tagName === "SELECT" || el.type === "number") value = el.value.trim() === '' ? null : Number(el.value);
        else value = nullOrValue(el);

        setNestedValue(obj, el.name, value);
    });

    return obj;
}

// Handles reference types
function setNestedValue(obj, path, value)
{
    const parts = path.split(".");
    let current = obj;

    for (let i = 0; i < parts.length - 1; i++)
    {
        const key = parts[i];

        if (!(key in current) || typeof current[key] !== "object" || current[key] === null)
            current[key] = {};

        current = current[key];
    }

    current[parts[parts.length - 1]] = value;
}

function nullOrValue(input)
{
    var v = input.value.trim();
    return v.length > 0 ? v : null;
};

function info(message)
{
    document.getElementById("divInfo").innerHTML = message;
};
Enter fullscreen mode Exit fullscreen mode

The interesting point here is the serializeForm function that generates an instance of the User object by selecting all <input> and <select> controls and their values.

To test all this, create a simple rule, type values for the ID, Total Debt, and Total Savings fields in the form as shown in the screenshot below, and click the Evaluate button:

User Web Form Evaluated

Remember that all rule action methods of the User source object write to the User.Output property, which the evaluate endpoint uses to set Response.Output. That's the value the page renders as the info message - it is added functionality for this demo and has nothing to do with the engine or the editor itself.

Summary

Moving form validation out of hard-coded C# and into a rules engine your business team can manage on its own does more than save you a few re-deploys down the line. It hands ownership of the logic back to the people who actually understand the business, while your development team keeps full control of the data surfaces, mappings, and action methods those rules call into.

The demo project covered in this article walks through the whole flow end to end: a source object your rules can be built against, an editor that renders itself and the form from that object, and an evaluator that executes whatever rules your business team has saved. Clone the repo, run it locally, and start experimenting with your own source objects and rules - the provided Engine Free edition is more than enough to see everything described here in action.

And if your validation needs eventually grow past a single form, the same setup scales into broader decision automation - risk scoring, fraud detection, or anything else your business logic needs to cover - without changing how your development team works with it.

Top comments (0)