3

I have a class (as a protobuf) OrderChange, that represents when an order (imagine Amazon.com) changes:

message OrderChange {
  Order old_order = 1;
  Order new_order = 2;
}

message Order {
  OrderType order_type = 1;
  OrderCategory order_category = 2;
  OrderStatus order_status = 3;
  // many more fields
}

enum OrderType {
  ORDER_TYPE_RETAIL = 0;
  ORDER_TYPE_BUSINESS = 1;
}

enum OrderCategory {
  ORDER_CATEGORY_ELECTRONICS = 0;
  ORDER_CATEGORY_FOOD = 1;
  ORDER_CATEGORY_FURNITURE = 2;
  ORDER_CATEGORY_FITNESS = 3;
  ORDER_CATEGORY_HOUSEHOLD = 4;
}

enum OrderStatus {
  ORDER_STATUS_PAID = 0;
  ORDER_STATUS_SHIPPED = 1;
  ORDER_STATUS_DELIVERED = 2;
}

For each OrderChange object, I want to trigger some code for each conditional. For example if Order is RETAIL, FURNITURE, and PAID, I want to send a specific email.

OrderType and OrderCategory probably won't change between old_order and new_order, so my code can look at only new_order for these fields. Other fields such as OrderStatus will change, and my code can compare old_order and new_order to know what changed.

My problem is that Order has many fields, each with many values, so the number of conditional combinations is huge. Using only if/else or switch/case would be unmaintainable code. Assume an unlimited number of fields in Order, and conditions can be any of X choose Y, so an unlimited number of conditionals.

So my question is, what pattern can I use to make these conditionals more maintainable?

Maybe I can break the fields into handlers - a handler for each OrderType, then each of these handlers contain a list of handlers for each OrderCategory, and continuing to nest a single field for each level until there are no more fields. My issue would be that beyond maybe the OrderType being the highest level field, there is no clear hierarchical relationship for the other fields. As in, it's not clear that OrderCategory handlers should contain OrderType handlers.

How should I design this?

I'm working in Go, but welcome answers in any language.

10
  • 1
    Does this answer your question? Approaches to checking multiple conditions?
    – gnat
    Oct 2 '21 at 18:02
  • 1
    How many different actions do you have in total? Oct 2 '21 at 18:28
  • Your example has a rule that contains all three elements of an order as conditions. Will all your rules look like that? If not it would be great to have examples of other rules.
    – Helena
    Oct 3 '21 at 6:25
  • Who will add new rules? Will it always be done by your team, or will different teams be responsible for different aspects?
    – Helena
    Oct 3 '21 at 6:26
  • What fields of the Order do you expect to change most often? Will it be more typical do add new OrderCategories, are you planning to add a lot of OrderStatus in the future or do you want to add new fields altogether?
    – Helena
    Oct 3 '21 at 6:30
6

One approach would be to create a bunch of objects (or just functions) each of which takes an Order and only checks for the unique combination that is supposed to trigger a business outcome, put them in a list, and pass the order through the whole list.

These objects (or functions) should then produce desired side-effects if their respective condition is triggered. If the side-effects aren't costly or aren't something that would obstruct normal operation, design your objects to execute them immediately, otherwise, make the side effect schedule the task to be executed later (e.g. some emails might be sent in a batch at a specific time of day).

This way, you can specify these business rules independently and combine them however you like.

My code examples will be in C#, but I think you can get the idea from them.

Object version:

interface IOrderProcessor {
    void Process(Order order);
}

// This is a member field somewhere:
List<IOrderProcessor> orderProcessors = new List<IOrderProcessor>();

// initialize it:
orderProcessors.Add(new PaidFurnitureProcessor());
orderProcessors.Add(new SomeOtherProcessor());
...

// In some method:
foreach (var processor in orderProcessors)
    processor.Process(order);

// Then for each business rule:
class PaidFurnitureProcessor : IOrderProcessor {
    public void Process(Order order) {
        if (order.order_type == OrderType.ORDER_TYPE_RETAIL && 
            order.order_category == OrderCategory.ORDER_CATEGORY_FURNITURE && 
            order.order_status == OrderStatus.ORDER_STATUS_PAID)
        {
            // ...
        }
    }
}

Lambda version:

// This is a member field somewhere: 
List<Action<Order>> orderProcessors;
 
// initialize it: 
orderProcessors =  new List<Action<Order>>() {
    ProcessPaidFurniture, 
    ProcessSomethingElse,
    ... 
};
 
// foreach loop:
foreach (var processor in orderProcessors)
    processor(order);
 
// For each business rule: 
void ProcessPaidFurniture(Order order) {
    // do checks & side effects as before 
}

If you need to reuse the same conditions for different side-effects, pass the side effect as a parameter (as a lambda). You'll have to work out the details depending on the needs of your project, though. If the lambda requires parameters that cannot be passed in by an IOrderProcessor, you'll have to find a way to create them somewhere else so that they can capture those parameters, or perhaps you can supply them as a data structure along with the Order instance.

Object Version:

// Add a lambda either to the constructor, or to the Process method.
// Here's the constructor version:
class PaidFurnitureProcessor : IOrderProcessor {
    public PaidFurnitureProcessor(Action action) {
        this.action = action;
    }

    public void Process(Order order) {
        if(/* do checks */) {
            this.action();
        }
    }
    
    // ...
}

// then
orderProcessors.Add(new PaidFurnitureProcessor(() => SendEmail()));
orderProcessors.Add(new PaidFurnitureProcessor(() => DoSomethingElse()));
...

//------------------------------------------------

// if it makes more sense to pass the action to the Process method, 
// change the IOrderProcessor interface, and then:

foreach (var processor in orderProcessors)
    processor.Process(order, action);

Lambda version:

// For each business rule: 
void ProcessPaidFurniture(Order order, Action action) {
    if(/* do checks */) {
        action();
    }
}

//------------------------------------------------

// If it makes more sense to have preconfigured side effects:
orderProcessors =  new List<Action<Order>>() {
    (order) => ProcessPaidFurniture(order, () => SendEmail()), 
    (order) => ProcessSomethingElse(order, () => DoSomethingElse()),
    ... 
};

// foreach loop:
foreach (var processor in orderProcessors)
    processor(order);

//------------------------------------------------

// If it makes more sense to pass the action at call time:
orderProcessors =  new List<Action<Order, Action>>() {
    (order, action) => ProcessPaidFurniture(order, action), 
    (order, action) => ProcessSomethingElse(order, action),
    ... 
};

// foreach loop:
foreach (var processor in orderProcessors)
    processor(order, action);

Or some variation of that. Or a combination (e.g., the action can be a full blown object if the action itself has complex behavior).


Alternatively, you can make these into predicates and have them return a bool, with true indicating that the order instance satisfies the conditions. This would then allow you to sort these into different "buckets" (e.g., you could preprocess the orders to find all orders that have to do with paid furniture and put them in a single list, then send that list, that now contains only "paid furniture" orders, somewhere to be processed according to that specific business rule.

So, something like:

var paidFurnitureOrders = allOrders.Where(order => IsPaidFurniture(order)).ToList();
ProcessPaidFurnitureOrders(paidFurnitureOrders);

// elsewhere:

public bool IsPaidFurniture(Order order) {
    bool result = order.order_type == OrderType.ORDER_TYPE_RETAIL && 
                  order.order_category == OrderCategory.ORDER_CATEGORY_FURNITURE && 
                  order.order_status == OrderStatus.ORDER_STATUS_PAID;
    
    return result;
    
}

Again, this could also be an object if you think there's a need, or if it's more convenient, or if you prefer it. Note also that you can combine multiple rules via composition, because they all have the same signature:

public bool ComboPredicate(Order order) { 
    return IsPaidFurniture(order) && IsSomethingElse(order);
}

P.S. You can also achieve composition with objects, but you have to separate out the rule and the action. Here are two ways to do it.

One might look something like this (again, each lambda can be a full blown object if required).

Instead of an IOrderProcessor interface that's implemented by different subtypes, you'd just have a single OrderProcessor class that takes in a rule and an action:

class OrderProcessor 
{
    Function<Order, bool> rule;
    Action<Order> action;
    public OrderProcessor(Function<Order, bool> rule, Action<Order> action) {
        this.rule = rule;
        this.action = action;
    }

    public void Process(Order order) {
        if(rule(order)) {
            action(order);
        }
    }
}

Then, for the simpler (non-composite rules), define rules and corresponding actions in the same file, and provide some way to get an OrderProcessor that combines them, e.g. a factory function:

public static CreateFurnitureProcessor() {
    return new OrderProcessor(paidFurnitureRule, paidFurnitureAction);
}

For rules defined in terms of other rules, you can create something like the ComboPredicate above, and assign a different action in the corresponding factory.

Another way is to have an abstract base class that provides a protected interface that's only visible to inheritors, and that separates the check from the action:

abstract class OrderProcessor 
{
    public void Process(Order order) {
        if(CheckRule(order)) {
            PerformAction(order);
        }
    }

    protected abstract bool CheckRule(Order order);
    protected abstract void PerformAction(Order order);
}

Simple, non-composite rules just implement the two protected methods in a straightforward way. A composite rule can implement PerformAction in the usual way, but for the check, it could create sub-rules (in the constructor (preferably), or right there in the method), and do:

    protected override bool CheckRule(Order order) {
        bool result = this.paidFurnitureProcessor.CheckRule(order) &&
                      this.someOtherProcessor.CheckRule(order));
        
        // or an OR, XOR, something more complicated...

        return result;
    }
    
    protected void PerformAction(Order order) { /* ... */ }

P.P.S. It really helps for code readability if you can find good names for methods/objects that implement these rules - you can try to infer some of them from the conditions you're checking, but ask the business people (domain experts) if you can, they likely already have terminology for these things.

10
  • Thanks for your detailed answer! I think I like the first approach: the conditional and subsequent processing is packaged neatly together in IOrderProcessor.
    – onepiece
    Oct 3 '21 at 4:06
  • You touched upon this in your P.S.: for both approaches, I'm concerned that the naming will get too unreadable. My Order class has many fields, and I'll add more fields over time. A conditional can be any combination of X choose Y fields. So a conditional involving say 10 fields may need naming that's the string concatenation of those 10 fields, which gets super long and unreadable. There's only so many concise terms that can encompass 10+ fields. How to address this?
    – onepiece
    Oct 3 '21 at 4:09
  • 3
    @onepiece - Ultimately, that's a problem for you to solve, based on your understanding of the business domain. Don't concatenate 10 names, though; you don't need to see the conditions in the name. Try to understand the meaning behind the check, the why behind it, and give it a concise name that reflects that. That's what's the most useful information in the name of a function; if someone wants to see the conditions, they can always look inside. Also, as I've said, the business people/users often already have a concise term that you can use, you just have to ask around or dig a little. Oct 3 '21 at 5:37
  • @onepiece - Another thing you might use to shorten the names is to see if perhaps there are clearly delineated major categories of checks that you can place into different namespaces or modules, so that you can reuse some of the names for similar operations that appear in these different contexts, if it makes sense to do so. If this is something that the business was doing before in a different way, and you're automating the process for them, then they probably had some system of classification and naming for their own use that you can draw inspiration from. Oct 3 '21 at 5:47
  • I see. Another question: what if I want to have these two separate conditionals: orderChange.OrderType == RETAIL and orderChange.OrderType == RETAIL && orderChange.OrderCategory == FOOD. But I only want one of them to trigger (xor). I could 1) put them in the same IOrderProcessor, but that hurts granularity 2) Create separate IOrderProcessors and place the more specific conditional earlier in the list. For orderChange.OrderType == RETAIL, also add && !orderChange.OrderCategory == FOOD, but this becomes hard to maintain the more conditionals there are.
    – onepiece
    Oct 3 '21 at 18:17
0

You basically want to implement some side effects whenever an order changes. So first you create an event on the object that manages those changes and you subscribe to it from whatever objects you potentially want to respond to order changes. You pass both the old and the new order as event arguments so interested parties can compare and act or not.

Your OrderChange object is just an event argument. Think of it as "Something changes. Now who would care? To whom could it matter?"

Then you do not handle it centrally. You do not need the knowledge of what to do in one place, which would be the real issue. Of course your application would already need to be setup in an object oriented manner. If you now find it is not, this may help you to make it that way.

3
  • I believe I'm on that path. OrderChange is being produced from another location. I'm writing code for a consumer of OrderChange that's only concerned about sending emails for OrderChanges. Since email use cases could be triggered by any combination of OrderChange field values, my question still applies.
    – onepiece
    Oct 3 '21 at 17:37
  • @ Martin Matt & @onepiece - Just wanted to say: an event is essentially just a collection of event handlers, which corresponds to my answer's list of orderProcessors (it's structurally the same) - so using events is certainly a way to do it, especially if some of the business rules need to detect a specific change, rather than act on a specific current state (combination of properties). But that's only a part of the puzzle; if you have a general event like that, you still end up with a forest of if-statements; the heart of the problem is how to represent these checks independently. Oct 3 '21 at 17:44
  • @onepiece - What Martin Maat is suggesting here is to have independent (decentralized) subscribers - which is what my IOrderProcessor objects (or functions) are - it's just that the signature of the Process method would be slightly different, as required by the event. Oct 3 '21 at 17:52

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.