Skip to content

Quick Start

scottctr edited this page Mar 29, 2018 · 54 revisions

Let's create a state machine to manage a sale from a simple point-of-sale (POS) system. The state machine code is actually quite brief, but we'll take time to explain what's going on to help you use NStateManager for your own projects. Depending on your experience with state management, it should take 30 - 60 minutes to work through the example and comprehend the details.

Let's jump in. Here are the requirements for managing our sales:

POSv1

  1. Sales start in an Open state and stay there as items are added
  2. When a payment is made, the state may
    • stay in Open if the amount is less than the balance
    • move to Complete if the amount is equal to the balance
    • move to ChangeDue if the amount is greater than the balance
  3. When change is given the state moves from ChangeDue to Complete
  4. Log a message each time a sale changes state.

Side note: Stop and consider how you would implement these requirements before learning how to accomplish them with NStateManager. These requirements are simple to implement, but keep in mind that they are for a simple POS system, possibly just a POC. Obviously, they would change and grow quickly for a production system. How well would your solution adapt to quickly changing requirements? This is when you'll be thankful you're using NStateManager! :-)

Preliminaries

Before setting up the state machine, we need to get a few things in place

  1. Classes to represent our business entities: sale, sale item, and payment
  2. Something to represent each of the sale's states
  3. Something to represent a sale's trigger events

Sale classes

Here are some simple sale, sale item, and payment classes that will meet our needs. Feel free to just copy them to your own solution.

public class Sale
{
    public double Balance { get; private set; }
    public List<SaleItem> Items { get; } = new List<SaleItem>();
    public List<Payment> Payments { get; } = new List<Payment>();
    public SaleState State { get; set; }
    public int SaleID { get; }

    public Sale(int saleID)
    {
       SaleID = saleID;
    }

    public void AddItem(SaleItem item)
    {
       Items.Add(item);
       updateBalance();
    }

    public void AddPayment(Payment payment)
    {
       Payments.Add(payment);
       updateBalance();
    }

    public void ReturnChange()
    {
        AddPayment(new Payment(Balance));
    }

    private void updateBalance()
    {
        Balance = Items.Sum(item => item.Price) - Payments.Sum(payment => payment.Amount);
    }
}

public class SaleItem
{
    public string Name { get; }
    public double Price { get; }

    public SaleItem(string name, double price)
    {
        Name = name;
        Price = price;
    }
}

public class Payment
{
    public double Amount { get; }

    public Payment(double amount)
    {
        Amount = amount;
    }
}

States

State is the condition something is in at a particular time. In our POS system, the states (Open, ChangeDue, Complete) determine what happens when items and payments are added. You can use any IComparable class to represent your states, but we'll use an enum.

public enum SaleState { Open, ChangeDue, Complete }

Trigger Events

Trigger events are the events that can trigger an action and/or change in state. Our solution will define actions to take when requests are made to add an item, make a payment, or return change. The result of those actions may also lead to a change in state.

NStateManager allows virtually any type of class to be used as a trigger, but we'll use another enum.

public enum SaleEvent { AddItem, Pay, ChangeGiven }

State Machine

With the preliminaries out of the way, let's look at how to define and configure a state machine.

Defining the StateMachine

Let's define our state machine and explore the various pieces.

_stateMachine = new StateMachine<Sale, SaleState, SaleEvent>(
  stateAccessor: (sale) => sale.State,
  stateMutator: (sale, state) => sale.State = state);

First, notice the 3 type arguments passed to our StateMachine

  1. Sale is the type argument (T) defining the context for our state machine.
  2. SaleState is the type argument (TState) defining the IComparable type used for the possible states.
  3. SaleEvent is the type argument (TTrigger) defining the type used for the triggers (i.e. events).

Second, you see there are 2 parameters required by the constructor:

  • stateAccessor is a function to get the current state of the Sale being processed.
  • stateMutator is an action to set the state when it needs to be updated.

Configure states

Now that we've created the state machine, it's time to configure the states, but first a key state management concept that should help understand what we're doing -- state and behavior must be interwoven for proper state management and execution of behaviors. In other words, state is a result of behavior and behavior is frequently based on current state. Because of this, configuring a state includes defining the behavior to take when a trigger occurs (i.e. trigger action) and when to transition to a new state.

_stateMachine.ConfigureState(SaleState.Open)
  .AddTriggerAction<SaleItem>(SaleEvent.AddItem, (sale, saleItem) =>
    {
        sale.AddItem(saleItem);
        Output.WriteLine($"Adding {saleItem.Name} for {saleItem.Price:C}. {getSaleStatus(sale)}");
    })
  .AddTriggerAction<Payment>(SaleEvent.Pay, (sale, payment) =>
    {
       sale.AddPayment(payment);
       Output.WriteLine($"Adding payment of {payment.Amount:C}. {getSaleStatus(sale)}");
    })
  .AddTransition(SaleEvent.Pay, SaleState.ChangeDue, condition: sale => sale.Balance < 0, name: "Open2ChangeDue", priority: 1)
  .AddTransition(SaleEvent.Pay, SaleState.Complete, condition: sale => Math.Abs(sale.Balance) < .005, name: "Open2Complete", priority: 2);

ConfigureState adds a new state to the state machine and returns an implementation of an IStateConfiguration that's used to configure the new state. You'll also notice that each IStateConfiguration method returns the same state configuration object to provide a fluent interface.

AddTriggerAction tells the state machine what action to perform when a trigger occurs. In the first case above, we define how to handle adding a new item to the sale. SaleEvent.AddItem is the trigger for adding an item. We're using a lambda expression as the action to take each time the trigger occurs. Also note that this call to AddTriggerAction includes an optional type argument (TRequest) of SaleItem. Including the type argument requires that later calls to StateMachine.FireTrigger for the AddItem event include an instance of a SaleItem and also makes that instance of the SaleItem available to the action defined here.

The second call to AddTriggerAction defines how to handle adding a payment and follows the same pattern as the first one, so nothing new here.

AddTransition defines when to transition to a new state. The first call to AddTransition says that when the SaleEvent.Pay trigger occurs, the sale should transition to the ChangeDue state if the condition is met -- balance is now less than 0. "Open2ChangeDue" is the name for this transition and "1" is the priority for this transition. More on priority in a moment.

The second call to AddTransition defines another possible transition for the SaleEvent.Pay trigger, but here the sale transitions to ChangeDue if its condition is met -- the balance becomes 0. The name of this transition is "Open2Complete" and the priority is "2".

Ideally the transition conditions are all mutually exclusive, but NStateManager attempts the transitions in priority order as a safety net -- stopping when the first transition succeeds. It's also important to note that the transitions are evaluated after any trigger actions are executed.

That does it for the Open state so let's look at the ChangeDue state.

_stateMachine.ConfigureState(SaleState.ChangeDue)
  .AddTriggerAction<Payment>(SaleEvent.ChangeGiven, (sale, payment) =>
    {
       sale.ReturnChange();
       Output.WriteLine($"Returning change of {payment.Amount:C}. {getSaleStatus(sale)}");
    })
  .AddTransition(SaleEvent.ChangeGiven, SaleState.Complete);

Hopefully, this looks somewhat familiar after walking through the Open state, but I will highlight a couple of things. Since the only action allowed in the ChangeDue state is ChangeGiven, we only have 1 trigger action and 1 transition. The trigger action follows the same pattern as the first 2 we looked at, but I have cheated here and used the Payment class to represent the change details. Since the Payment will have a negative amount, the math works and keeps things a bit simpler for this example.

The call to AddTransition here is much like the previous 2 we looked at, but there is one significant difference -- it doesn't include a condition. This means that a sale in the ChangeDue state will always go to Complete when change is given.

Side Note: We're only using simple transitions in this example. Take some time to explore the other transition options to see how to best meet your own requirements. You'll also find that you can define actions to take when entering and exiting states.

We haven't configured the Complete state, but we actually don't need to. Since Complete is a final state, we won't take action on any triggers or transition to any other states once the sale is completed.

State Machine vs. State Configuration

Another requirement for our POS system is to log each time a sale changes state. There are a couple of options to address this. We could use the IStateConfiguration.AddEntryAction to log each time a sale enters the given state, but that would require duplicating code for each state. The better option is to use StateMachine.RegisterOnTransitionedEvent to log all state transitions across the whole state machine.

_stateMachine.RegisterOnTransitionedAction((sale, transitionDetails) 
  => Output.WriteLine($"Sale {sale.SaleID} transitioned from {transitionDetails.PreviousState} to {transitionDetails.CurrentState}."));

The action for RegisterOnTransitionedAction provides the context (i.e Sale in our case) and the details of the transition that caused the state to transition.

There's another scenario where there's a choice between using the StateMachine and State functionality -- trigger actions. StateMachine has an AddTransitionAction similar to the one used above for the state configurations. Our actions were state-specific, but if we wanted to use similar actions for triggers in any state, we could use the StateMachine.AddTransactionAction to prevent duplicating code.

Using the state machine

Our state machine is fully configured for our current requirements, so let's see how to use it.

//Add an item to a sale
_stateMachine.FireTrigger(sale, SaleEvent.AddItem, saleItem); 

//Add a payment to a sale
_stateMachine.FireTrigger(sale, SaleEvent.Pay, payment);

//Give the customer their change
_stateMachine.FireTrigger(sale, SaleEvent.ChangeGiven, payment);

StateMachine.FireTrigger is how we make use of a state machine . The first parameter is the context. The second parameter is the trigger that's occurring. And the third parameter is the details of the event that's occurring. Note that you will not use the third parameter if you didn't define a parameter type (TRequest) on the associated call to AddTriggerAction.

Exposing the sale state machine

I've wrapped all of the code above in a SaleStateMachine class to make it easier to access. You should review that class to see how all the pieces come together and use it to implement the following test.

Testing it all out

Now let's create a simple test case and look at the output.

static void Main(string[] args)
{
  var sale = new Sale(saleID: 1);
  SaleStateMachine.AddItem(sale, new SaleItem("Magazine", price: 3.00));
  SaleStateMachine.AddItem(sale, new SaleItem("Fuel", price: 10.00));
  SaleStateMachine.AddPayment(sale, new Payment(amount: 10.00));
  SaleStateMachine.AddPayment(sale, new Payment(amount: 10.00));
  SaleStateMachine.ReturnChange(sale, new Payment(amount: -7.00));
  //We should be in Complete state here, so the following actions should be ignored
  SaleStateMachine.AddItem(sale, new SaleItem("Magazine", 3.00));
  SaleStateMachine.AddItem(sale, new SaleItem("Fuel", 10.00));

  System.Console.Read();
}
Adding Magazine for $3.00.
--> SaleState: Open             Balance: $3.00.
Adding Fuel for $10.00.
--> SaleState: Open             Balance: $13.00.
Adding payment of $10.00.
--> SaleState: Open             Balance: $3.00.
Adding payment of $10.00.
--> SaleState: Open             Balance: -$7.00.
Sale 1 transitioned from Open to ChangeDue.
Returning change of ($7.00).
--> SaleState: ChangeDue        Balance: $0.00.
Sale 1 transitioned from ChangeDue to Complete.

The output confirms our configuration meets our requirements:

  1. Added a magazine and fuel to give us a balance of $13.00 and still in the Open state.
  2. Made a payment of $10.00 to bring the balance $3.00 and still in the Open state.
  3. Made a second payment of $10.00 to bring the balance to -$7.00 and the state changed to ChangeDue.
  4. Returned change of $7.00 to customer to bring the balance to 0 and the state changed to Complete.
  5. The last 2 attempts to add items were ignored since there aren't any trigger actions or transitions defined for the Complete state.

That's it. We've created a state machine to manage the sale component of a POS system. I hope it helps you understand how NStateManager can help you to create more elegant solutions. If you still have questions, you can look at the complete example, check out the rest of the documentation, or ask questions in the Issues section.

Next?

Want to test your understanding before using in your own project? Try adding some additional functionality to this example. This will help ensure you understand how NStateManager works and demonstrate how NStateManager can reduce the effort required to adapt to changing requirements.

  • Add ability to cancel a sale, including a Cancelled final state.
  • Add ability to add more items to a sale in lieu of receiving change back.
    • What if new items exceed amount of change due?
  • Delay completing a sale until items, like fuel, are fully delivered to the customer.
    • New sale state?
    • Item state?
    • Can additional items be added to the sale while waiting for delivery?
    • What if customers don't pump the amount of fuel purchased?
  • Add ability to return items.
    • Does this mean that Complete isn't a final state?
    • Update item state when returned to prevent it from being returned multiple times.
    • Add a new state to represent a sale executing a return to prevent multiple terminals from refunding simultaneously.