-
Notifications
You must be signed in to change notification settings - Fork 68
Business Rule Configuration
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 rules in CommandBase
- Chaining business rules
- Executing code on failed validation of a rule
- Executing code on successful validation of a rule
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.
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)
);
}
}
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.
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.
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);
}
}
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.
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.
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))
);
}
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"))
);
}
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:
- If RuleNumberOne is not successful, ensure that no other rules are executed.
- If RuleNumberOne is successful, ensure that RuleNumberTwo and RuleNumberThree are executed, but not ExpensiveRule and RuleNumberFour (yet).
- If RuleNumberTwo or RuleNumberThree are not successful, ensure that no other rules are executed.
- If RuleNumberTwo and RuleNumberThree are successful, ensure that ExpensiveRule and RuleNumberFour are executed, but not SuperExpensiveRule.
- If ExpensiveRule is successful, ensure that SuperExpensiveRule is executed.
- 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.