"A state machine is a contract: these transitions are allowed, all others throw. That contract belongs in the domain layer."
ERP systems live and die by their status workflows. Without a state machine, status transitions are scattered across handlers as if-else chains. When you add a new status, you search every handler for the checks to update. A domain state machine centralises all rules in one place.
Part of the Green Concrete ERP project. View full project page →
The Quotation Workflow
The Green Concrete RFQ has five states: Draft (salesperson working), PendingApproval (submitted, awaiting manager), Approved (manager approved, PDF generated), Rejected (manager rejected with reason), Cancelled (by salesperson or admin before approval).
Valid transitions: Draft → PendingApproval (submit), PendingApproval → Approved, PendingApproval → Rejected, Draft → Cancelled, PendingApproval → Cancelled.
Invalid: Approved → anything (cannot un-approve), Rejected → anything (create a new RFQ instead), Draft → Approved (must go through manager).
QuotationStateMachine — Complete Implementation
// GreenConcrete.Domain/StateMachines/QuotationStateMachine.cs
public static class QuotationStateMachine
{
public static void Transition(Quotation quotation, QuotationStatus to)
{
bool valid = (quotation.Status, to) switch
{
(QuotationStatus.Draft, QuotationStatus.PendingApproval) => true,
(QuotationStatus.PendingApproval, QuotationStatus.Approved) => true,
(QuotationStatus.PendingApproval, QuotationStatus.Rejected) => true,
(QuotationStatus.Draft, QuotationStatus.Cancelled) => true,
(QuotationStatus.PendingApproval, QuotationStatus.Cancelled) => true,
_ => false
};
if (!valid)
throw new InvalidOperationException(
"Cannot transition from " + quotation.Status + " to " + to);
quotation.Status = to;
quotation.UpdatedAt = DateTime.UtcNow;
}
}The handler calls the state machine once — all validation is done inside:
// ApproveQuotationHandler.cs QuotationStateMachine.Transition(quotation, QuotationStatus.Approved); // throws InvalidOperationException if the transition is illegal await _repo.SaveAsync(quotation, ct); await _pdfService.GenerateAndSaveAsync(quotation, ct); // auto PDF
Extending the Pattern to Other ERP Modules
The same pattern applies to Purchase Orders (Draft → Submitted → Approved → Received), HR Leave Requests (Pending → Approved/Rejected), and Inventory Transfers (Initiated → InTransit → Received/Cancelled). Each domain entity gets its own static state machine class. The pattern is identical — only the states and allowed transitions differ.
🎯 Quick Check
Q1: Why does the state machine live in the Domain layer rather than the Application layer?
Show Answer
Because valid status transitions are a business rule, not an application concern. The domain layer defines what the business allows — independent of how it is called (HTTP, gRPC, background job, test). Application handlers use the state machine but don't define the rules. If the rules were in a handler, adding a new handler could accidentally bypass them.
Q2: Why does InvalidOperationException get thrown instead of returning a result object?
Show Answer
Because an illegal transition is a programming error, not a user input error. A valid user action (reject an approved quotation) should never reach this code path — the UI prevents it and the API checks authorization. If this exception fires, it means a handler called Transition with an invalid combination, which is a bug. Exceptions are appropriate for bugs. User validation errors should return Result objects instead.
Q3: How do you prevent a salesperson from calling the Approve API endpoint directly?
Show Answer
[Authorize(Roles = "Manager,Admin")] on the ApproveQuotation API endpoint. The JWT carries the role claim. The API validates the JWT signature and role before the handler runs. Even if a salesperson obtains a valid JWT, it will have Role = Salesperson and the API will return 403 Forbidden.
Key Takeaways
- State machines centralise status transition rules — add a new status in one place instead of searching every handler
- The state machine lives in Domain — business rules belong to the innermost layer
- InvalidOperationException on illegal transitions catches programming errors early, before they corrupt data
- The same pattern extends to Purchase Orders, HR, Inventory — any entity with a status workflow
- API role authorization enforces the same workflow server-side, regardless of what the UI shows