-
Notifications
You must be signed in to change notification settings - Fork 21
Actors, use cases and flows
An actor is a stateful entity that has a behavior that changes over time. Depending on what it has done before, an actor accepts or rejects certain messages.
Say the user wants to calculate the volume of a pizza.
Here's an actor with that behavior:
public class PizzaVolumeCalculator extends AbstractActor {
...
@Override
protected Model behavior() {
Model model = Model.builder()
.useCase("Calculate Pizza Volume")
.basicFlow()
.step("S1").user(EnterRadius.class).system(saveRadius)
.step("S2").user(EnterHeight.class).system(saveHeight)
.step("S3").user(CalculateVolume.class).systemPublish(calculateVolume)
.step("S4").continuesAt("S1")
.flow("Negative radius").after("S1").condition(isNegativeRadius)
.step("S1a_1").system(throwIllegalRadiusException)
.flow("Negative height").after("S2").condition(isNegativeHeight)
.step("S2a_1").system(throwIllegalHeightException)
.build();
return model;
}
...
}
You can find the corresponding unit test here.
A console application to try it out is here.
A model contains use cases with flows.
A use case represents a goal of a user, like 'buy product'.
A flow defines the steps to reach the goal, or to handle errors along the way.
A user can be a human user, like an e-commerce customer, or a system or service.
As you can see, the Calculate Pizza Volume
use case has three flows, one basic flow and two alternative flows.
A flow is a sequence of steps. A step can run if the step before it in the flow has run, or if it's the first step of a flow. There are two ways to progress in a flow:
-
A step reacts to a message if the message is an instance of the class specified in the step. For example, if your code calls
pizzaVolumeCalculator.reactTo(new EnterRadius(...));
, it runs theS1
step. After that, it is ready to run theS2
step. -
If the step doesn't specify a message class, it runs automatically. For example, the step
S1a_1
doesn't specify a message class, so it runs automatically after stepS1
, if the radius is negative.
A use case has exactly one basic flow. Either you specify it at the beginning of the model. Or requirements as code creates an empty one implicitly. You should state the 'happy day scenario' in the basic flow: the case where nothing goes wrong, and the user reaches her goal.
The header of an alternative flow should either contain a flow position, e.g. after("S1")
, or a condition, e.g condition(isNegativeRadius)
, or both. Such a flow starts when:
- the model has been run up to the flow position (if present), and
- the condition is fulfilled (if present), and
- the message to react to is an instance of the class defined in the first step of the alternative flow (or the first step defines no message).
IMPORTANT NOTE: if there's neither a flow position, nor a condition in the header, it does not mean that the flow can start at any time. It means that the flow can only start right at the beginning, when no step has been run. That's the case with the basic flow in the example. If you want to express that a flow can start at any time, use the flow position anytime()
instead.
Let's look at these two example flows:
.basicFlow()
.step("S1").user(EnterRadius.class).system(saveRadius)
.step("S2").user(EnterHeight.class).system(saveHeight)
.step("S3").user(CalculateVolume.class).systemPublish(calculateVolume)
.step("S4").continuesAt("S1")
.flow("Negative radius").after("S1").condition(isNegativeRadius)
.step("S1a_1").system(throwIllegalRadiusException)
What happens when your code makes the following calls?
PizzaVolumeCalculator pizzaVolumeCalculator = new PizzaVolumeCalculator();
pizzaVolumeCalculator.reactTo(new EnterRadius(4));
pizzaVolumeCalculator.reactTo(new EnterHeight(5));
Optional<Double> pizzaVolume = pizzaVolumeCalculator.reactTo(new CalculateVolume());
Right, the pizzaVolumeCalculator
runs the basic flow. Since S3
uses systemPublish
instead of system
,
it can publish the result back to the caller.
But what if you call the pizzaVolumeCalculator
with a negative radius?
PizzaVolumeCalculator pizzaVolumeCalculator = new PizzaVolumeCalculator();
pizzaVolumeCalculator.reactTo(new EnterRadius(-1));
In that case, the alternative flow Negative radius
is entered after step S1
, since the flow position matches. The first step of the Negative radius
flow specifies no message class, so the step runs automatically:
.flow("Negative radius").after("S1").condition(isNegativeRadius)
.step("S1a_1").system(throwIllegalRadiusException)
But what if your code calls reactTo()
in the wrong order?
PizzaVolumeCalculator pizzaVolumeCalculator = new PizzaVolumeCalculator();
pizzaVolumeCalculator.reactTo(new EnterHeight(5)); // this should come second, not first
pizzaVolumeCalculator.reactTo(new EnterRadius(4));
In that case, the messages are consumed silently, and the actor does nothing.
If you want to customize the behavior for messages that can't be handled at a certain time,
call actor.getModelRunner().handleUnhandledWith(...)
in the constructor of your actor.
IMPORTANT NOTE: Any flow can have an alternative flow, so there may be alternative flows to alternative flows. But at any given time, at most one flow runs. If there are several flows whose first step specifies the same message class, or that specify no message class, you must take extra care. In that case, the flow positions or conditions of these flows must be mutually exclusive. If more than one flow can run, an exception is thrown.
A flow can specify one of the following flow positions in its header:
after(x): Starts the flow after step x has been run. Note: You should also use after to handle exceptions that occurred in step x.
insteadOf(x): Starts the flow as an alternative to step x.
anytime(): Starts the flow after any step that has been run, or at the beginning.
To specify a condition in a flow's header, you need to implement the Condition
interface provided by requirements as code:
public interface Condition {
boolean evaluate();
}
Since it is a single method interface, you can use a method reference to provide an instance of it on the fly. For example:
private Condition isNegativeRadius;
...
public PizzaVolumeCalculator() {
this.isNegativeRadius = this::isNegativeRadius;
...
}
private boolean isNegativeRadius() {
return r < 0;
}
See the example actor class for details.
When an actor's behavior only uses the system()
method, it's restricted to just consuming messages.
But an actor can also publish events with systemPublish()
, as shown in this file:
class PublishingActor extends AbstractActor {
@Override
public Model behavior() {
Model model = Model.builder()
.user(EnterName.class).systemPublish(this::publishNameAsString)
.on(String.class).system(this::displayNameString)
.build();
return model;
}
private String publishNameAsString(EnterName enterName) {
return enterName.getUserName();
}
public void displayNameString(String nameString) {
System.out.println("Welcome, " + nameString + ".");
}
}
As you can see, publishNameAsString()
takes a command object as input parameter, and returns an event to be published. In this case, a String.
By default, the actor takes the returned event and publishes it to the same model, as shown above. But you can also publish events to a different actor. That receiving actor will react to the event.
The syntax is:
.user(/* command class */).systemPublish(/* event producing function*/).to(/* receiving actor */)
or
.on(/* event class */).systemPublish(/* event producing function*/).to(/* receiving actor */)
Here is an example of two actors. The MessageProducer
receives an EnterName
command and sends a NameEntered
event to the MessageConsumer
. The consumer receives the event, and prints the name.
class MessageProducer extends AbstractActor {
private AbstractActor messageConsumer;
public MessageProducer(AbstractActor messageConsumer) {
this.messageConsumer = messageConsumer;
}
@Override
public Model behavior() {
Model model = Model.builder()
.user(EnterName.class).systemPublish(this::nameEntered).to(messageConsumer)
.build();
return model;
}
private NameEntered nameEntered(EnterName enterName) {
return new NameEntered(enterName.getUserName());
}
}
class MessageConsumer extends AbstractActor {
@Override
public Model behavior() {
Model model = Model.builder()
.on(NameEntered.class).system(this::displayName)
.build();
return model;
}
public void displayName(NameEntered nameEntered) {
System.out.println("Welcome, " + nameEntered.getUserName() + ".");
}
}
To access the model runner inside of an actor, call super.getModelRunner()
.
Note that in any case, an actor returns the event that was published last to the caller of actor.reactTo()
.