Skip to content

Business Rule Configuration

Aaron Hanusa edited this page Feb 4, 2021 · 28 revisions

Rules within the Peasy framework have been written to allow you to configure rules 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 customers and existing customers are subjected to an age verification check before successfully persisting it into our data store entity.

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 a method for convenience and readiblity 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)
        );
    }
}

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

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 results are then supplied to the rules that need them.

Configuring rules in CommandBase

Configuring a single rule (CommandBase)

CommandBase provides the OnGetRulesAsync method 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 execute.

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 before the command performs any actual work.

Lastly, it should be noted that the use of TheseRules is a method for convenience and readiblity 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 configuration 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);
    }
}

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

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 and await data from the data proxy. The results are then supplied to the rules that need them.

It should be noted that we also could have could have overridden OnInitializationAsync and load the existing customer there. 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. 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 wanted each rule to be validated upon successful validation of its 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 defined rules.

Executing code on failed validation of a rule

Sometimes you might want to execute some 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 some 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 and that successive rules execute based on previous rules' execution statuses.

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

First lLet's consider the following command:

public class MyCommand : CommandBase
{
    protected override Task<IEnumerable<IRule>> OnGetRulesAsync()
    {
        return TheseRules
        (
            new TrueRule()
                .IfValidThenValidate(new TrueRule(), new TrueRule())
                .IfValidThenValidate
                (
                    new TrueRule().IfValidThenValidate(new FalseRule1()),
                    new FalseRule3()
                )
        );
    }
}

In the above code, we configure a command with a single rule TrueRule that is configured to execute a list of successors based on the success of it's execution. In this example, TrueRule always successfully validates, while all FalseRules fail validation.

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 FooCommand() as IRulesContainer;
    var rules = await rulesContainer.GetRulesAsync();

    rules.Count().ShouldBe(1);
    var firstRule = rules.First();

    firstRule.GetSuccessors().Count().ShouldBe(2);

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

    var secondSuccessor = firstRule.GetSuccessors().Second();
    secondSuccessor.Rules.Count().ShouldBe(2);
    secondSuccessor.Rules.First().ShouldBeOfType<TrueRule>();
    secondSuccessor.Rules.First().GetSuccessors().Count().ShouldBe(1);
    secondSuccessor.Rules.First().GetSuccessors().First().Rules.First().ShouldBeOfType<FalseRule1>();
    secondSuccessor.Rules.Second().ShouldBeOfType<FalseRule3>();
}

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.

CommandBase implements the IRulesContainer interface, which allows access to all configured rules via the GetRulesAsync method. Through this interface, you can gain access to your configured rules and test that are configured correctly.

Within the context of peasy, rules are generally configured in services or commands. Out of the box, ServiceBase returns command instances that inherit from CommandBase. Additionally, ServiceCommand inherits CommandBase.

Second and GetSuccessors are extension methods that can be copied from the Peasy unit tests project;

To reiterate, each individual test should be tested individually and then configuration tests independently as in the above configuration. Because the configuration orchestrations have been thoroughly tested, you can trust that simply how the rules have been configured will be all you need to test rule configurations.

Clone this wiki locally