Encapsulated Decision Tables






3.29/5 (3 votes)
A mechanism for specifying program control flow via delegated calls from a decision table
Introduction
Decision Tables are one of the oldest models for recording in writing or programming complex conditional logic. In .NET a Decision Table can be thought of as an instance of
Dictionary<TKey, Action<T1,..Tn>>
or
Dictionary<TKey, Func<T1,…Tn,TResult>>
For each KeyValuePair<TKey, TValue> in the Dictionary, the TKey completely defines the Condition under which the Action (a delegated call) specified in the TValue is executed. Decision Tables provide an elegant, easy to maintain and fast alternative to switch/case or if/else in complex logic. Use of a composite Key can actually reduce the total code size in cases where multiple state values must be evaluated to select the delegated call, a feature unavailable to switch/case and error prone and slow to implement in if/else.
Once created, the Dictionary can be initialized either with Add(TKey, TValue) or with a constructor initializer. The TValue can be initialized with a conforming method of the containing class, a conforming static member of separate class, or a lambda expression. However, having the initialized Decision Table exposed, for example, as a class member of its consuming code presents a potential for misuse since the Dictionary could be modified inadvertantly after initialization, leading to maintainability issues or unexpected results. This article shows a technique for fully encapsulating the Decision Table, closing the Dictionary from modification and enabling the Decision Table to be loaded into an IOC.
Background
The code provided operates on entities within a sample domain model, shown in static class diagram below. This model approximates that necessary for calculating taxes within a retail problem space.
The principle concrete entities used in the code are
LineItem
, an entity for which taxes must be calculatedTaxItem
, an entity which retains the values of the calculated taxTaxItemLink
, an structural entity maintaining a relationship between a SourceTaxItem
and a DestinationLineItem
The TaxItem
contains a property of Type TaxAuthority
. The instance assigned to this property might hold the parameterizations for a particular type of calculation in an actual system. Three concrete realizations of TaxAuthority
are provided.
Not shown are three concrete realizations of ComputeTax
each of which calculate the values for a corresponding concrete TaxAuthority
instance. For simplicity, the Domain model and computation entities are exceeding shallow, and the ComputeTax
specializations simply return constant but unique values in a Tuple<decimal, decimal>. The Decision Tables constructed in the code relate a concrete TaxAuthority
to an instance of its corresponding ComputeTax
specialization.
Encapsulating the Decision Table
The unit tests in the sample source in some ways show evolutionary steps in the development of this concept, illustrating a variety of ways of initializing the Dictionary and then invoking the delegation via an extension method on the Dictionary. As noted previously, in these implementations the Dictionary is not closed to modification and thus is susceptible to post-initialization traumas that could produce unexpected results at runtime. To preclude this, the Dictionary must be moved into a wrapper class as a member variable with a non-public access modifier.
The abstract generic class EncapsulatedDecisionTable
is the root of the encapsulation hierarchy.
public abstract class EncapsulatedDecisionTable<TKey,TValue,TDomain>
where TKey : class
where TValue : class
where TDomain : class
{
public readonly IKeyExtractor<TDomain, TKey> _extractor;
protected Dictionary<TKey, TValue> _decisionTable;
protected EncapsulatedDecisionTable(IKeyExtractor<TDomain,TKey> extractor)
{
_extractor = extractor;
}
}
The generic parameters are
- TKey, the Key Type for the Dictionary
- TValue, the Value Type for the Dictionary, either an Action<T1,...,Tn> or a Func<T1,...,TResult> as is appropriate
- TDomain, a Domain Type from which a Key instance can be formulated
The wrapper has a protected member variable for the Dictionary and a non-default constructor whose parameter is a Type implementing IKeyExtractor
public interface IKeyExtractor<TDomain,TKey>
where TDomain : class
where TKey : class
{
TKey ExtractKey(TDomain arg);
}
The concrete implementation of IKeyExtractor
must be able to return an instance of the TKey from analysis of an instance of the TDomain. Failure to do should should throw an appropriate Exception from the implementation. The extractor is cached in a public member variable on construction.
Two abstract subclasses of EncapsulatedDecisionTable
are provided, one for use with TValues based on Action<T1,...,Tn> and the other for use with TValues based on Func<T1,...,TResult>. The latter has an additional generic parameter also of Type TResult.
public abstract class ActionBasedEncapsulatedDecisionTable<TKey,TValue,TDomain>
: EncapsulateDecisionTable<TKey,TValue,TDomain>
where TKey : class
where TValue : class
where TDomain : class
{
protected ActionBasedEncapsulatedDecisionTable(IKeyExtractor<TDomain, TKey> extractor)
:base(extractor)
{
}
protected virtual void Execute(TDomain arg)
{
throw new DecisionTableDispatchException
(DecisionTableDispatchException.FormatMessage(arg));
}
}
public abstract class FuncBasedEncapsulatedDecisionTable<TKey,TValue,TDomain,TResult>
: EncapsulateDecisionTable<TKey,TValue,TDomain>
where TKey : class
where TValue : class
where TDomain : class
where TResult : class
{
protected FuncBasedEncapsulatedDecisionTable(IKeyExtractor<TDomain, TKey> extractor)
base(extractor)
{
}
protected virtual TResult Execute(TDomain arg)
{
throw new DecisionTableDispatchException
(DecisionTableDispatchException.FormatMessage(arg));
}
}
The virtual method Execute
methods in in these classes dictate the signature of the call delegation functionality. The base implementation simply throws a DecisionTableDispatchException
if called. The actual delegation must occur in a concrete subclass of the wrapper. The base Execute
is provided to simplify coding in the case where the TKey instance does not exist in the Dictionary.
Each concrete subclass is responsible for creating and initialiizing its Dictionary and setting the _decisionTable
member variable of the base class. This activity should occur during construction. In order to externally expose the call delegation functionality, each a concrete subclass must als implement either IEncapsulatedDecisionTable<TDomain>
public interface IEncapsulatedDecisionTable<TDomain>
where TDomain : class
{
void Execute(TDomain arg);
}
or IEncapsulatedDecisionTable<TDomain,TResult>
public interface IEncapsulatedDecisionTable<TDomain,TResult>
where TDomain : class
where TResult : class
{
TResult Execute(TDomain arg);
}
as is appropriate for the TValue.
If the goal is to have the Decision Table dynamically loaded from an IOC at runtime, the concrete subclass class must also implement IEncapsulatedDecisionTableIoc<TKey,TValue>
public interface IEncapsulatedDecisionTableIoc<TKey,TValue>
where TKey: class
where TValue : class
{
void Clear();
void Add(TKey condition, TValue action);
}
While necessary to allow the Dictionary to be populated during IOC initialization, this interface breaks the encapsulation goal and therefore should never appear on a wrapper subclass intended to be directly created via the new operator.
Invocation of the delegated call in the wrapper subclass should always be identical, differing only in the generics associated with TValue and the TDomain assigned to the wrapper. Each subclass should override the Execute method similar to the following sample.
public class UnfilledTaxDecisionTable :
FuncBasedEncapsulatedDecisionTable<Type,
Func<TaxItemLink, Tuple<decimal, decimal>>,
TaxItemLink,
Tuple<decimal, decimal>>,
IEncapsulatedDecisionTable<TaxItemLink, Tuple<decimal, decimal>>
{
:
public Tuple<decimal,decimal> Execute(TaxItemLink link)
{
Func<TaxItemLink, Tuple<decimal, decimal>> f;
if (_decisionTable.TryGetValue(_extractor.ExtractKey(link), out f))
{
return f(link);
}
return base.Execute(link); //always throws
}
:
}
More Than Just a Wrapper
Wrappering for encapsulation is a well-known technique. However, moving programatic flow control into an IOC creates a Customization Seam when developing an SDK for a product. A Customization Seam describes a deliberate point in the software design where customization is meant to be absorbed, leveraging the most from the base product's normal behavior while limiting the scope of the required customization effort. In an SDK customization seams help define the assembly structure, source code delivery requirements, and required guidance documentation for doing the likely customizations.
The IOC unit test in the sample suggests the power of the concept
public void TestIOCDecisionTable()
{
var container = new UnityContainer();
// Adding the extension initializes the DecisionTable
container.AddNewExtension<ContainerExtension>();
var link = new TaxItemLink
{
Destination = new LineItem(),
Source = new TaxItem {Authority = new VATTax()}
};
Tuple<decimal, decimal> result =
container.Resolve<IEncapsulatedDecisionTable<TaxItemLink,
Tuple<Decimal, decimal>>>().Execute(link);
Assert.True(result.Item1 == 3.0M);
}
In a software product, the base could be implemented very similarly to the above. The Customization Seam exists in how the ContainerExtension populates the underlying Decision Table.
internal class ContainerExtension : UnityContainerExtension
{
:
protected override void Initialize()
{
var scanner = new SimpleScanner();
//find DecisionTable related entities
Type decisionTableType = scanner.FindTypes(decisionTablePredicate).First();
Type[] computerTypes = scanner.FindTypes(decisionEntryPredicate).ToArray();
IEnumerable<Type> authorityTypes = scanner.FindTypes(taxAuthorityPredicate);
//Create the decision table, injecting a key extractor
object decisionTable = Activator.CreateInstance(decisionTableType,
new object[] {new TaxKeyExtractor()});
// Clear the internal table
(decisionTable as IEncapsulatedDecisionTableIoc<Type, ComputeTax>).Clear();
// load the table by (in this model) matching prefixes of Types
Array.ForEach(computerTypes, c =>
{
string prefix = c.Name.Substring(0, c.Name.IndexOf("Compute"));
Type authority = authorityTypes.FirstOrDefault(s => s.Name.StartsWith
(prefix));
// Create the DecisionEntry
object computeInstance = Activator.CreateInstance(c, false);
// Load it to the DecisionTable
(decisionTable as IEncapsulatedDecisionTableIoc<Type,
ComputeTax>).Add(authority,
computeInstance as ComputeTax);});
// Register the DecisionTable in the IOC
Container.RegisterInstance(typeof (IEncapsulatedDecisionTable<TaxItemLink,
Tuple<decimal, decimal>>), decisionTable,
new ContainerControlledLifetimeManager());
}
:
}
While this code simply reproduces the behavior of the other samples, the potential for different strategies of populating the Decision Table is vast and could include conventions for selecting a derived Type in preference to one of the base product Types when poplulating the Decision Table. Thus the customization guidance scope is limited topically, in this case, to the discussion of subclassing ComputeTax concrete types and deploying the resulting assemblies for runtime insertion by the IOC.
By no means is this recommended as a generic technique in every situation. However, runtime replacable logic is a powerful customization strategy.
Using the code
The code is delivered a single solution (VS2012) containing a single unit text project (xUnit.net). The various unit tests exercise all of the points discussed above in a variety of ways, including hoisting an encapsulated Decision Table into the Unity IOC containerHistory
13 Oct 2013 Added "More than a Wrapper"; removed Point of Interest.