🛠 Series: ERP RFQ ModulePart 6 of 8

"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