This is Part 7 of a series on using State Machines to express complex logic.

In our last post, we looked at how to use events to express logic we want whenever the state changes.

In this post we will look at situations where the state changes are permissible only when some conditions are met.

Assume this code is being used by some sort of call center, and we want the following:

  1. All calls duration must be recorded
  2. Being on hold does not stop timing.
  3. A call can only be placed on hold after 10 seconds, to avoid situations where a caller is immediately placed on hold.

To implement this logic, we update our Call class as follows:

public sealed class Call
{
    // State machine
    private readonly StateMachine<Status, Trigger> _stateMachine;

    // Store our start time for tracking
    private DateTime _startTime;

    // Constant for minimum threshold
    private const int MinimumTimeBeforeHoldInSeconds = 10;
    public Status CurrentStatus => _stateMachine.State;

    public Call(TimeProvider timeProvider) : this(Status.Ready, timeProvider)
    {
    }

    private Call(Status status, TimeProvider timeProvider)
    {
        var provider = timeProvider;
        _stateMachine = new StateMachine<Status, Trigger>(status);

        //
        // Configure state machine
        //
        _stateMachine.Configure(Status.Ready)
            .Permit(Trigger.Dial, Status.Ringing);

        _stateMachine.Configure(Status.Ringing)
            .Permit(Trigger.PickUp, Status.Connected)
            .Permit(Trigger.HangUp, Status.Ready)
            .OnEntry(() => Log.Information("Ringing..."));

        // Only allow transition of the time since start time is greater than or
        // equal to the threshold (10 seconds). This is by capturing the current time
        // at the point of requested state change and comparing with the start time

        _stateMachine.Configure(Status.Connected)
            .PermitIf(Trigger.Hold, Status.OnHold,
                () => (timeProvider.GetUtcNow().DateTime - _startTime).TotalSeconds >= MinimumTimeBeforeHoldInSeconds)
            .Permit(Trigger.HangUp, Status.Ready)
            .OnEntry(() =>
            {
                Log.Information("Connected...");
                // Set the start time
                _startTime = provider.GetUtcNow().DateTime;
            });


        _stateMachine.Configure(Status.OnHold)
            .PermitIf(Trigger.UnHold, Status.Connected)
            .PermitIf(Trigger.HangUp, Status.Ready)
            .OnEntry(() => Log.Information("Placing On Hold..."));

        _stateMachine.Configure(Status.Ready)
            .OnEntry(() => Log.Information("Hanging Up..."));
    }

    public void Dial()
    {
        _stateMachine.Fire(Trigger.Dial);
    }

    public void HangUp()
    {
        _stateMachine.Fire(Trigger.HangUp);
    }

    public void PickUp()
    {
        _stateMachine.Fire(Trigger.PickUp);
    }

    public void Hold()
    {
        _stateMachine.Fire(Trigger.Hold);
    }

    public void UnHold()
    {
        _stateMachine.Fire(Trigger.UnHold);
    }
}

The logic here is once the Call is connected, we capture the start time.

Then when a user tries to place the Call OnHold, we compare that time to the start time.

Some tests to verify this logic:

[Theory]
[InlineData(10.0)]
[InlineData(10.1)]
[InlineData(40)]
public void Call_Is_Placed_OnHold_After_Ten_Seconds(decimal seconds)
{
    var provider = new FakeTimeProvider();
    provider.SetUtcNow(DateTime.UtcNow);
    var call = new Call(provider);
    call.Dial();
    _output.Output.Should().EndWith("Ringing...\n");
    call.PickUp();
    _output.Output.Should().EndWith("Connected...\n");
    // Advance the time by 10 seconds
    provider.Advance(TimeSpan.FromSeconds((double)seconds));
    var ex = Record.Exception(() => call.Hold());
    ex.Should().BeNull();
    // Check status is on hold
    call.CurrentStatus.Should().Be(Status.OnHold);
}

[Theory]
[InlineData(9.0)]
[InlineData(9.9)]
[InlineData(0)]
[InlineData(1)]
public void Call_Placing_OnHold_Fails_Before_Threshold_Seconds(decimal seconds)
{
    var provider = new FakeTimeProvider();
    provider.SetUtcNow(DateTime.UtcNow);
    var call = new Call(provider);
    call.Dial();
    _output.Output.Should().EndWith("Ringing...\n");
    call.PickUp();
    _output.Output.Should().EndWith("Connected...\n");
    // Advance the time by 9 seconds
    provider.Advance(TimeSpan.FromSeconds((double)seconds));
    var ex = Record.Exception(() => call.Hold());
    ex.Should().BeOfType<InvalidOperationException>();
}

These tests should pass:

PermitIfTests

TLDR

Transitions between states can be controlled by conditions using the PermitIf() method during configuration.

The code is in my GitHub.

Happy hacking!