State pattern with a real-world example
Overview of the pattern
The core idea of the state pattern is that an object change behavior based on it’s state. We will show how it works in the example. Usually this pattern is applied when you have some state inside the object and your business logic changes based on that state!
Car insurance example
I will present you the problem that I had while working for a car insurance company. I just ommited details of the forms and simplified conditions for the same of simplicity of the example but the core logic and idea is still the same. Here is the problem:
We want to create damage processor service. Whenever our customer has an accident with his car, he came to our app and fulfills some forms. Which forms will be presented to the person and in which order depends from the current state of the wizard! Here is an image that explains in which order the client needs to submit those forms:
Our wizard first show IncidentInfoForm
to the client, then based on the location that is submitted inside that form, we go to either ForeignIncidentForm
or DomesticIncidentForm
and finally regardless of location we populate CreditCardInfoForm
. When client populates ForeignIncidentForm
, we have to publish event to the kafka topic which is listened by service that is implemented by internatinal police. Also, when user populates credit card form, we just need to call external bank service just to check if his credit card info is valid! If credit card info is not valid, exception is thrown and user is prompted to input the info again. He can make a mistake not more than 3 times in a row. If he enter his credit card wrong 3 times in a row, all the submitted data until that point is deleted and user is returned to the IncidentInfoForm
.
There are a few states in which our Person
can be:
- Person didn’t populated anything, ready to populate
IncidentInfoForm
->StateIncidentInfo
- Person populated
IncidentInfoForm
, location is foreign ->StateForeign
- Person populated
IncidentInfoForm
, location is domestic ->StateDomestic
- Person populated ForeignIncidentForm / DomesticIncidentForm ->
StateCreditCard
Program behave differently based on the state!
It’s very important to realize that program behavior changes depending on the state. Based on the data inside IncidentInfoForm we will present next form, either DomesticIncidentForm
or ForeignIncidentForm
. Also, if we try to submit credit card info while in the StateCreditCard
state, everything will be fine but if we try to submit credit card info while in any other state, exception should be thrown.
This state machine problem is ideal for our state pattern. Let’s resolve this problem with state pattern properly and then later we will try to answer why some alternative tempting solutions are not good.
Let’s jump to the code
Wizard class:
public class Wizard {
private WizardState state = new StateIncidentInfo();
private final Set<Form> submittedForms = new HashSet<>();
void submitForm(Form form) {
state.submitForm(form, this);
}
public String getNextForm() {
return state.supportsForm();
}
public Set<Form> getSubmittedForms() {
return submittedForms;
}
public void setState(WizardState state) {
this.state = state;
}
}
Resonsibilities: which forms we already populated and what data is inside(submittedForms
field), which form should be populated next(getNextForm
method), what is the state of the wizard(state
field), etc.
Important note: if we add new state or change some rule regarding transitioning from one state to another, we should not touch this Wizard class at all. We expect that our transitioning logic will be complex and that also transitioning from one state to another will be complex and that is the main reason why we will create one class per state. All the domain knowledge while in one state and transitions related to that one state will be inside the class that supports that state.
WizardState class:
public abstract class WizardState {
protected abstract void doSubmitForm(Form form, Wizard wizard);
public void submitForm(Form form, Wizard wizard) {
if (!form.getClass().getSimpleName().equals(supportsForm())) {
throw new RuntimeException("Form " + form.getClass().getSimpleName()
+ "is not supported in the state" + this.getClass().getSimpleName());
}
doSubmitForm(form, wizard);
}
public abstract String supportsForm();
}
As you can see in the requirements, every state supports only one form. That is the purpose of the supportsForm
method. It returns the name of the form that can be submitted in the particular state. submitForm
is doing basic check that if we try to submit wrong form, exception should be thrown. Otherwise, delegate to the abstract doSubmitForm
method! doSubmitForm
method implementation will include state transitions and specific business knowledge for a concrete state!
Notice that doSubmitForm
accepts both form and wizard context as a parameters. This is important because we will maybe need info about history/context of the wizard while processing some form.
Concrete forms
Let’s start with the concrete forms, and start with the first one:
public class StateIncidentInfo extends WizardState {
@Override
protected void doSubmitForm(Form form, Wizard wizard) {
wizard.getSubmittedForms().add(form);
IncidentInfoForm incidentInfoForm = (IncidentInfoForm) form;
if (incidentInfoForm.isForeign()) {
wizard.setState(new StateForeign(new KafkaProducer()));
} else {
wizard.setState(new StateDomestic());
}
}
@Override
public String supportsForm() {
return IncidentInfoForm.class.getSimpleName();
}
}
From the supportsForm
method you can conclude that while inside this state you can only submit IncidentInfoForm
. And inside the doSubmitForm
, we see specific state transition and form handling for this state. Next states are StateForeign
and StateDomestic
(which shouldn’t be presented becauseit’s trivial):
public class StateForeign extends WizardState {
private final KafkaProducer kafkaProducer;
public StateForeign(KafkaProducer kafkaProducer) {
this.kafkaProducer = kafkaProducer;
}
@Override
protected void doSubmitForm(Form form, Wizard wizard) {
wizard.getSubmittedForms().add(form);
kafkaProducer.sendMessage("//Properly formatted message","InternationalPoliceTopic");
wizard.setState(new StateCreditCard(new BankService()));
}
@Override
public String supportsForm() {
return ForeignIncidentForm.class.getSimpleName();
}
}
Nothing to explain here, everything is straightforward. The last state is StateCreditCard
which is a bit complex but still we are just doing state transitioning and specific business knowledge for that state!
public class StateCreditCard extends WizardState {
private int wrongCount = 0;
private final BankService bankService;
@Override
protected void doSubmitForm(Form form, Wizard wizard) {
CreditCardInfoForm f = (CreditCardInfoForm) form;
if (bankService.isCreditCardIdValid(f.getCreditCardId())) {
wizard.getSubmittedForms().add(form);
} else {
wrongCount++;
if (wrongCount >= 3) {
wizard.setState(new StateIncidentInfo());
}
throw new IllegalArgumentException("Credit card info incorrect!");
}
}
@Override
public String supportsForm() {
return CreditCardInfoForm.class.getSimpleName();
}
}
Form classes are pure simple dto classes so I would not show them.
Open closed principle
Take a note how our code is aligned with open closed principle. We can extend our code by supporting new forms but not by modifying our already existing classes but by creating new classes for new states.
Single responsibility principle
Take a note how StateCreditCard
class is responsible only for one state and totally independent of all other classes! You can independently unit test that one class.
Divide and conquer
This is the main benefit from the pattern. We divided complex logic and state transitioning to the multiple classes. Each of those classes handle one state(piece of business knowledge). In the future it will be much easier to maintain this software and much easier to add new forms.
What is the alternative to the state pattern?
Without the state pattern code would look something like this:
public class FormsProcessor {
private String state = "StateIncidentInfo";
private List<Form> submittedForms = new ArrayList<>();
public void process(Form form) {
switch (state) {
case "StateIncidentInfo":
if (!form.getClass().getSimpleName(). equals(IncidentInfoForm.class .getSimpleName())) {
throw new IllegalArgumentException("You cannot submit IncidentInfoForm inside the StateIncidentInfo state")
}
submittedForms.add(form);
IncidentInfoForm incidentInfoForm = (IncidentInfoForm) form;
if (incidentInfoForm.isForeign()) {
state = "StateForeign";
} else {
state = "StateDomestic";
}
case "StateCreditCard":
//...
}
}
}
There are multiple problems here:
- There is no way to unit test one of the states because multiple states(units) are inside the same method!
- Bug discovery time is too late. You can very easily forget to add that if check where you throw a
IllegalArgumentException
and you will notice the bug only at runtime, everything will compile just fine! - Every time you change your logic you are modifying current code which means you are not following open closed principle. And modifying code means that you can break things that already work!
- This
process
method will become big very fast. Just 7-8 states with ~50 lines of code for every state and that is 350-400 lines of code in one method. Of course, there are no limits that are burried at stone about how many lines of code one method should contain but if it does contains 350 lines that should be a red flag. - If two persons are trying to add states in parralel, it’s much easier for those persons to end up with merge conflicts with this bad approach because they don’t have separate units that they are working on. They are working on the same unit called
process
method - You saw that some of the states contains dependencies like
BankService
orKafkaProducer
. But every state should contain maybe one or two dependencies. However, if all states are inside theprocess
method, thatFormProcessor
can very easily grow to 10+ dependencies which is once again a red flag
Conclusion
We divided bigger problem to more smaller ones. Once again we saw that design pattern is nothing else but applying SOLID priciples(open closed and single responsibility) in a well known manner. At the end, I think that if you liked this article, you would probably like a BPMN diagrams and Java implementation of those diagrams. One of the Java BPMN implementations that I worked with and liked it is called camunda.