DEV Community

ko-chan
ko-chan

Posted on • Originally published at ko-chan.github.io

Designing SaaS Commerce with State Machines [Part 11]

This article was originally published on Saru Blog.


What You Will Learn

  • Why "string status" breaks down in SaaS commerce
  • Implementation patterns for embedding state machines into Go domain models
  • Design techniques for coordinating multiple state machines
  • Handling edge cases: partial payments, expiration, and optimistic locking

The Status Column from Hell

When building web applications, you will almost certainly encounter "status" columns. Order status, invoice status, user account status. What starts as a simple active / inactive boolean grows into pending, processing, completed, cancelled, refunded... as the service evolves.

In the SaaS I'm building (a multi-tenant subscription management system), there are separate statuses for Quotes, Orders, Invoices, and Payments.

It starts simple.

CREATE TABLE invoices (
    id UUID PRIMARY KEY,
    status VARCHAR(20) NOT NULL DEFAULT 'draft',
    ...
);
Enter fullscreen mode Exit fullscreen mode

On the application side:

// A common implementation
func (s *InvoiceService) MarkPaid(id uuid.UUID) error {
    invoice, _ := s.repo.Get(id)
    invoice.Status = "paid"  // ← direct string assignment
    return s.repo.Update(invoice)
}
Enter fullscreen mode Exit fullscreen mode

This works. But as the service grows, problems emerge.

Problem 1: Can't prevent invalid transitions

// Is draft → paid really an allowed transition?
// Should "paid" be possible without going through "sent" first?
invoice.Status = "paid"
Enter fullscreen mode Exit fullscreen mode

By business rules, invoices should transition draft → sent → paid. But with string assignment, any state can transition to any other state.

Problem 2: Typos aren't caught until runtime

invoice.Status = "piad"  // typo goes unnoticed
Enter fullscreen mode Exit fullscreen mode

It compiles. Without tests, you'll only discover this in production.

Problem 3: Transition rules scatter across the codebase

// handler.go
if invoice.Status == "sent" || invoice.Status == "overdue" {
    invoice.Status = "paid"
}

// service.go
if invoice.Status != "void" && invoice.Status != "paid" {
    // payment processing
}

// worker.go
if invoice.Status == "sent" && time.Now().After(invoice.DueDate) {
    invoice.Status = "overdue"
}
Enter fullscreen mode Exit fullscreen mode

The same transition rules are scattered across multiple locations, and missing an update in one place becomes a bug.

State Machines as the Solution

The state machine idea is simple: define which states can transition to which other states in one place, and forbid all other transitions.

In SaaS commerce (Quote-to-Cash), there are the following entities:

Cart                    Quote
  │                       │
  │ Convert()             │ Accept() → NewOrderFromQuote()
  └──────────┐   ┌───────┘
             ↓   ↓
        Order
             │
             │ Complete()
             ↓
     Subscription
             │
             │ billing cycle
             ↓
       Invoice
             │
             │ RecordPayment()
             ↓
       Payment
Enter fullscreen mode Exit fullscreen mode

Orders are created from two entry points: Cart and Quote. From there, the flow continues through Subscription → Invoice → Payment. Cart is the self-service path where customers select products; Quote is the path where sales representatives present terms. The entry points differ, but the resulting Order has the same structure.

This article focuses on the Quote → Order → Invoice → Payment flow. Each has its own state machine.

Quote

draft ──→ sent ──→ accepted
                ├→ rejected
                └→ expired
Enter fullscreen mode Exit fullscreen mode
  • draft: Just created. Line items can be added/removed
  • sent: Sent to customer. No modifications allowed
  • accepted: Customer accepted. Can be converted to an order
  • rejected / expired: Terminal states

Order

pending ──→ awaiting_payment ──→ confirmed ──→ processing ──→ completed
Enter fullscreen mode Exit fullscreen mode
State Transitions To
pending awaiting_payment, confirmed, cancelled
awaiting_payment confirmed, cancelled
confirmed processing, cancelled
processing completed, cancelled
completed / cancelled (terminal states)

cancelled is reachable from any state except completed. Orders should be cancellable until the very last moment — a business requirement.

Invoice

draft ──→ sent ──→ paid
    │         ├→ overdue ──→ paid
    │         │         └→ void
    └→ void   └→ void
Enter fullscreen mode Exit fullscreen mode
  • The key point: overdue can transition to paid. Late payments happen routinely

Payment

pending ──→ completed ──→ refunded
    │              └→ disputed ──→ completed (chargeback won)
    └→ failed                  └→ refunded (chargeback lost)
Enter fullscreen mode Exit fullscreen mode

Implementation Patterns in Go

Pattern 1: Embed Transition Rules in Enum Types

Centralize transition rule definitions in one place. In Go, use custom types and methods.

type QuoteStatus string

const (
    QuoteStatusDraft    QuoteStatus = "draft"
    QuoteStatusSent     QuoteStatus = "sent"
    QuoteStatusAccepted QuoteStatus = "accepted"
    QuoteStatusRejected QuoteStatus = "rejected"
    QuoteStatusExpired  QuoteStatus = "expired"
)

// CanTransitionTo defines all transition rules.
// This single method shows which transitions are allowed.
func (s QuoteStatus) CanTransitionTo(target QuoteStatus) bool {
    switch s {
    case QuoteStatusDraft:
        return target == QuoteStatusSent
    case QuoteStatusSent:
        return target == QuoteStatusAccepted ||
               target == QuoteStatusRejected ||
               target == QuoteStatusExpired
    case QuoteStatusAccepted, QuoteStatusRejected, QuoteStatusExpired:
        return false // terminal states cannot transition
    default:
        return false
    }
}
Enter fullscreen mode Exit fullscreen mode

The benefits of this design:

  • Transition rules live in one place. They don't scatter across handlers and service layers
  • default: return false automatically rejects unknown states
  • Easy to test. All transition patterns can be exhaustively verified

Tests verify both allowed and forbidden transitions:

func TestQuoteStatus_CanTransitionTo(t *testing.T) {
    tests := []struct {
        from     QuoteStatus
        to       QuoteStatus
        expected bool
    }{
        // Allowed transitions
        {QuoteStatusDraft, QuoteStatusSent, true},
        {QuoteStatusSent, QuoteStatusAccepted, true},
        {QuoteStatusSent, QuoteStatusRejected, true},
        {QuoteStatusSent, QuoteStatusExpired, true},

        // Forbidden transitions
        {QuoteStatusDraft, QuoteStatusAccepted, false},  // can't go directly from draft to accepted
        {QuoteStatusAccepted, QuoteStatusDraft, false},   // can't return from terminal state
        {QuoteStatusRejected, QuoteStatusSent, false},    // can't re-send after rejection
    }

    for _, tt := range tests {
        got := tt.from.CanTransitionTo(tt.to)
        if got != tt.expected {
            t.Errorf("%s → %s: got %v, want %v", tt.from, tt.to, got, tt.expected)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Table-driven tests that enumerate all transition patterns also serve as a specification document. When adding a new state, forgetting to add a test case becomes conspicuous.

For greater robustness, a test that covers all state combinations is effective:

func TestQuoteStatus_AllTransitions(t *testing.T) {
    allStatuses := []QuoteStatus{
        QuoteStatusDraft, QuoteStatusSent,
        QuoteStatusAccepted, QuoteStatusRejected, QuoteStatusExpired,
    }

    // Whitelist of allowed transitions
    allowed := map[QuoteStatus][]QuoteStatus{
        QuoteStatusDraft: {QuoteStatusSent},
        QuoteStatusSent:  {QuoteStatusAccepted, QuoteStatusRejected, QuoteStatusExpired},
    }

    for _, from := range allStatuses {
        for _, to := range allStatuses {
            expected := false
            for _, a := range allowed[from] {
                if a == to {
                    expected = true
                    break
                }
            }
            got := from.CanTransitionTo(to)
            if got != expected {
                t.Errorf("%s → %s: got %v, want %v", from, to, got, expected)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This test verifies all 5×5=25 combinations. While forgetting to add a new state to allStatuses would leave the test incomplete, it at least guarantees existing transition rules haven't been broken.

Pattern 2: Execute Transitions Through Domain Model Methods

Rather than calling CanTransitionTo() directly, give the domain model methods that represent transitions.

// Send transitions the quote to the sent state.
func (q *Quote) Send() error {
    if !q.Status.CanTransitionTo(QuoteStatusSent) {
        return ErrInvalidQuoteTransition
    }
    if len(q.Items) == 0 {
        return ErrQuoteEmpty  // guard: can't send an empty quote
    }
    q.Status = QuoteStatusSent
    q.UpdatedAt = time.Now()
    return nil
}

// Accept transitions the quote to the accepted state.
func (q *Quote) Accept() error {
    if !q.Status.CanTransitionTo(QuoteStatusAccepted) {
        return ErrInvalidQuoteTransition
    }
    if q.IsExpired() {
        return ErrQuoteExpired  // guard: can't accept an expired quote
    }
    q.Status = QuoteStatusAccepted
    q.UpdatedAt = time.Now()
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Each method follows a common structure:

  1. Check if the transition is allowed (CanTransitionTo)
  2. Check guard conditions (business-rule-specific additional conditions)
  3. Change state
  4. Update timestamp

This keeps the handler layer simple:

// handler
func (h *QuoteHandler) Send(c echo.Context) error {
    quote, err := h.service.Get(c.Request().Context(), quoteID)
    if err != nil {
        return err
    }

    if err := quote.Send(); err != nil {
        return err  // transition rule violations automatically become errors
    }

    return h.service.Update(c.Request().Context(), quote)
}
Enter fullscreen mode Exit fullscreen mode

The handler expresses only the intent to "send the quote" and doesn't need to know the details of transition rules.

Pattern 3: State-Dependent Operation Restrictions

Encapsulate "when changes are allowed" within the domain model as well.

// AddItem adds a line item to the quote.
func (q *Quote) AddItem(item *QuoteItem) error {
    if q.Status != QuoteStatusDraft {
        return ErrInvalidQuoteStatus  // can't modify items outside of draft
    }
    item.QuoteID = q.ID
    q.Items = append(q.Items, item)
    q.CalculateTotals()
    q.UpdatedAt = time.Now()
    return nil
}

// IsModifiable returns whether the quote can be modified.
func (q *Quote) IsModifiable() bool {
    return q.Status == QuoteStatusDraft
}
Enter fullscreen mode Exit fullscreen mode

The rule "you can only add line items to a draft quote" is naturally expressed within the domain model. API handlers and frontends don't need to check this independently.

Coordinating Multiple State Machines

So far, we've been discussing single entities. In SaaS commerce, multiple state machines coordinate to form a single business flow. This is the hardest part of the design.

Quote → Order Conversion

When a quote is accepted, it can be converted to an order. But "accepted" alone isn't sufficient.

// CanConvertToOrder determines whether a quote can be converted to an order.
// All conditions must be met.
func (q *Quote) CanConvertToOrder() bool {
    return q.Status == QuoteStatusAccepted &&
           !q.IsExpired() &&
           len(q.Items) > 0
}
Enter fullscreen mode Exit fullscreen mode

A combination of three conditions:

Condition Reason
Status == Accepted Can't convert unless accepted
!IsExpired() Can't convert even if accepted, if the validity period has expired
len(Items) > 0 A quote with no line items can't become an order

The conversion is implemented as a factory method:

// NewOrderFromQuote creates an order from an accepted quote.
func NewOrderFromQuote(quote *Quote) (*Order, error) {
    if !quote.CanConvertToOrder() {
        return nil, ErrInvalidQuoteStatus
    }

    order := NewOrder(CreateOrderParams{
        ProviderID: quote.ProviderID,
        CustomerID: quote.CustomerID,
        QuoteID:    &quote.ID,  // ← keep a reference to the quote
        Currency:   quote.Currency,
    })

    // Copy line items from quote to order
    for _, qi := range quote.Items {
        oi := NewOrderItem(CreateOrderItemParams{
            PlanID:      qi.PlanID,
            Description: qi.Description,
            Quantity:    qi.Quantity,
            UnitPrice:   qi.UnitPrice,
        })
        oi.OrderID = order.ID
        order.Items = append(order.Items, oi)
    }

    order.Subtotal = quote.Subtotal
    order.TotalAmount = quote.TotalAmount
    return order, nil
}
Enter fullscreen mode Exit fullscreen mode

Three key points:

  1. Centralized guard conditions: Aggregate checks in CanConvertToOrder(). Don't check individually in the service layer
  2. Preserve references: Track the original quote via QuoteID
  3. Copy data: Copy quote line items to the order, giving them independent lifecycles

Invoice and Payment Coordination

The coordination between invoices and payments is slightly more complex.

// RecordPayment records a payment against the invoice.
func (inv *Invoice) RecordPayment(amount decimal.Decimal) error {
    if inv.Status != InvoiceStatusSent && inv.Status != InvoiceStatusOverdue {
        return ErrInvoiceNotPayable  // can't pay draft or void invoices
    }

    newAmountPaid := inv.AmountPaid.Add(amount)
    if newAmountPaid.GreaterThan(inv.TotalAmount) {
        return ErrPaymentExceedsAmount  // prevent overpayment
    }

    inv.AmountPaid = newAmountPaid
    inv.AmountDue = inv.TotalAmount.Sub(newAmountPaid)
    inv.UpdatedAt = time.Now()

    // Automatically transition to paid when fully paid
    if inv.AmountDue.LessThanOrEqual(decimal.Zero) {
        return inv.MarkPaid()
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

This implementation contains two design decisions.

1. Allow partial payments

By separating AmountPaid and AmountDue, the invoice amount can be satisfied through multiple payments. This supports installment payments and partial receipts.

2. Automatic transitions

When AmountDue reaches zero, the state automatically transitions to paid without explicitly calling MarkPaid(). The payment completion logic is contained within the domain model.

The Overall Flow

Cart
  │ Convert()
  ↓
Quote
  │ Accept() → CanConvertToOrder()
  ↓
Order
  │ Confirm() → Complete()
  ↓
Subscription
  │ billing cycle arrives
  ↓
Invoice
  │ Send() → RecordPayment()
  ↓
Payment
  │ Complete() or Fail()
  ↓
[Flow ends, next billing cycle begins]
Enter fullscreen mode Exit fullscreen mode

Each entity maintains its own state machine while coordinating through conversion methods (NewOrderFromQuote) and guard conditions (CanConvertToOrder).

Edge Cases and Design Decisions

Time-Based Automatic Transitions

Quotes have validity periods. How should expiration be handled?

// IsExpired determines whether the validity period has passed.
func (q *Quote) IsExpired() bool {
    return time.Now().After(q.ValidUntil)
}

// Accept checks for expiration during acceptance.
func (q *Quote) Accept() error {
    if !q.Status.CanTransitionTo(QuoteStatusAccepted) {
        return ErrInvalidQuoteTransition
    }
    if q.IsExpired() {
        return ErrQuoteExpired
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

There's a design choice here:

Approach Pros Cons
Check on access (↑ implementation) No cron job needed, simple DB status and reality can diverge
Periodically transition to expired via cron DB consistency maintained Cron management required

The implementation above uses check-on-access. When a quote is accessed (when someone tries to accept it), the expiration is checked, and if expired, the request is rejected. The DB status remains sent, but IsExpired() determines the actual state.

Optimistic Locking

When multiple users simultaneously operate on the same quote or invoice, a version field detects conflicts.

type Quote struct {
    // ...
    Version int  // for optimistic locking
}
Enter fullscreen mode Exit fullscreen mode
UPDATE quotes
SET status = $1, version = version + 1, updated_at = NOW()
WHERE id = $2 AND version = $3;  -- if version doesn't match, update count = 0
Enter fullscreen mode Exit fullscreen mode

If the update count is 0, return ErrVersionConflict.

Designing Terminal States

Every state machine has terminal states. No transitions are possible from terminal states.

case QuoteStatusAccepted, QuoteStatusRejected, QuoteStatusExpired:
    return false // terminal states
Enter fullscreen mode Exit fullscreen mode

However, Payment's completed is not a terminal state. Transitions to refunded and disputed are possible. Because even "completed" payments can have follow-up processing in business terms.

case PaymentStatusCompleted:
    return target == PaymentStatusRefunded || target == PaymentStatusDisputed
Enter fullscreen mode Exit fullscreen mode

"Which states are terminal" is purely a business rules question, not a technical constraint. It's a design decision that requires discussion with domain experts.

When to Use This Pattern

Not every status column needs a state machine.

Situation State Machine Reason
2-state ON/OFF (active/inactive) Not needed A boolean is sufficient
3+ states with a defined transition order Needed There's value in preventing invalid transitions
Multiple entities coordinating Needed Guard condition management becomes complex
Transitions with side effects (notifications, billing) Needed Centralize side-effect execution conditions

Why not use DB CHECK constraints or triggers? PostgreSQL CHECK constraints can restrict allowed status values, and triggers can enforce transition rules. However, guard conditions ("can't send a quote with no line items", "can't accept an expired quote") depend on application context and can't be fully expressed on the DB side alone. It's practical to split responsibilities: CHECK constraints for status value restriction, and application-side for transition rules and guard conditions.

Why not use a library? Go has state machine libraries (looplab/fsm, etc.). However, the patterns above are sufficient in many cases. The CanTransitionTo() + domain method combination can be implemented without external dependencies, and integrates naturally with guard conditions and business logic. Libraries become useful when you need transition callbacks or state transition persistence (event sourcing).

Summary

Pattern Overview
Centralize transition rules in enum types Define allowed transitions in one place with CanTransitionTo()
Execute transitions via domain methods Encapsulate guard conditions in Quote.Send(), Invoice.MarkPaid()
State-dependent operation restrictions Use IsModifiable(), IsCancellable() for UI control as well
Centralized guard conditions Aggregate multi-condition checks in CanConvertToOrder()
Factory methods for conversion Type-safe entity conversion with NewOrderFromQuote()
Automatic transitions Automatically transition to paid on full payment within RecordPayment()

Managing "status as strings" is easy but breaks down as entities multiply. Embedding state machines into domain models prevents invalid transitions at the type level and avoids scattered transition rules.

Particularly in SaaS commerce where multiple state machines coordinate, a design where each entity is responsible for its own transition rules while safely coordinating through guard conditions and factory methods is effective.


Series Articles

Top comments (0)