State Machine Design pattern — Part 2: State Pattern vs. State Machine

Kousik Nath
DataDrivenInvestor

--

In the last post, we talked about using State Machine to build state-oriented systems to solve several business problems. Before we start building any proper state machine example, it’s better if we explore other alternatives & discuss their pros & cons. It will help us to properly realise the potential of State Machine design patterns.

Problem Statement:

Let’s consider a very simple version of an Uber trip life cycle. The life cycle consists of the following states & transitions as described in the image below. Let’s find out different approaches to build this state-oriented system.

The Basic Approach:

The intuitive approach that comes into mind first is to handle states & transitions through simple if else. But this approach does not scale, with every new state / transition addition / deletion, you need to change the big block of if else / switch statements that drive the whole logic. Refer to the below code to identify how much messy the code looks & just imagine what happens when the code base grows massively 😮. This approach can work with extremely static transitions & states, but that chance is very rare. You should avoid this method as it would become a huge maintenance overhead. Example Code:

void manageStatesAndTransitions(Event event, InputData data) {
State nextState = getNextState(event, data);

switch(nextState) {
case State.TRIP_REQUESTED:
handleTripRequest(event, data);
break;

case State.PAYMENT:
handlePayment(event, data);
break;

case State.DRIVER_ASSIGNED:
handleDriverAllocation(event, data);
break;

case State.DRIVER_CANCELLED:
handleTripCancellationByDriver(event, data);
break;

case State.CUSTOMER_CANCELLED:
handleTripCancellationByCustomer(event, data);
break;
}
}

void handleTripRequest(Event event, InputData data) {

if(event == Event.trip_requested) {
// Check pre-conditions
// do some work if needed...

manageStatesAndTransitions(Event.payment_requested, data);
} else if(event == Event.payment_failed) {
showBookingError();
}
}

void handlePayment(Event event, InputData data) {
if(event == Event.payment_requested) {
Payment payment = doPayment(data);

if(payment.isSuccess()) {
manageStatesAndTransitions(Event.payment_success, data);
} else {
manageStatesAndTransitions(Event.payment_failed, data);
}
} else if(event == Event.payment_failed) {
manageStatesAndTransitions(Event.payment_failed, data);
} else if(event == Event.payment_success) {
manageStatesAndTransitions(Event.driver_assigned, data);
}
}

State Pattern Approach:

State pattern is one of the behavioural design patterns devised by Gang Of Four. In this pattern, the concerned object holds internal state which can change & the object’s behaviour changes accordingly.

Characteristics:

  1. The state-specific behaviours are defined in different classes & the original object delegates the execution of the behaviour to the current state’s object implementation.
  2. States trigger state transitions from one state to another.
  3. All states implement a common interface that defines all the possible behaviours / actions.

Let’s model the Uber trip states through this mechanism below:

  1. We will define an interface which represents the contract of a state. All states will implement these methods which dictate the behaviour of the object at a certain state. If a method is not applicable in a particular state, the state will ignore defining any action in that method:
interface State
{
void handleTripRequest();
void handlePaymentRequest();
void handleDriverCancellation();
void handleCustomerCancellation();
void completeTrip(); // Driver completes trip, Unassign the driver.
void endTrip(); // After driver is unassigned, do driver & customer rating, take feedback etc.
}

2. We will do a concrete implementation of different states.
TripRequested state:
This is the initial state when customer requests for a trip. It implements the handleTripRequest method and after successful initiation, it sets the state to Payment. So this state indirectly calls Payment state.

class TripRequested implements State {

UberTrip context;

public TripRequested(UberTrip ctx) {
this.context = ctx;
}

@Override
void handleTripRequest() {
if(!context.tripStarted()) {
context.setState(context.getPaymentRequestedState());
}
}

@Override
void handlePaymentRequest() {
System.out.println("This state just handles initiation of trip request, it does not handle payment");
}

@Override
void handleDriverCancellation() {
System.out.println("This state just handles initiation of trip request, it does not handle cancellation");
}

@Override
void handleCustomerCancellation() {
System.out.println("This state just handles initiation of trip request, it does not handle cancellation");
}

@Override
void completeTrip() {
System.out.println("This state just handles initiation of trip request, it does not handle trip completion");
}

@Override
void endTrip() {
System.out.println("This state just handles initiation of trip request, it does not handle ending trip.");
}
}

Payment state:
It handles payment request, success & failure states. On success, it sets the trip’s state to DriverAssigned, on failure, it sets the trip’s state to TripRequested.

class Payment implements State {

UberTrip context;

public PaymentRequested(UberTrip ctx) {
this.context = ctx;
}

@Override
void handleTripRequest() {
System.out.println("Payment state does not handle trip initiation request.");
}

@Override
void handlePaymentRequest() {
Payment payment = doPayment();

if(payment.isSuccess()) {
context.setState(context.getDriverAssignedState()); // Call driver assigned state.
} else {
context.setState(context.getTripRequestedState()); // Call trip requested state
}
}

@Override
void handleDriverCancellation() {
System.out.println("Payment state just handles payment, it does not handle cancellation");
}

@Override
void handleCustomerCancellation() {
System.out.println("Payment state just handles payment, it does not handle cancellation");
}

@Override
void completeTrip() {
System.out.println("Payment state just handles payment, it does not handle trip completion");
}

@Override
void endTrip() {
System.out.println("Payment state just handles payment, it does not handle ending trip.");
}
}

DriverAssigned state:
When assigned driver cancels the trip, the trip’s state is set to TripRequested state so that a new trip request starts automatically. When the driver completes the trip, the trip’s state is changed to DriverUnAssigned state.

class DriverAssigned implements State {

UberTrip context;

public DriverAssigned(UberTrip ctx) {
this.context = ctx;
}

@Override
void handleTripRequest() {
System.out.println("Driver Assigned state does not handle trip initiation request.");
}

@Override
void handlePaymentRequest() {
System.out.println("Driver Assigned state does not handle payment request.");
}

@Override
void handleDriverCancellation() {
context.setState(context.getTripRequestedState()); // If driver cancels, go back to trip requested state & try again.
}

@Override
void handleCustomerCancellation() {
System.out.println("Driver Assigned state does not handle customer cancellation.");
}

@Override
void completeTrip() {
context.setState(context.getDriverUnAssignedState()); // Call driver unassigned state
}

@Override
void endTrip() {
System.out.println("Driver Assigned state does not handle ending trip.");
}
}

CustomerCancelled state:
When a customer cancels the trip, a new trip request is not automatically retried, rather, the trip’s state is set to DriverUnAssigned state.

class CustomerCancelled implements State {

UberTrip context;

public CustomerCancelled(UberTrip ctx) {
this.context = ctx;
}

@Override
void handleTripRequest() {
System.out.println("Customer Cancelled state does not handle trip initiation request.");
}

@Override
void handlePaymentRequest() {
System.out.println("Customer Cancelled state does not handle payment request.");
}

@Override
void handleDriverCancellation() {
System.out.println("Customer Cancelled state does not handle driver cancellation.");
}

@Override
void handleCustomerCancellation() {
context.setState(context.getDriverUnassignedState()); // If customer cancels, umassign the driver & related stuffs.
}

@Override
void completeTrip() {
System.out.println("Customer Cancelled state does not handle trip completion.");
}

@Override
void endTrip() {
System.out.println("Customer Cancelled state does not handle ending trip.");
}
}

Further, DriverUnAssigned state can handle customer / driver rating & feedback accordingly & moves trip’s state to TripEnd state. It’s not shown in the code here.

UberTrip class:
This is the class that describes all possible actions on the trip. It manages an internal state which gets set by individual state objects. UberTrip delegates the behaviour to individual state objects.

class UberTrip {
State tripRequestedState;
State paymentState;
State driverAssignedState;
State driverUnassignedState;
State customerCancelledState;

// CurrentState
State state;

public UberTrip() {
tripRequestedState = new TripRequested(this);
paymentState = new Payment(this);
driverAssignedState = new DriverAssigned(this);
driverUnassignedState = new DriverUnAssigned(this);
customerCancelledState = new CustomerCancelled(this);
}

public setState(State st) {
this.state = st;
}

public getState() {
return this.state;
}

public void requestTrip() {
state.handleTripRequest();
}

public void doPayment() {
state.handlePaymentRequest();
}

public void driverCancelled() {
state.handleDriverCancellation();
}

public void customerCancelled() {
state.handleCustomerCancellation();
}

public void completeTrip() {
state.completeTrip();
}
}

Pros & Cons of State Pattern:

There is no explicit transition defined in this system. Transitions are handled by the states themselves. States can define checks based on some parameters to validate whether it can call the next state or not. This is quite a messy way to implement state-based systems, transitions are still tightly coupled with the states & states take the responsibility to call the next state by setting the next state in the context object ( here the UberTrip object ). So logically a state object handles its own behaviour & next possible transitions — multiple responsibilities. With more new states & transitions, the code base might become junk. Also, all the state objects implement common behaviours through the interface which really seems unnecessary & extra work in real life development. This pattern is better than the basic if else / switch based approach in the way that here you think about decomposing your application process into states & divide behaviours into multiple states, but since transitions are implicitly handled by states themselves, this method is not scalable & in real life you might end up violating Open Closed — Open for extension & closed for Modification principal.

In the next post, we will discuss implementing a proper state machine through Spring State Machine.

--

--

Deep discussions on problem solving, distributed systems, computing concepts, real life systems designing. Developer @Uber. https://in.linkedin.com/in/kousikn