Skip to content

Business Rule Configuration

Aaron Hanusa edited this page Jun 16, 2021 · 28 revisions

Rules within the Peasy framework have been designed to allow you to configure them with maximum flexibility using an expressive syntax.

Configuring rules in ServiceBase

Configuring a single rule (ServiceBase)

ServiceBase exposes commands for invoking create, retrieve, update, and delete (CRUD) operations against the injected data proxies. These operations ensure that all validation and business rules are valid before marshaling the call to their respective data proxy CRUD operations.

For example, we may want to ensure that new and existing customers are subjected to an age verification check before successfully persisting it into our data store.

Let's consume the CustomerAgeVerificationRule, here's how that looks:

public class CustomerService : ServiceBase<Customer, int>
{
    public CustomerService(IDataProxy<Customer, int> customerDataProxy) : base(customerDataProxy)
    {
    }

    protected override Task<IEnumerable<IRule>> OnInsertCommandGetRulesAsync(Customer resource, ExecutionContext<Customer> context)
    {
        return TheseRules
        (
            new CustomerAgeVerificationRule(resource.BirthDate)
        );
    }

    protected override Task<IEnumerable<IRule>> OnUpdateCommandGetRulesAsync(Customer resource, ExecutionContext<Customer> context)
    {
        return TheseRules
        (
            new CustomerAgeVerificationRule(resource.BirthDate)
        );
    }
}

In the following example, we simply override the OnInsertCommandGetRulesAsync and OnUpdateCommandGetRulesAsync methods and provide the rule(s) that we want to pass validation before marshaling the call to the data proxy.

What we've essentially done is inject business rules into the thread-safe command execution pipeline, providing clarity as to what business rules are executed for each type of CRUD operation.

Lastly, it should be noted that the use of TheseRules is for convenience and readability only. You can return rules in any fashion you prefer.

Configuring multiple rules (ServiceBase)

There's really not much difference between returning one or multiple business rules.

Here's an example of configuration multiple rules:

public class CustomerService : ServiceBase<Customer, int>
{
    public CustomerService(IDataProxy<Customer, int> customerDataProxy) : base(customerDataProxy)
    {
    }

    protected override Task<IEnumerable<IRule>> OnInsertCommandGetRulesAsync(Customer resource, ExecutionContext<Customer> context)
    {
        return TheseRules
        (
            new CustomerAgeVerificationRule(resource.BirthDate),
            new CustomerNameRule(resource.Name)
        );
    }

    protected override Task<IEnumerable<IRule>> OnUpdateCommandGetRulesAsync(Customer resource, ExecutionContext<Customer> context)
    {
        return TheseRules
        (
            new CustomerAgeVerificationRule(resource.BirthDate),
            new CustomerNameRule(resource.Name)
        );
    }
}

Configuring rules that require data (ServiceBase)

Sometimes business rules require data from data proxies for validation.

Here's how that might look:

public class CustomerService : ServiceBase<Customer, int>
{
    public CustomerService(IDataProxy<Customer, int> customerDataProxy) : base(customerDataProxy)
    {
    }

    protected override async Task<IEnumerable<IRule>> OnUpdateCommandGetRulesAsync(Customer resource, ExecutionContext<Customer> context)
    {
        var existingCustomer = await base.DataProxy.GetByIDAsync(resource.ID);
        return new IRule[] // standard syntax, you can also use `return await TheseRules(...)
        {
            new SomeCustomerRule(existingCustomer),
            new AnotherCustomerRule(existingCustomer)
        };
    }
}

In the following example, we simply override the OnUpdateCommandGetRulesAsync and await data from the data proxy. The result is then supplied to the rules that need them.

Configuring rules in CommandBase

Configuring a single rule (CommandBase)

CommandBase provides the OnGetRulesAsync hook where you can configure your rules.

Let's consume the CustomerAgeVerificationRule in a command that is responsible for creating new customers.

Here's how that might look:

public class CreateCustomerCommand : CommandBase<Customer>
{
    private IDataProxy<Customer, int> _customerDataProxy;
    private Customer _newCustomer;

    public CreateCustomerCommand(Customer newCustomer, IDataProxy<Customer, int> customerDataProxy)
    {
        _customerDataProxy = customerDataProxy;
        _newCustomer = newCustomer;
    }

    protected override Task<IEnumerable<IRule>> OnGetRulesAsync()
    {
        return TheseRules
        (
            new CustomerAgeVerificationRule(_newCustomer.BirthDate)
        );
    }

    protected override Task<Customer> OnExecuteAsync()
    {
        return _customerDataProxy.InsertAsync(_newCustomer);
    }
}

In the following example, we simply override OnGetRulesAsync and provide a rule that we want to pass validation before allowing the code in OnExecuteAsync to run.

It should be noted that the use of TheseRules is for convenience and readability only. You can return rules in any fashion you prefer.

Configuring multiple rules (CommandBase)

There's really not much difference between returning one or multiple business rules.

Here's an example of configuring multiple rules:

public class CreateCustomerCommand : CommandBase<Customer>
{
    private IDataProxy<Customer, int> _customerDataProxy;
    private Customer _newCustomer;

    public CreateCustomerCommand(Customer newCustomer, IDataProxy<Customer, int> customerDataProxy)
    {
        _customerDataProxy = customerDataProxy;
        _newCustomer = newCustomer;
    }

    protected override Task<IEnumerable<IRule>> OnGetRulesAsync()
    {
        return TheseRules
        (
            new CustomerAgeVerificationRule(_newCustomer.BirthDate),
            new CustomerNameRule(resource.Name)
        );
    }

    protected override Task<Customer> OnExecuteAsync()
    {
        return _customerDataProxy.InsertAsync(_newCustomer);
    }
}

Configuring rules that require data (CommandBase)

Sometimes business rules require data from data proxies for validation.

Here's how that might look:

public class UpdateCustomerCommand : CommandBase<Customer>
{
    private IDataProxy<Customer, int> _customerDataProxy;
    private int _customerId;

    public UpdateCustomerCommand(int customerId, IDataProxy<Customer, int> customerDataProxy)
    {
        _customerDataProxy = customerDataProxy;
        _customerId = customerId;
    }

    protected override async Task<IEnumerable<IRule>> OnGetRulesAsync()
    {
        var existingCustomer = await _customerDataProxy.GetByIDAsync(_customerId);
        return new IRule[] // standard syntax, you can also use `return await TheseRules(...)
        {
            new SomeCustomerRule(existingCustomer),
            new AnotherCustomerRule(existingCustomer)
        };
    }

    protected override Task<Customer> OnExecuteAsync()
    {
        return _customerDataProxy.UpdateAsync(_newCustomer);
    }
}

In the following example, we simply override the OnGetRulesAsync method and await data from the data proxy. The result is then supplied to the rules that need it.

It should be noted that we also could have could have overridden OnInitializationAsync to perform the data retrieval of our existing customer. Doing so can lead to cleaner/more explicit code. However, we left that out for the sake of brevity. There is no right way to do this and as always, consistency is key.

Chaining business rules

Business rule execution can be expensive, especially if a rule requires data from a data source which could result in a hit to a database or a call to a an external HTTP service. To help circumvent potentially expensive data retrievals, RuleBase exposes IfValidThenValidate, which accepts a list of IRule, and will only be validated in the event that the parent rule's validation is successful.

Let's take a look at an example:

protected override Task<IEnumerable<IRule>> OnInsertCommandGetRulesAsync(Customer resource, ExecutionContext<Customer> context)
{
    return TheseRules
    (
        new SomeRule().IfValidThenValidate(new ExpensiveRule(_someDataProxy))
    );
}

In this example, we configure a service with the parent rule SomeRule and specify that upon successful validation, it should validate ExpensiveRule, who requires a data proxy and will most likely perform a method invocation to retrieve data for validation. It's important to note that the error message of a parent rule will be set to it's child rule should it's child fail validation.

Let's look at another example and introduce another rule that's really expensive to validate, as it requires getting data from two data proxies.

protected override Task<IEnumerable<IRule>> OnInsertCommandGetRulesAsync(Customer resource, ExecutionContext<Customer> context)
{
    return TheseRules
    (
        new SomeRule().IfValidThenValidate
        (
            new ExpensiveRule(_someDataProxy),
            new TerriblyExpensiveRule(_anotherDataProxy, _yetAnotherDataProxy)
        )
    );
}

In this example, both ExpensiveRule and TerriblyExpensiveRule will only be validated upon successful validation of SomeRule. But what if we only want each rule to be validated upon successful validation of its immediate predecessor?

Here's how that might look:

protected override Task<IEnumerable<IRule>> OnInsertCommandGetRulesAsync(Customer resource, ExecutionContext<Customer> context)
{
    return TheseRules
    (
        new SomeRule().IfValidThenValidate
        (
            new ExpensiveRule(_someDataProxy).IfValidThenValidate
            (
                new TerriblyExpensiveRule(_anotherDataProxy, _yetAnotherDataProxy)
            )
        );
    )
}

Next let's look at validating a set of rules based on the successful validation of another set of rules.

protected override async Task<IEnumerable<IRule>> OnInsertCommandGetRulesAsync(Customer resource, ExecutionContext<Customer> context)
{
    var baseRules = await base.OnInsertCommandGetRulesAsync(resource, context);
    baseRules.IfAllValidThenValidate
    (
        new ExpensiveRule(_someDataProxy),
        new TerriblyExpensiveRule(_anotherDataProxy, _yetAnotherDataProxy)
    );
    return baseRules;
}

In this scenario, we have overridden OnInsertCommandGetRulesAsync and want to ensure that all of the rules defined in the base implementation are executed successfully before validating our newly configured rules.

Executing code on failed validation of a rule

Sometimes you might want to execute logic based on the failed validation of a business rule.

Here's how that might look:

protected override Task<IEnumerable<IRule>> OnInsertCommandGetRulesAsync(Customer resource, ExecutionContext<Customer> context)
{
    return TheseRules
    (
        new SomeRule().IfInvalidThenInvoke(async (rule) => await _logger.LogErrorAsync(rule.ErrorMessage))
    );
}

Executing code on successful validation of a rule

Sometimes you might want to execute logic based on the successful validation of a business rule.

Here's how that might look:

protected override Task<IEnumerable<IRule>> OnInsertCommandGetRulesAsync(Customer resource, ExecutionContext<Customer> context)
{
    return TheseRules
    (
        new SomeRule().IfValidThenInvoke(async (rule) => await _logger.LogSuccessAsync("Your success details"))
    );
}

Testing rule configurations

Rules should be individually tested as standalone, reusable units. However, you will also want to test that a sequence of rules have been configured properly as well.

Below is a sample of what rule configuration test coverage might look like for a more complicated rule configuration:

First let's consider the following command:

public class MyCommand : CommandBase
{
    private Customer _customer;

    public MyCommand(Customer customer)
    {
        _customer = customer;
    }

    protected override Task<IEnumerable<IRule>> OnGetRulesAsync()
    {
        return TheseRules
        (
            new RuleNumberOne(_customer)
                .IfValidThenValidate
                (
                    new RuleNumberTwo(_customer),
                    new RuleNumberThree(_customer)
                )
                .IfValidThenValidate
                (
                    new ExpensiveRule(_customer).IfValidThenValidate(new SuperExpensiveRule(_customer)),
                    new RuleNumberFour(_customer)
                )
        );
    }
}

In the above code, we configure a command with a more advanced configuration scheme.

Now let's add some test coverage around the rule configuration for the command:

[Fact]
public void MyCommand_Rule_Is_Properly_Configured()
{
    var rulesContainer = new MyCommand() as IRulesContainer;
    var rules = await rulesContainer.GetRulesAsync();

    rules.Count().ShouldBe(1);

    var firstRule = rules.First();
    firstRule.ShouldBeOfType<RuleNumberOne>();
    firstRule.GetSuccessors().Count().ShouldBe(2);

    var firstSuccessor = firstRule.GetSuccessors().First();
    firstSuccessor.Rules.Count().ShouldBe(2);
    firstSuccessor.Rules.First().ShouldBeOfType<RuleNumberTwo>();
    firstSuccessor.Rules.Second().ShouldBeOfType<RuleNumberThree>();

    var secondSuccessor = firstRule.GetSuccessors().Second();
    secondSuccessor.Rules.Count().ShouldBe(2);
    secondSuccessor.Rules.First().ShouldBeOfType<ExpensiveRule>();
    secondSuccessor.Rules.First().GetSuccessors().Count().ShouldBe(1);
    secondSuccessor.Rules.First().GetSuccessors().First().Rules.First().ShouldBeOfType<SuperExpensiveRule>();
    secondSuccessor.Rules.Second().ShouldBeOfType<RuleNumberFour>();
}

In the above test, we ensure that one rule has been configured as the root rule. Based on successful validation, it has been configured to execute two lists of rules, also known as successors. Each successor is then tested to ensure that it has been configured with the appropriate rule types and in the correct order.

The benefit of this testing approach is that there are many runtime permutations that could result depending on how the validation result of each rule plays out. Normally each permutation would require an individual unit test. For example, imagine the following validation scenarios for the above command that would normally require individual test cases:

  1. If RuleNumberOne is not successful, ensure that no other rules are executed.
  2. If RuleNumberOne is successful, ensure that RuleNumberTwo and RuleNumberThree are executed, but not ExpensiveRule and RuleNumberFour (yet).
  3. If RuleNumberTwo or RuleNumberThree are not successful, ensure that no other rules are executed.
  4. If RuleNumberTwo and RuleNumberThree are successful, ensure that ExpensiveRule and RuleNumberFour are executed, but not SuperExpensiveRule.
  5. If ExpensiveRule is successful, ensure that SuperExpensiveRule is executed.
  6. If ExpensiveRule is not successful, ensure that SuperExpensiveRule is not executed.

Because the rule configuration framework has been extensively tested here, you can focus on testing the actual configuration and skip the hassle of testing each potential logic flow path above, knowing that the underlying rule executions will work as expected.

It should be noted that CommandBase implements the IRulesContainer interface and publicly exposes GetRulesAsync. However, by default, all commands returned from ServiceBase are done so via the ICommand abstraction, which does not implement IRulesContainer. Therefore, if testing a command's rules configuration from an ICommand interface, you will need to cast it to an IRulesContainer. This, of course, assumes that you will be creating your commands by inheriting from CommandBase or using ServiceCommand, both of which implement IRulesContainer. If you create your commands from scratch by implementing ICommand, you will also want to implement IRulesContainer and provide an implementation for it.

Lastly, it should be noted that Second and GetSuccessors are extension methods that can be copied from the TestingExtensions class in the Peasy unit test project to help aid you in testing rule configurations.