Nullifying Null
One of the more annoying problems with building rules that are also code is that you have to deal with code related issues. One of the more common ones is NullReferenceException.
For example, let us say that we have the following rule:
when Order.Amount > 10 and Customer.IsPreferred: ApplyDiscount 5.precent
We also support a mode in which a customer can create an order without actually registering on the site (anonymous checkout).
In this scenario, the Customer property is null. We can rewrite the rule to look like this:
when Order.Amount > 10 and Customer is not null and Customer.IsPreferred:
ApplyDiscount 5.precent
But I think that this is extremely ugly. We can also decide to return a default instance of the customer when it is not there, but here I want to show you another way to handle this. We define the rule as invalid when Customer is not there, so it should not be run. The question is how we can know that.
The dirty way is to do something like this:
var referencesCustomer = File.ReadAllText(ruleName).Contains("Customer"); if(referencesCustomer && Customer == null) return;
If you gagged when seeing this code, that is a good sign. Let us solve this properly. First, we want some help from the compiler, so let us inspect the when() meta method that we have seen in the previous post a little closer.
[Meta] public static ExpressionStatement when(Expression condition, BlockExpression action) { var ctor = new BlockExpression(); var conditionFunc = new Block(); conditionFunc.Add(new ReturnStatement(condition)); ctor.Body.Add( new BinaryExpression( BinaryOperatorType.Assign, new ReferenceExpression("Condition"), new BlockExpression(conditionFunc) ) ); Expression serialize = new CodeSerializer().Serialize(condition); Builder.Revisit(serialize); ctor.Body.Add( new BinaryExpression( BinaryOperatorType.Assign, new ReferenceExpression("ConditionExpression"), serialize ) ); ctor.Body.Add( new BinaryExpression( BinaryOperatorType.Assign, new ReferenceExpression("Action"), action ) ); return new ExpressionStatement( new MethodInvocationExpression( ctor )); }
We take the cal to the when method and transform it to the following code:
delegate { Condition = () => Order.Amount > 10 && Customer.IsPreferred; ConditionExpression = (Expression<Func<bool>>)() => Order.Amount > 10 && Customer.IsPreferred; Action = delegate { // not interesting for this post }; }();
I am translating to C# 3.0 here in order to make it easier to grasp the concept. The real code is in Boo, of course, and is more interesting. The most fascinating concept here is the use of CodeSerializer, which will turn the condition that we passed into an AST that we can access at runtime. I tried to simulate that by doing an explicit cast to expression tree, which would give similar result in C#).
Having the AST of the code at runtime, even if we don't want to change it (a totally different concept) is incredibly powerful. In this case, we are going to use this to detect when we are referencing a null property and marking the rule as invalid.
Here is the code:
public void Evaluate() { var references = new List<string>(); new InlineVisitor { OnReferenceExpression = r => references.Add(r.Name); }.Visit(ConditionExpression); if(references.Contains("Customer") && Customer == null) return;// rule invalid if(Condition()) Action(); }
This is a very simple example of how you can add smarts to the way that your code behaves. This technique is the foundation for a whole host of options. I am using similar approaches for adaptive rules and for auditable actions. Fun stuff, if I say so myself.
Comments
While the solution you posted may be succinct it is far from simple for anyone new to .net. I've only been doing c# .net for a year but I've been programming for 9 years so I think I can say you would need to comment the hell out of that code.
Paul,
I makes some assumptions about you being able to understand AST, I admit.
Fine as long as you don't need a rule that reacts to it being null and doing something...
pb,
That is actually easy.
OnIsExpression => x => isExpressions.Add(x.Name)
Now you just check if it is there.
I get trying to make it easier to read, but this kind of hides something somewhat important, that you'd have to figure out when your rules weren't doing what you thought. I think I'd rather see either
1) Just add a "not Invalid(Customer)" to the beginning of each when
2) a rule about rules, such as:
for_all_whens
so it was all clear what was going on. That seems possible given the code you've posted so far. Maybe you're just trying to do this as a simple example of a more complicated concept you have going on though.
pb,
While the second is possible (and is mostly what I did today), I strongly recommend against it. Trying to understand how it works is hard.
1) is an eye sore.
It is easier to say that if Customer is null, the rule is not evaluated. (To be rather more exact, it is known that if customer is null, the rule evaluate to false).
Since this applies to just about any property, where there can be many, it is simpler overall, I think.
Why not simply use the null object pattern in this case?
I cant say I use null object much myself, but in this kind of scenario it might be a more robust approach, making the life for a rule writer easier.
Just because you don't have a anonymous customer in your persistent store doesnt mean you can't expose one to the rule engine.
Also, are you exposing your "real" domain model to the DSL?
So that the DSL could invoke code in the domain that is not supposed to?
Or are you transforming a subset of the domain into some sort of readonly mini DM for the rule engine?
I think 'null' (UnregisteredCustomer) object may be easier in this case (with IsRegistered false to check that if something depends on fact of having a known Customer).
Roger,
Yes, I did pointed that one out. That is one option to deal with that, but it is not always a good option.
Roger,
I am doing both. Exposing the domain directly to the DSL make it easier to work with it.
On the other hand, it hurt versioning and the fluidity of the language.
Ok I must be blind, because I didn't see that you pointed out null object the first time I read it.
Either way.
I'd say that there are two major benefits for using null object here.
1) The person that performs an anonymous checkout isby definition a "customer" since he is buying from you.
So it would be extremely odd to not model this into the domain.
2) Nullifying rules because of state is not very obvious for the reader of the DSL script.
This breaks the rule of least surprise (IMO)
Even if the intent is to lessen the burden on the DSL writer, I'd guess that this will be more error prone because people could make incorrect assumptions on state and expect that certain blocks of code have been executed when they have infact just been ignored.
Roger, he pointed it out here:
"We can also decide to return a default instance of the customer when it is not there,...."
That's the nullable pattern.
I tend to agree with Roger that the null value pattern fits nicely... after all an anonymous customer is still a (non-preferred) customer and your domain should reflect that. Out of curiosity, for what reasons is the null value pattern not a good option?
Because it doesn't always work.
Null is a missing value, in which case the rule is invalid.
NullValue pattern implies that it should still work, which is not always the case.
As a simple example, how would you handle a rule that give 2% discounts for customers after 6 years ?
Comment preview