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:
csharp1public enum ParcelStatus2{3 LabelCreated,4 PickedUp,5 InTransit,6 OutForDelivery,7 Delivered,8 Exception,9 Returned10}
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:
csharp1public static class ParcelStatusRules2{3 public static readonly HashSet<ParcelStatus> TerminalStates =4 [5 ParcelStatus.Delivered,6 ParcelStatus.Returned7 ];89 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:
csharp1public static class ParcelStatusRules2{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 };1415 public static readonly HashSet<ParcelStatus> TerminalStates =16 [17 ParcelStatus.Delivered,18 ParcelStatus.Returned19 ];2021 public static bool IsTerminal(ParcelStatus status) =>22 TerminalStates.Contains(status);2324 public static bool CanTransition(ParcelStatus from, ParcelStatus to) =>25 AllowedTransitions.TryGetValue(from, out var allowed) && allowed.Contains(to);2627 public static IReadOnlySet<ParcelStatus> GetAllowedTransitions(ParcelStatus from) =>28 AllowedTransitions.TryGetValue(from, out var allowed)29 ? allowed30 : 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:
csharp1ParcelStatusRules.CanTransition(ParcelStatus.LabelCreated, ParcelStatus.PickedUp);2// true -- valid happy path transition34ParcelStatusRules.CanTransition(ParcelStatus.LabelCreated, ParcelStatus.Delivered);5// false -- can't skip steps67ParcelStatusRules.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:
csharp1public class ParcelStatusService2{3 private readonly ParcelRepository _repository;45 public ParcelStatusService(ParcelRepository repository)6 {7 _repository = repository;8 }910 public async Task<StatusTransitionResult> TransitionStatusAsync(11 Guid parcelId, ParcelStatus newStatus)12 {13 var parcel = await _repository.GetByIdAsync(parcelId);1415 if (parcel is null)16 {17 return StatusTransitionResult.NotFound(parcelId);18 }1920 if (ParcelStatusRules.IsTerminal(parcel.Status))21 {22 return StatusTransitionResult.TerminalState(parcel.Status);23 }2425 if (!ParcelStatusRules.CanTransition(parcel.Status, newStatus))26 {27 return StatusTransitionResult.InvalidTransition(28 parcel.Status,29 newStatus,30 ParcelStatusRules.GetAllowedTransitions(parcel.Status));31 }3233 parcel.Status = newStatus;34 parcel.UpdatedAt = DateTime.UtcNow;35 await _repository.UpdateAsync(parcel);3637 return StatusTransitionResult.Success(parcel);38 }39}
The service follows a clear validation pipeline:
- Find the parcel (404 if not found)
- Check for terminal state (422 if terminal)
- Validate the transition (422 if invalid)
- 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:
csharp1public class StatusTransitionResult2{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; }1011 public static StatusTransitionResult Success(Parcel parcel) => new()12 {13 IsSuccess = true,14 Parcel = parcel15 };1617 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 };2324 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 = current30 };3132 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 = allowed43 };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:
csharp1builder.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():
csharp1return UnprocessableEntity(new2{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:
csharp1private IActionResult ToActionResult(StatusTransitionResult result)2{3 if (result.IsSuccess)4 {5 return Ok(result.Parcel);6 }78 return result.ErrorType switch9 {10 "not_found" => NotFound(new { error = result.ErrorMessage }),1112 "terminal_state" => UnprocessableEntity(new13 {14 error = result.ErrorType,15 message = result.ErrorMessage,16 currentStatus = result.CurrentStatus?.ToString()17 }),1819 "invalid_transition" => UnprocessableEntity(new20 {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 }),2728 _ => 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:
csharp1[Fact]2public void LabelCreated_CanTransition_To_PickedUp()3{4 Assert.True(ParcelStatusRules.CanTransition(5 ParcelStatus.LabelCreated, ParcelStatus.PickedUp));6}78[Fact]9public void LabelCreated_Cannot_Skip_To_Delivered()10{11 Assert.False(ParcelStatusRules.CanTransition(12 ParcelStatus.LabelCreated, ParcelStatus.Delivered));13}1415[Fact]16public void Delivered_Is_Terminal()17{18 Assert.True(ParcelStatusRules.IsTerminal(ParcelStatus.Delivered));19}2021[Fact]22public void Delivered_Cannot_Transition_To_Anything()23{24 var allowed = ParcelStatusRules.GetAllowedTransitions(ParcelStatus.Delivered);25 Assert.Empty(allowed);26}2728[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:
csharp1[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);78 var service = new ParcelStatusService(mockRepo.Object);9 var result = await service.TransitionStatusAsync(parcel.Id, ParcelStatus.PickedUp);1011 Assert.True(result.IsSuccess);12 Assert.Equal(ParcelStatus.PickedUp, result.Parcel!.Status);13 mockRepo.Verify(r => r.UpdateAsync(parcel), Times.Once);14}1516[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);2223 var service = new ParcelStatusService(mockRepo.Object);24 var result = await service.TransitionStatusAsync(parcel.Id, ParcelStatus.InTransit);2526 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 ───> Delivered2 │ │ │ │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
ParcelStatusServicevalidates 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