20 Questions

In the game 20 questions one player thinks of something and the other players ask yes or no questions in an attempt to determine what the first player is thinking of.  The first player considers each question in light of his privately held information and replies with the appropriate response.

This is a great model for encapsulation.  The first player thinks of an object and holds it in memory, and the details of the object are not available to the other players (private state).  Other player ask questions (invoke behaviors) to gather information about the object.  The behaviors are limited to yes or no questions and consist mostly of a common, predictable set of useful questions:  Is it an animal, vegetable, or mineral?  Is it bigger or smaller than a breadbox? Is it something you eat? etc.

Suppose the game ends without a successful guess, and the first player thinks of another object for the next round of questions.  The new object replaces the previous one in his mind, and the other players repeat the questioning process.  Are the previous set of questions still valid?  Absolutely.  Are they relevant?  For the most part.  Are they useful?  Probably.

The internal state has changed, but the interactions have not.  The same set of behaviors still applies although it may produce a different set of responses.  This is wonderful if we are writing a 20 questions simulator, but how does this apply to real world business objects?

Suppose we are modeling an object that represents an order.  Right off the bat we are told that the order can have the following statuses: New, Processing, Shipped, Completed, and Canceled.  So we naively create an OrderStatus enumeration with those values and model the Order object like so:

public class Order {
    public OrderStatus Status { get; private set;}

    public Order() {
        Status = OrderStatus.New;
    }

    public void Submit() {
        if(Status == OrderStatus.Canceled)
            throw new OrderException("Already canceled");
        if(Status != OrderStatus.New)
            throw new OrderException("Already submitted");
        Status = OrderStatus.Processing;
    }

   public void Ship() {
       if(Status == OrderStatus.New)
           throw new OrderException("Not submitted");
       if(Status == OrderStatus.Shipped)
           throw new OrderException("Already shipped");
       if(status == OrderStatus.Canceled) 
           throw new OrderException("Already canceled");
       if(status == OrderStatus.Completed) 
           throw new OrderException("Already completed");
       Status = OrderStatus.Shipped;
   }

   public void Deliver() {
        if(Status == OrderStatus.New) 
            throw new OrderException("Not submitted");
        if(Status == OrderStatus.Processing) 
            throw new OrderException("Not shipped");
        if(status == OrderStatus.Canceled) 
            throw new OrderException("Already canceled");
        if(status == OrderStatus.Completed) 
            throw new OrderException("Already completed");
       Status = OrderStatus.Completed;
   }
   
   public void Cancel() {
        if(status == OrderStatus.Shipped) 
            throw new OrderException("Already shipped");
        if(status == OrderStatus.Canceled) 
            throw new OrderException("Already canceled");
        if(status == OrderStatus.Completed) 
            throw new OrderException("Already completed");     
        Status = OrderStatus.Canceled;
    }
}

Now, as soon as that hits source control we’re told that there is also an Invalid status that would prevent the order from being shipped.  That value must then be added to the enum and the Ship() method must be modified to enforce the new business rule.  Also, any reference to Order.Status will need to be evaluated to ensure that the new enum value does not break existing logic—perhaps there is a switch statement with cases for the first set of values known at the time and a default block that throws an exception.  If the order ever enters the Invalid state then such code would fail in undefined ways.

Even if such concerns could be easily addressed, suppose yet another new requirement dictates that cancelations should have an effective date that can be in the future, and that there should be a distinction between orders that have been canceled versus orders that are currently canceled.  Such a change would require a change in the representation of internal state, with additional properties, which would affect code downstream.

What what if the original Order object was instead written in such a way that it’s state was exposed with 20-questions style behaviors?

public class Order {
    private OrderStatus _status;

    public bool CanBeSubmitted {
        get { return _status == OrderStatus.New; }
    }
    public bool IsSubmitted { 
        get { return _status == OrderStatus.Processing; }
    }
    public bool HasBeenSubmitted { 
        get { return _status != OrderStatus.New; }
    }
    public bool IsReadyToShip { 
        get { return _status == OrderStatus.Procesisng; }
    }
    public bool HasBeenShipped { 
        get { return _status == OrderStatus.Shipped; }
    }
    public bool IsCanceled { 
        get { return _status == OrderStatus.Canceled; }
    }
    public bool IsCompleted { 
        get { return _status == OrderStatus.Completed; }
    }

    public Order() {
        Status = OrderStatus.New;
    }

    public void Submit() {
        if(IsCanceled) 
            throw new OrderException("Order has been canceled");
        if(HasBeenSumitted) 
            throw new OrderException("Order already submitted");
        Status = OrderStatus.Processing;
    }

    public void Ship() {
        if(IsCanceled) 
            throw new OrderException("Order has been canceled");
        if(IsCompleted) 
            throw new OrderException("Order has been completed");
        if(HasBeenShipped) 
            throw new OrderException("Order has already been shipped");
        if(!IsReadyToShip) 
            throw new OrderException("Order not ready to ship");
        Status = OrderStatus.Shipped;
    }

    public void Deliver() {
        if(IsCanceled) 
            throw new OrderException("Order has been canceled");
        if(IsCompleted) 
            throw new OrderException("Order has been completed");
        if(!HasBeenShipped) 
            throw new OrderException("Order has not been shipped");
        Status = OrderStatus.Completed;
    }
   
    public void Cancel() {
        if(IsCanceled)
            throw new OrderException("Order has been canceled");
        if(IsCompleted)
            throw new OrderException("Order has been completed");
        if(HasBeenShipped) 
            throw new OrderException("Order has already been shipped");
        Status = OrderStatus.Canceled;
     }
}

Now the new requirements can be folded in easily by changing the internal state and adding new behaviors, while leaving the existing behaviors as they are:

public class Order {
    private bool _submitted;
    private bool _invalid;
    private bool _processed;
    private bool _shipped;
    private bool _canceled:
    private DateTime _cancelationDate;
    private bool _completed;

    public bool CanBeSubmitted { 
        get { return !_submitted && !_canceled; }
    }
    public bool IsSubmitted { 
        get { return _submitted && !_processed && !_canceled; }
    }
    public bool HasBeenSubmitted { get { return _submitted; } }
    public bool IsReadyToShip { 
        get { return !_invalid && _processed && !_shipped && !_canceled; }
    }
    public bool HasBeenShipped { get { return _shipped; } }
    public bool IsCanceled { 
        get { return _canceled && DateTime.Now >= _cancelationDate; }
    }
    public bool IsCompleted { get { return _completed; } }

    // New behaviors
    public bool IsInvalid { return _invalid; }
    public bool HasBeenCanceled { return _canceled; }

    public Order() {
    }

    public void Submit() {
        if(IsCanceled) 
            throw new OrderException("Order has been canceled");
        if(HasBeenSumitted) 
            throw new OrderException("Order already submitted");
        _submitted = true;
    }

    public void MarkInvalid(string reason) {
        _invalid = true;
    }

    public void MarkValid()
    {
        _invalid = false;
    }

    public void Ship() {
        if(IsCanceled) 
            throw new OrderException("Order has been canceled");
        if(IsCompleted) 
            throw new OrderException("Order has been completed");
        if(HasBeenShipped) 
            throw new OrderException("Order has already been shipped");
        if(!IsReadyToShip) 
            throw new OrderException("Order not ready to ship");
        if(IsInvalid)
            throw new OrderException("Order not valid");
        _shipped = true;
    }

   public void Deliver() {
       if(IsCanceled) 
           throw new OrderException("Order has been canceled");
       if(IsCompleted) 
           throw new OrderException("Order has been completed");
       if(!HasBeenShipped) 
           throw new OrderException("Order has not been shipped");
       _completed = true;
   }

   public void Cancel() {
       Cancel(DateTime.Now);
   }   

   public void Cancel(DateTime asOfDate) {
        if(IsCanceled)
            throw new OrderException("Order has been canceled");
        if(IsCompleted)
            throw new OrderException("Order has been completed");
        if(HasBeenShipped) 
            throw new OrderException("Order has already been shipped");
        _canceled = true;
        _cancelationDate = asOfDate;
    }
}

Notice that because the object calls these behaviors internally, the method bodies themselves did not change much (if at all).  This provides a good indication that downstream consumers will also be protected against the changes that were made.

That isn’t to say that all state exposed by objects should be boolean in nature.  But when choosing the types of data to expose, it’s helpful to consider how those types might change over time.  Exposing enums directly is probably not a good idea most of the time, but descriptive strings and public value objects are probably safe.  The point is that external callers should only be able to access the information that is required and no more, and usually this can be elegantly handled by a series of very simple behaviors that enable the called object hide (and protect others from) its logic.

Comments

Popular posts from this blog

Non-nullable Reference Types

Objects Are People, Too

Named Locks and Lock Striping