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',
...
);
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)
}
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"
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
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"
}
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
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
-
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
| 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
- The key point:
overduecan transition topaid. Late payments happen routinely
Payment
pending ──→ completed ──→ refunded
│ └→ disputed ──→ completed (chargeback won)
└→ failed └→ refunded (chargeback lost)
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
}
}
The benefits of this design:
- Transition rules live in one place. They don't scatter across handlers and service layers
-
default: return falseautomatically 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)
}
}
}
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)
}
}
}
}
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
}
Each method follows a common structure:
-
Check if the transition is allowed (
CanTransitionTo) - Check guard conditions (business-rule-specific additional conditions)
- Change state
- 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)
}
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
}
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
}
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: "e.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
}
Three key points:
-
Centralized guard conditions: Aggregate checks in
CanConvertToOrder(). Don't check individually in the service layer -
Preserve references: Track the original quote via
QuoteID - 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
}
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]
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
}
// ...
}
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
}
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
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
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
"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
- Part 1: Fighting Unmaintainable Complexity with Automation
- Part 2: Automating WebAuthn E2E Tests in CI
- Part 3: Next.js × Go Monorepo Architecture
- Part 4: Multi-Tenant Isolation with PostgreSQL RLS
- Part 5: Multi-Portal Authentication Pitfalls
- Part 6: Building a 200K-Line SaaS Solo with Claude Code
- Part 7: Pitfalls and Solutions for Self-Hosted CI/CD
- Part 8: Achieving "Solo Team Development" with Claude Code Agent Team
- Part 9: Pitfalls of pnpm + Next.js Standalone Docker
- Part 10: Automating Code Reviews with GitHub Copilot × Claude Code × GitHub Actions
- Part 11: Designing SaaS Commerce with State Machines (this article)
Top comments (0)