Every now and then we get to implement very complex logic and there are various ways to manage the complexity. In my 3 Posts series, I have discussed how we can handle complexity introduced by the multi-tenancy aspect of the business logic.
I recently came across a logic, which was going to create a maze of if/else conditions in my code, which I could not let happen. Because of the complexity in the decision making, context driven dependency injection could help, but that would lead to business logic leaking to the infrastructure code, which is a real bad code smell. Considering container more on the infrastructure side and like it to handle the aspects of application, not business logic. I did not either want to introduce a heavy weight business rules engine, but I really like the readability side of the business rule engines. I can not really pin point which pattern or model I ended up implementing, but the closest I could find is the Adaptive Model explained by Martin Fowler, however for a different purpose in mind. Complementing the adaptive nature with strategy pattern did the trick for me.
That was more or less what and why I went that path, now we will explore bit of how. Lets look at an example of an advance pizza store where our code would guide the pizza maker bots. So we have got a selection of pizzas such as “Meatlovers”, “Satay Chicken” etc. This store also offers gluten free and nut free variations for some of the pizzas, where possible. It will choose an alternative ingredient if possible and in some cases it can not have the variation e.g. Satay Chicken does not have nut free option. So we have come up with the following implementation:
public class ProcessOrder { public List<Pizza> Execute(PizzaOrder order) { var pizzas = order.Select(orderItem => _recipeFactory.Get(orderItem).Apply() ); return pizzas; } } public class RecipeFactory: IRecipeFactory{ public IRecipe Get(string pizzaName){ switch (pizzaName) { case "MEATLOVERS": return new Meatlovers(); case "SATAYCHICKEN": return new SatayChicken(); default: } } }
Process order will take a request to prepare the number of pizzas, for each pizza it will call PizzaMaker. PizzaMaker uses RecipeFactory (instance provided by IoC) to apply ingredients. RecipeFactory, provides an instance of the Recipe class based on the pizza name. So far its all nice and simple. But complexity has to live somewhere and in this case it has been pushed down to the IRecipe implementation. Lets look at an implementation of the MeatloversRecipe.
public class MeatloversRecipe : RecipeBase { public Pizza Apply(OrderItem orderItem){ var pizza = new Pizza(); PrepareBase(); RecipeWorker.Apply(pizza, "Onion"); RecipeWorker.Apply(pizza, "Ham"); RecipeWorker.Apply(pizza, "MinceBeefBalls"); if (!orderItem.Options.NoNuts){ RecipeWorker.Apply(pizza, "Nuts"); } orderItem.Options.Extras.Where(item => IsGlutenFree(item)) .ForEach(extra => { RecipeWorker.Apply(pizza, extra); }); if (orderItem.Options.IsGlutenFree){ RecipeWorker.Apply(pizza, "Cheese"); } else{ RecipeWorker.Apply(pizza, "Special Cheese"); } } } protected abstract class RecipeBase: IRecipe { protected void PrepareBase(OrderItem orderItem){ if (orderItem.Options.IsGlutenFree){ orderItem.Options.PizzaBase = PizzaBase.GlutenFree; if (orderItem.Options.SpiceLevel == SpiceLevel.Normal){ RecipeWorker.Apply(pizza, "Strandard Red Sos"); } else { RecipeWorker.Apply(pizza, "Spicy Red Sos No Gluten"); } RecipeWorker.Apply(pizza, "Special Cheese"); } else { if (orderItem.Options.SpiceLevel == SpiceLevel.Normal){ RecipeWorker.Apply(pizza, "Strandard Red Sos"); } else { RecipeWorker.Apply(pizza, "Spicy Red Sos"); } RecipeWorker.Apply(pizza, "Special Cheese"); RecipeWorker.Apply(pizza, "SpecialMasalaMixture"); } } }
So MeatloverRecipe is making few decisions here, based on the gluten free or nut free options, it selects a different ingredient. Similarly other options will do similar setup based on the corresponding recipes. It can be argued that it delegates the responsibilities well, but at the cost of complex code and also not so readable. If I have to work out, what recipes offer nut free or gluten free options, I will have to go the implementation and make sense of it. You may have not noticed that we have identified a common piece of code and extracted to the abstract base class, employing DRY principles. Now this means less code now but will lead to more pain later on when we have to change one of the recipe and make sure it still works with others. This does not mean DRY is bad, the problem is in the way we implemented it.
Lets look at an alternative implementation and where we will cleaner implementation and better readability, that leads to more maintainable code. Taking some guidance from the Adaptive Method and applying a strategy pattern, we can extract fair chunk of the complexity out to the declarative code or data (json/xml). Which can be a portable set of business rules, applied with very simple code.
There is a similarity in all the Recipe implementations, they all apply ingredients in a particular sequence. Based on the options selected, they would choose a separate set of ingredients. So if we extract this behavior as strategy we can come with following strategies for all the pizzas:
- ApplyStandardRecipeStrategy
- ApplyGlutenOptionsRecipeStrategy
- ApplyGlutenAndNutOptionsRecipeStrategy
Assuming that we store all the ingredients in a data store with some metadata such as if the ingredient is nut free or gluten free etc. Recipe Strategies will get all the ingredients from data store and then apply them based on the criteria it deals with. Simplest would be the ApplyStandardRecipeStrategy, which will just apply the first available ingredient in the groups with assumption of first being the default. Out of these three, ApplyGlutenAndNutOptionsRecipeStrategy is the most complex and following is the implementation of it:
public class ApplyGlutenAndNutOptionsRecipeStrategy: IRecipeStrategy { public Pizza Apply(OrderItem orderItem) { var pizza = new Pizza(); RecipeRepository .GetFor(orderItem.PizzaName) .RecipeIngredients.Groups.OrderBy(ing => ing.Index) .ForEach(group => { group.Ingredients.First(ing => { (ing.IsDefault !orderItem.NoNuts && !orderItem.IsGlutenFree) || ( (!orderItem.NoNuts || ing.IsNutFree == orderItem.NoNuts) && (!orderItem.IsGlutenFree || ing.IsGlutenFree == orderItem.IsGlutenFree) ) }).OrderBy(ing => ing.Index) .ForEach(ing => { _recipeWorker.AddIngredient(ing.Code); }); } return pizza; } } public class ApplyStandardRecipeStrategy: IRecipeStrategy { public Pizza Apply(OrderItem orderItem) { var pizza = new Pizza(); RecipeRepository .GetFor(orderItem.PizzaName) .RecipeIngredients.Groups.OrderBy(ing > ing.Index) .ForEach(group => { group.Ingredients.First(ing => ing.IsDefault) .OrderBy(ing => ing.Index) .ForEach(ing => { _recipeWorker.AddIngredient(ing.Code); }); } return pizza; } }
By outsourcing the logic to these strategies, we just need to work out the apply the appropriate strategy at run time. Here we can replace the RecipeFactory with RecipeStrategyFactory. Unlike RecipeFactory, it will use little bit complex criteria to select the strategy. In the following implementation, the criteria has been extracted as static data as set of rules. We can even go further to extract the rules out of the code and put them in an external data store to work in Adaptive Method.
public class RecipeStrategyFactory { private const bool GF = true; private const bool NoNuts = true; private const bool? GF_NA = null; private const bool? NoNuts_NA = null private List<RecipeSelectionCriteria> _selectionCriteria = new List<RecipeSelectionCriteria> { // has both Glutten and Nut Free options new RecipeSelectionCriteria("Meatlovers", GF_NA, NoNuts_NA, typeof(ApplyGlutenAndNutOptionsRecipeStrategy)); //no recipe for Nut-Free new RecipeSelectionCriteria("SatayChickien", GF_NA, !NoNuts, typeof(ApplyGlutenOptionsRecipeStrategy)); //no recipe for Gluten Free or Nut-Free new RecipeSelectionCriteria("NewPizza", !GF, !NoNuts, typeof(ApplyStandardRecipeStrategy)); } public static IRecipeStrategy GetStrategy(OrderItem orderItem){ var criteria = _selectionCriteria.SingleOrDefault(crtr => crts.Name == orderItem.PizzaName && (!crts.IsGlutenFree.HasValue || crts.IsGlutenFree.Value == orderItem.Options.IsGlutenFree) && (!crts.NoNuts.HasValue || crts.IsGlutenFree.Value == orderItem.Options.NoNuts) ); if (criteria == null){ throw new ArgumentOutOfRange("No recipe found for the pizza"); } return Factory.Resolve(criteria.RecipeType); } private class RecipeSelectionCriteria { public string Name { get; set; } public bool? IsGlutenFree { get; set; } public bool? NoNuts { get; set; } public IRecipe RecipeStrategy { get; set; } } }
What we have achieved is the simpler code, easier to add more recipes as long as they can be served by existing strategies. Above all, the strategy selection criteria has made code quite readable to even non technical people. It is even clearer in highly complex scenarios. Recently I did implement this logic for a complex piece of work. To my delight, the piece of code was even referenced by the Testers as well. You know what would be really awesome, if it could be extracted and store in way that business can add/remove options using the given strategies.