20 minlesson

Implementing Status Transitions

Implementing Status Transitions

Now that we understand the state machine concept, let's build the actual implementation. We'll define the ParcelStatus enum, create a transition validation dictionary, and build a ParcelStatusService that enforces the rules.

The ParcelStatus Enum

Start with the enum that represents all possible parcel states:

csharp
1public enum ParcelStatus
2{
3 LabelCreated,
4 PickedUp,
5 InTransit,
6 OutForDelivery,
7 Delivered,
8 Exception,
9 Returned
10}

Each value represents a distinct phase in the parcel's lifecycle. The order in the enum does not imply transition order -- that's what the state machine dictionary handles.

Defining Terminal States

Terminal states are states from which no further transitions are possible. We define them as a static set for easy lookup:

csharp
1public static class ParcelStatusRules
2{
3 public static readonly HashSet<ParcelStatus> TerminalStates =
4 [
5 ParcelStatus.Delivered,
6 ParcelStatus.Returned
7 ];
8
9 public static bool IsTerminal(ParcelStatus status) =>
10 TerminalStates.Contains(status);
11}

This gives us a single place to check whether a parcel has reached the end of its journey.

The Transition Dictionary

The core of the state machine is a dictionary mapping each status to its set of valid next statuses:

csharp
1public static class ParcelStatusRules
2{
3 private static readonly Dictionary<ParcelStatus, HashSet<ParcelStatus>>
4 AllowedTransitions = new()
5 {
6 [ParcelStatus.LabelCreated] = [ParcelStatus.PickedUp, ParcelStatus.Exception],
7 [ParcelStatus.PickedUp] = [ParcelStatus.InTransit, ParcelStatus.Exception],
8 [ParcelStatus.InTransit] = [ParcelStatus.OutForDelivery, ParcelStatus.Exception],
9 [ParcelStatus.OutForDelivery] = [ParcelStatus.Delivered, ParcelStatus.Exception],
10 [ParcelStatus.Exception] = [ParcelStatus.Returned],
11 [ParcelStatus.Delivered] = [],
12 [ParcelStatus.Returned] = [],
13 };
14
15 public static readonly HashSet<ParcelStatus> TerminalStates =
16 [
17 ParcelStatus.Delivered,
18 ParcelStatus.Returned
19 ];
20
21 public static bool IsTerminal(ParcelStatus status) =>
22 TerminalStates.Contains(status);
23
24 public static bool CanTransition(ParcelStatus from, ParcelStatus to) =>
25 AllowedTransitions.TryGetValue(from, out var allowed) && allowed.Contains(to);
26
27 public static IReadOnlySet<ParcelStatus> GetAllowedTransitions(ParcelStatus from) =>
28 AllowedTransitions.TryGetValue(from, out var allowed)
29 ? allowed
30 : new HashSet<ParcelStatus>();
31}

Key design decisions:

  • Dictionary with HashSet values: O(1) lookup for both the current state and the target state.
  • Empty sets for terminal states: Rather than omitting them, we include them explicitly. This makes it obvious that terminal states have no transitions.
  • Static class: Transition rules are fixed at compile time. No need for instance state.

Validating a Transition

The CanTransition method performs the core validation:

csharp
1ParcelStatusRules.CanTransition(ParcelStatus.LabelCreated, ParcelStatus.PickedUp);
2// true -- valid happy path transition
3
4ParcelStatusRules.CanTransition(ParcelStatus.LabelCreated, ParcelStatus.Delivered);
5// false -- can't skip steps
6
7ParcelStatusRules.CanTransition(ParcelStatus.Delivered, ParcelStatus.InTransit);
8// false -- terminal state, no transitions allowed

This is a pure function with no side effects -- easy to test, easy to reason about.

Building the ParcelStatusService

The service layer wraps the static rules with actual parcel data access. It validates transitions and applies the state change:

csharp
1public class ParcelStatusService
2{
3 private readonly ParcelRepository _repository;
4
5 public ParcelStatusService(ParcelRepository repository)
6 {
7 _repository = repository;
8 }
9
10 public async Task<StatusTransitionResult> TransitionStatusAsync(
11 Guid parcelId, ParcelStatus newStatus)
12 {
13 var parcel = await _repository.GetByIdAsync(parcelId);
14
15 if (parcel is null)
16 {
17 return StatusTransitionResult.NotFound(parcelId);
18 }
19
20 if (ParcelStatusRules.IsTerminal(parcel.Status))
21 {
22 return StatusTransitionResult.TerminalState(parcel.Status);
23 }
24
25 if (!ParcelStatusRules.CanTransition(parcel.Status, newStatus))
26 {
27 return StatusTransitionResult.InvalidTransition(
28 parcel.Status,
29 newStatus,
30 ParcelStatusRules.GetAllowedTransitions(parcel.Status));
31 }
32
33 parcel.Status = newStatus;
34 parcel.UpdatedAt = DateTime.UtcNow;
35 await _repository.UpdateAsync(parcel);
36
37 return StatusTransitionResult.Success(parcel);
38 }
39}

The service follows a clear validation pipeline:

  1. Find the parcel (404 if not found)
  2. Check for terminal state (422 if terminal)
  3. Validate the transition (422 if invalid)
  4. Apply the change and persist

The Result Object

Instead of throwing exceptions for business rule violations, we use a result object. This keeps control flow explicit:

csharp
1public class StatusTransitionResult
2{
3 public bool IsSuccess { get; private init; }
4 public string? ErrorType { get; private init; }
5 public string? ErrorMessage { get; private init; }
6 public ParcelStatus? CurrentStatus { get; private init; }
7 public ParcelStatus? RequestedStatus { get; private init; }
8 public IReadOnlySet<ParcelStatus>? AllowedStatuses { get; private init; }
9 public Parcel? Parcel { get; private init; }
10
11 public static StatusTransitionResult Success(Parcel parcel) => new()
12 {
13 IsSuccess = true,
14 Parcel = parcel
15 };
16
17 public static StatusTransitionResult NotFound(Guid parcelId) => new()
18 {
19 IsSuccess = false,
20 ErrorType = "not_found",
21 ErrorMessage = $"Parcel with ID {parcelId} was not found"
22 };
23
24 public static StatusTransitionResult TerminalState(ParcelStatus current) => new()
25 {
26 IsSuccess = false,
27 ErrorType = "terminal_state",
28 ErrorMessage = $"Parcel is in terminal state '{current}' and cannot be modified",
29 CurrentStatus = current
30 };
31
32 public static StatusTransitionResult InvalidTransition(
33 ParcelStatus current,
34 ParcelStatus requested,
35 IReadOnlySet<ParcelStatus> allowed) => new()
36 {
37 IsSuccess = false,
38 ErrorType = "invalid_transition",
39 ErrorMessage = $"Cannot transition from '{current}' to '{requested}'",
40 CurrentStatus = current,
41 RequestedStatus = requested,
42 AllowedStatuses = allowed
43 };
44}

The result object carries enough context for the controller to build a meaningful error response, including the current status, the requested status, and the list of valid transitions.

Registering the Service

Register the service in Program.cs with a scoped lifetime, since it depends on a scoped repository:

csharp
1builder.Services.AddScoped<ParcelStatusService>();

The service is straightforward enough that it doesn't need an interface. If you later need to swap implementations (for example, to add event sourcing), you can extract one at that point.

Returning 422 Unprocessable Entity

ASP.NET Core supports 422 responses through UnprocessableEntity():

csharp
1return UnprocessableEntity(new
2{
3 error = "invalid_transition",
4 message = "Cannot transition from 'LabelCreated' to 'Delivered'",
5 currentStatus = "LabelCreated",
6 requestedStatus = "Delivered",
7 allowedStatuses = new[] { "PickedUp", "Exception" }
8});

The 422 status code is the right choice here because:

  • 400 Bad Request means the request is malformed (wrong JSON, missing fields)
  • 409 Conflict means a resource state conflict (like concurrent updates)
  • 422 Unprocessable Entity means the request is well-formed but violates business rules

The response body tells the client exactly what went wrong and what they can do instead.

Mapping Results to HTTP Responses

A helper method in the controller keeps the mapping clean:

csharp
1private IActionResult ToActionResult(StatusTransitionResult result)
2{
3 if (result.IsSuccess)
4 {
5 return Ok(result.Parcel);
6 }
7
8 return result.ErrorType switch
9 {
10 "not_found" => NotFound(new { error = result.ErrorMessage }),
11
12 "terminal_state" => UnprocessableEntity(new
13 {
14 error = result.ErrorType,
15 message = result.ErrorMessage,
16 currentStatus = result.CurrentStatus?.ToString()
17 }),
18
19 "invalid_transition" => UnprocessableEntity(new
20 {
21 error = result.ErrorType,
22 message = result.ErrorMessage,
23 currentStatus = result.CurrentStatus?.ToString(),
24 requestedStatus = result.RequestedStatus?.ToString(),
25 allowedStatuses = result.AllowedStatuses?.Select(s => s.ToString())
26 }),
27
28 _ => StatusCode(500)
29 };
30}

This pattern-match approach ensures each error type gets the right HTTP status code and response body.

Unit Testing the Transition Rules

Since the rules are in a static class, testing is straightforward:

csharp
1[Fact]
2public void LabelCreated_CanTransition_To_PickedUp()
3{
4 Assert.True(ParcelStatusRules.CanTransition(
5 ParcelStatus.LabelCreated, ParcelStatus.PickedUp));
6}
7
8[Fact]
9public void LabelCreated_Cannot_Skip_To_Delivered()
10{
11 Assert.False(ParcelStatusRules.CanTransition(
12 ParcelStatus.LabelCreated, ParcelStatus.Delivered));
13}
14
15[Fact]
16public void Delivered_Is_Terminal()
17{
18 Assert.True(ParcelStatusRules.IsTerminal(ParcelStatus.Delivered));
19}
20
21[Fact]
22public void Delivered_Cannot_Transition_To_Anything()
23{
24 var allowed = ParcelStatusRules.GetAllowedTransitions(ParcelStatus.Delivered);
25 Assert.Empty(allowed);
26}
27
28[Theory]
29[InlineData(ParcelStatus.LabelCreated)]
30[InlineData(ParcelStatus.PickedUp)]
31[InlineData(ParcelStatus.InTransit)]
32[InlineData(ParcelStatus.OutForDelivery)]
33public void Active_States_Can_Transition_To_Exception(ParcelStatus from)
34{
35 Assert.True(ParcelStatusRules.CanTransition(from, ParcelStatus.Exception));
36}

The Theory test with InlineData verifies the "exception from any active state" rule across all active statuses in a single test method.

Testing the Service

The service tests use a mock repository to isolate the business logic:

csharp
1[Fact]
2public async Task TransitionStatusAsync_ValidTransition_UpdatesParcel()
3{
4 var parcel = new Parcel { Id = Guid.NewGuid(), Status = ParcelStatus.LabelCreated };
5 var mockRepo = new Mock<ParcelRepository>();
6 mockRepo.Setup(r => r.GetByIdAsync(parcel.Id)).ReturnsAsync(parcel);
7
8 var service = new ParcelStatusService(mockRepo.Object);
9 var result = await service.TransitionStatusAsync(parcel.Id, ParcelStatus.PickedUp);
10
11 Assert.True(result.IsSuccess);
12 Assert.Equal(ParcelStatus.PickedUp, result.Parcel!.Status);
13 mockRepo.Verify(r => r.UpdateAsync(parcel), Times.Once);
14}
15
16[Fact]
17public async Task TransitionStatusAsync_TerminalState_ReturnsError()
18{
19 var parcel = new Parcel { Id = Guid.NewGuid(), Status = ParcelStatus.Delivered };
20 var mockRepo = new Mock<ParcelRepository>();
21 mockRepo.Setup(r => r.GetByIdAsync(parcel.Id)).ReturnsAsync(parcel);
22
23 var service = new ParcelStatusService(mockRepo.Object);
24 var result = await service.TransitionStatusAsync(parcel.Id, ParcelStatus.InTransit);
25
26 Assert.False(result.IsSuccess);
27 Assert.Equal("terminal_state", result.ErrorType);
28}

Complete Transition Diagram

For reference, here is every valid transition in the system:

1LabelCreated ───> PickedUp ───> InTransit ───> OutForDelivery ───> Delivered
2 │ │ │ │
3 └──> Exception <─┘──────<──────┘────────<───────┘
4
5 └───> Returned

Both Delivered and Returned are dead ends. Once a parcel reaches either state, no further status changes are permitted.

Key Takeaways

  • Use a Dictionary<ParcelStatus, HashSet<ParcelStatus>> for O(1) transition validation
  • Include terminal states with empty sets for explicitness
  • The ParcelStatusService validates transitions and persists the change
  • Return result objects instead of throwing exceptions for business rule violations
  • 422 Unprocessable Entity is the correct status code for invalid transitions
  • Include the current status, requested status, and allowed transitions in error responses
  • Test static rules directly and service logic with mocked dependencies
Implementing Status Transitions - Anko Academy