-
Notifications
You must be signed in to change notification settings - Fork 6
Quick Start
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:
- Sales start in an Open state and stay there as items are added
- 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
- When change is given the state moves from ChangeDue to Complete
- 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! :-)
Before setting up the state machine, we need to get a few things in place
- Classes to represent our business entities: sale, sale item, and payment
- Something to represent each of the sale's states
- Something to represent a sale's trigger events
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;
}
}
You can use any IComparable class to represent your states, but we'll use an enum.
public enum SaleState { Open, ChangeDue, Complete }
Trigger events are the events that can trigger 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 }
With the preliminaries out of the way, let's look at how to define and configure a state machine.
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
-
Sale (
T
) is the type of object being managed. -
SaleState (
TState
) is the IComparable type used for the possible states. -
SaleEvent (
TTrigger
) is the type used for the triggers (i.e. events) that can lead to a change in state.
Second, you see there are 2 parameters required by the constructor:
-
stateAccessor
is a function to get the current state of an object -
stateMutator
is an action to set the state when it's updated
Now that we've created the state machine, it's time to configure it, but I want to introduce a key state management concept first -- 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, leaving, and reentering states.
We haven't configured the Completed
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.
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 a new state, but that would require duplicating code for each state. The better option is to use StateMachine.RegisterOnTransitionedEvent
to handle 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.
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 (i.e object we're managing state for). The second parameter is the trigger (or event) that's occurring. And the third parameter are 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
.
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.
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:
- Added a magazine and fuel to give us a balance of $13.00 and still in the Open state.
- Made a payment of $10.00 to bring the balance $3.00 and still in the Open state.
- Made a second payment of $10.00 to bring the balance to -$7.00 and the state changed to ChangeDue.
- Returned change of $7.00 to customer to bring the balance to 0 and the state changed to Complete.
- 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.
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.