SaaSの商取引をステートマシンで設計する [第11回]
この記事で得られること
- SaaSの商取引で「文字列ステータス」が破綻する理由
- Goでステートマシンをドメインモデルに組み込む実装パターン
- 複数のステートマシンが連携するときの設計手法
- 部分支払い・期限切れ・楽観的ロックなどのエッジケース対処
statusカラムの地獄
Webアプリケーションを作っていると、ほぼ確実に「ステータス」カラムに出会う。注文の状態、請求書の状態、ユーザーアカウントの状態。最初は active / inactive の2値で済んでいたものが、サービスの成長とともに pending, processing, completed, cancelled, refunded... と増殖していく。
筆者が開発しているSaaS(マルチテナント型サブスクリプション管理システム)では、見積(Quote)、注文(Order)、請求書(Invoice)、決済(Payment)のそれぞれにステータスがある。
最初はシンプルだ。
CREATE TABLE invoices (
id UUID PRIMARY KEY,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
...
);
アプリケーション側ではこうなる。
// よくある実装
func (s *InvoiceService) MarkPaid(id uuid.UUID) error {
invoice, _ := s.repo.Get(id)
invoice.Status = "paid" // ← 文字列を直接代入
return s.repo.Update(invoice)
}
これは動く。しかし、サービスが成長するにつれて問題が出る。
問題1: 不正な遷移を防げない
// draft → paid は本当に許される遷移か?
// 送付(sent)を経由せずに「支払い済み」にしていいのか?
invoice.Status = "paid"
ビジネスルール上、請求書は draft → sent → paid の順に遷移すべきだ。しかし文字列代入では、どこからでもどの状態にでも遷移できてしまう。
問題2: タイポが実行時まで発見できない
invoice.Status = "piad" // typoに気づかない
コンパイルは通る。テストを書いていなければ、本番で初めて発覚する。
問題3: 遷移ルールがコード全体に散らばる
// handler.go
if invoice.Status == "sent" || invoice.Status == "overdue" {
invoice.Status = "paid"
}
// service.go
if invoice.Status != "void" && invoice.Status != "paid" {
// 支払い処理
}
// worker.go
if invoice.Status == "sent" && time.Now().After(invoice.DueDate) {
invoice.Status = "overdue"
}
同じ遷移ルールが複数箇所に分散し、1箇所の修正漏れがバグになる。
ステートマシンという解決策
ステートマシンのアイデアはシンプルだ。「どの状態からどの状態に遷移できるか」を1箇所に定義し、それ以外の遷移を禁止する。
SaaSの商取引(Quote-to-Cash)には、以下のエンティティがある。
Cart(買い物かご) Quote(見積)
│ │
│ Convert() │ Accept() → NewOrderFromQuote()
└───────┐ ┌──────┘
↓ ↓
Order(注文)
│
│ Complete()
↓
Subscription(サブスクリプション)
│
│ billing cycle
↓
Invoice(請求書)
│
│ RecordPayment()
↓
Payment(決済)
CartとQuoteの2つの入口からOrderが作られ、そこからSubscription → Invoice → Paymentへと流れる。Cart(買い物かご)は顧客がセルフサービスで商品を選ぶ経路、Quote(見積)は営業担当が条件を提示する経路だ。入口は違うが、合流先のOrderは同じ構造を持つ。
本記事ではQuote → Order → Invoice → Paymentの流れに焦点を当てる。それぞれが独自のステートマシンを持つ。
Quote(見積)
draft ──→ sent ──→ accepted
├→ rejected
└→ expired
-
draft: 作成直後。明細の追加・削除が可能 -
sent: 顧客に送付済み。変更不可 -
accepted: 顧客が承諾。注文に変換可能 -
rejected/expired: 終了状態
Order(注文)
pending ──→ awaiting_payment ──→ confirmed ──→ processing ──→ completed
| 状態 | 遷移先 |
|---|---|
pending |
awaiting_payment, confirmed, cancelled
|
awaiting_payment |
confirmed, cancelled
|
confirmed |
processing, cancelled
|
processing |
completed, cancelled
|
completed / cancelled
|
(終了状態) |
cancelled は completed 以外のどの状態からも遷移できる。注文は最後の瞬間までキャンセル可能にしておくのがビジネス上の要件だ。
Invoice(請求書)
draft ──→ sent ──→ paid
│ ├→ overdue ──→ paid
│ │ └→ void
└→ void └→ void
-
overdue(滞納)からpaidへの遷移が可能なのがポイント。遅延支払いは日常的に発生する
Payment(決済)
pending ──→ completed ──→ refunded
│ └→ disputed ──→ completed(チャージバック勝利)
└→ failed └→ refunded(チャージバック敗北)
Goでの実装パターン
パターン1: enum型に遷移ルールを持たせる
遷移ルールの定義場所を1箇所に集約する。Goではカスタム型とメソッドを使う。
type QuoteStatus string
const (
QuoteStatusDraft QuoteStatus = "draft"
QuoteStatusSent QuoteStatus = "sent"
QuoteStatusAccepted QuoteStatus = "accepted"
QuoteStatusRejected QuoteStatus = "rejected"
QuoteStatusExpired QuoteStatus = "expired"
)
// CanTransitionTo が遷移ルールのすべてを定義する。
// このメソッドだけを見れば、どの遷移が許可されているかがわかる。
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 // 終了状態からは遷移できない
default:
return false
}
}
この設計の利点:
- 遷移ルールが1箇所にある。ハンドラーやサービス層に散らばらない
-
default: return falseで未知の状態は自動的に拒否される - テストが書きやすい。すべての遷移パターンを網羅的にテストできる
テストは、許可された遷移と禁止された遷移の両方を検証する。
func TestQuoteStatus_CanTransitionTo(t *testing.T) {
tests := []struct {
from QuoteStatus
to QuoteStatus
expected bool
}{
// 許可された遷移
{QuoteStatusDraft, QuoteStatusSent, true},
{QuoteStatusSent, QuoteStatusAccepted, true},
{QuoteStatusSent, QuoteStatusRejected, true},
{QuoteStatusSent, QuoteStatusExpired, true},
// 禁止された遷移
{QuoteStatusDraft, QuoteStatusAccepted, false}, // draftから直接acceptedにはできない
{QuoteStatusAccepted, QuoteStatusDraft, false}, // 終了状態からは戻れない
{QuoteStatusRejected, QuoteStatusSent, false}, // 拒否後に再送付はできない
}
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)
}
}
}
テーブル駆動テストで全遷移パターンを列挙すると、遷移ルールの仕様書としても機能する。新しい状態を追加したときに、テストを追加し忘れるとコメントとして目立つ。
より堅牢にするなら、全状態の組み合わせを網羅するテストも有効だ。
func TestQuoteStatus_AllTransitions(t *testing.T) {
allStatuses := []QuoteStatus{
QuoteStatusDraft, QuoteStatusSent,
QuoteStatusAccepted, QuoteStatusRejected, QuoteStatusExpired,
}
// 許可された遷移のホワイトリスト
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)
}
}
}
}
このテストは5×5=25通りの全組み合わせを検証する。新しい状態を追加したときに allStatuses への追加を忘れるとテストが不完全になるが、少なくとも既存の遷移ルールが壊れていないことは保証できる。
パターン2: ドメインモデルのメソッドで遷移を実行する
CanTransitionTo() を直接呼ぶのではなく、遷移を表すメソッドをドメインモデルに持たせる。
// Send は見積を送付状態に遷移させる。
func (q *Quote) Send() error {
if !q.Status.CanTransitionTo(QuoteStatusSent) {
return ErrInvalidQuoteTransition
}
if len(q.Items) == 0 {
return ErrQuoteEmpty // ガード条件: 明細がないと送れない
}
q.Status = QuoteStatusSent
q.UpdatedAt = time.Now()
return nil
}
// Accept は見積を承諾状態に遷移させる。
func (q *Quote) Accept() error {
if !q.Status.CanTransitionTo(QuoteStatusAccepted) {
return ErrInvalidQuoteTransition
}
if q.IsExpired() {
return ErrQuoteExpired // ガード条件: 期限切れは承諾できない
}
q.Status = QuoteStatusAccepted
q.UpdatedAt = time.Now()
return nil
}
各メソッドの構造は共通している:
-
遷移の可否を確認(
CanTransitionTo) - ガード条件を確認(ビジネスルール固有の追加条件)
- 状態を変更
- タイムスタンプを更新
これにより、ハンドラー層はシンプルになる。
// 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 // 遷移ルール違反は自動的にエラーになる
}
return h.service.Update(c.Request().Context(), quote)
}
ハンドラーは「見積を送付する」という意図だけを表現し、遷移ルールの詳細を知る必要がない。
パターン3: 状態に応じた操作制限
「いつ変更できるか」もドメインモデルに閉じ込める。
// AddItem は見積に明細を追加する。
func (q *Quote) AddItem(item *QuoteItem) error {
if q.Status != QuoteStatusDraft {
return ErrInvalidQuoteStatus // draft以外では明細を変更できない
}
item.QuoteID = q.ID
q.Items = append(q.Items, item)
q.CalculateTotals()
q.UpdatedAt = time.Now()
return nil
}
// IsModifiable は変更可能な状態かどうかを返す。
func (q *Quote) IsModifiable() bool {
return q.Status == QuoteStatusDraft
}
「draftの見積にしか明細を追加できない」というルールが、ドメインモデルの中に自然に表現される。APIハンドラーやフロントエンドが個別にチェックする必要がない。
複数ステートマシンの連携
ここまでは単一エンティティの話だった。SaaSの商取引では、複数のステートマシンが連携して1つのビジネスフローを形成する。これが設計上最も難しいところだ。
Quote → Order への変換
見積が承諾されたら、注文に変換できる。しかし「承諾済み」だけでは足りない。
// CanConvertToOrder は、見積を注文に変換できるかを判定する。
// 複数の条件をすべて満たす必要がある。
func (q *Quote) CanConvertToOrder() bool {
return q.Status == QuoteStatusAccepted &&
!q.IsExpired() &&
len(q.Items) > 0
}
3つの条件の組み合わせ:
| 条件 | 理由 |
|---|---|
Status == Accepted |
承諾されていなければ変換できない |
!IsExpired() |
承諾後でも有効期限が切れていれば変換できない |
len(Items) > 0 |
明細がない見積は注文にならない |
変換処理はファクトリメソッドとして実装する。
// NewOrderFromQuote は承諾済み見積から注文を作成する。
func NewOrderFromQuote(quote *Quote) (*Order, error) {
if !quote.CanConvertToOrder() {
return nil, ErrInvalidQuoteStatus
}
order := NewOrder(CreateOrderParams{
ProviderID: quote.ProviderID,
CustomerID: quote.CustomerID,
QuoteID: "e.ID, // ← 見積への参照を保持
Currency: quote.Currency,
})
// 見積の明細を注文の明細にコピー
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
}
ポイントは3つ:
-
ガード条件の一元化:
CanConvertToOrder()に判定を集約。サービス層で個別にチェックしない -
参照の保持:
QuoteIDで元の見積を追跡可能にする - データのコピー: 見積の明細を注文にコピーし、独立したライフサイクルを持たせる
Invoice と Payment の連携
請求書と決済の連携は、もう少し複雑だ。
// RecordPayment は請求書に対する支払いを記録する。
func (inv *Invoice) RecordPayment(amount decimal.Decimal) error {
if inv.Status != InvoiceStatusSent && inv.Status != InvoiceStatusOverdue {
return ErrInvoiceNotPayable // draftやvoidには支払いできない
}
newAmountPaid := inv.AmountPaid.Add(amount)
if newAmountPaid.GreaterThan(inv.TotalAmount) {
return ErrPaymentExceedsAmount // 過払い防止
}
inv.AmountPaid = newAmountPaid
inv.AmountDue = inv.TotalAmount.Sub(newAmountPaid)
inv.UpdatedAt = time.Now()
// 全額支払い完了なら自動的にpaidへ遷移
if inv.AmountDue.LessThanOrEqual(decimal.Zero) {
return inv.MarkPaid()
}
return nil
}
この実装には2つの設計判断がある。
1. 部分支払いを許容する
AmountPaid と AmountDue を分離し、複数回の支払いで請求額を満たせるようにしている。これにより、分割払いや部分入金に対応できる。
2. 自動遷移
AmountDue がゼロになったら、明示的に MarkPaid() を呼ばなくても自動的に paid に遷移する。支払い完了の判定がドメインモデル内に閉じている。
全体のフロー
Cart(買い物かご)
│ Convert()
↓
Quote(見積)
│ Accept() → CanConvertToOrder()
↓
Order(注文)
│ Confirm() → Complete()
↓
Subscription(サブスクリプション)
│ billing cycle到来
↓
Invoice(請求書)
│ Send() → RecordPayment()
↓
Payment(決済)
│ Complete() or Fail()
↓
[フロー終了、次の請求サイクルへ]
各エンティティが独自のステートマシンを持ちつつ、変換メソッド(NewOrderFromQuote)やガード条件(CanConvertToOrder)を通じて連携する。
エッジケースと設計判断
時間による自動遷移
見積には有効期限がある。期限切れの判定をどうするか。
// IsExpired は有効期限が過ぎているかを判定する。
func (q *Quote) IsExpired() bool {
return time.Now().After(q.ValidUntil)
}
// Accept は承諾時に期限切れチェックを行う。
func (q *Quote) Accept() error {
if !q.Status.CanTransitionTo(QuoteStatusAccepted) {
return ErrInvalidQuoteTransition
}
if q.IsExpired() {
return ErrQuoteExpired
}
// ...
}
ここには設計上の選択肢がある。
| 方式 | メリット | デメリット |
|---|---|---|
| アクセス時に判定(↑の実装) | Cronジョブ不要、シンプル | DB上のstatusと実態がずれる |
| Cronで定期的にexpiredへ遷移 | DBの整合性が保たれる | Cronの管理が必要 |
上記の実装ではアクセス時判定を採用している。見積にアクセスしたとき(承諾しようとしたとき)に期限を確認し、切れていれば拒否する。DB上のstatusは sent のままだが、IsExpired() で実態を判定できる。
楽観的ロック
複数のユーザーが同時に同じ見積や請求書を操作する場合、version フィールドで衝突を検出する。
type Quote struct {
// ...
Version int // 楽観的ロック用
}
UPDATE quotes
SET status = $1, version = version + 1, updated_at = NOW()
WHERE id = $2 AND version = $3; -- version不一致なら更新件数=0
更新件数が0の場合は ErrVersionConflict を返す。
終了状態の設計
すべてのステートマシンに終了状態(terminal state)がある。終了状態からは遷移できない。
case QuoteStatusAccepted, QuoteStatusRejected, QuoteStatusExpired:
return false // 終了状態
しかし、Paymentの completed は終了状態ではない。返金(refunded)やチャージバック(disputed)への遷移がある。ビジネス上「完了」した決済にも後続処理があり得るためだ。
case PaymentStatusCompleted:
return target == PaymentStatusRefunded || target == PaymentStatusDisputed
「どの状態を終了状態にするか」は純粋にビジネスルールの問題であり、技術的な制約ではない。ドメインエキスパートとの議論が必要な設計判断だ。
このパターンをいつ使うか
すべてのstatusカラムにステートマシンが必要なわけではない。
| 状況 | ステートマシン | 理由 |
|---|---|---|
| 2状態のON/OFF(active/inactive) | 不要 | boolで十分 |
| 3状態以上で遷移順序がある | 必要 | 不正な遷移を防ぐ価値がある |
| 複数エンティティが連携する | 必要 | ガード条件の管理が複雑になる |
| 遷移に副作用がある(通知、課金) | 必要 | 副作用の実行条件を一元管理する |
DBのCHECK制約やトリガーではダメなのか? PostgreSQLのCHECK制約で許可されたステータス値を制限したり、トリガーで遷移ルールを強制することはできる。しかし、ガード条件(「明細が空の見積は送付できない」「期限切れは承諾できない」)はアプリケーションのコンテキストに依存するため、DB側だけでは表現しきれない。ステータス値の制限はCHECK制約で、遷移ルールとガード条件はアプリケーション側で、と役割を分けるのが現実的だ。
ライブラリは使わないのか? Goにはステートマシンライブラリ(looplab/fsm など)がある。しかし、上記のパターンで十分なケースが多い。CanTransitionTo() + ドメインメソッドの組み合わせは、外部依存なしで実装でき、ガード条件やビジネスロジックとの統合も自然だ。ライブラリが有効なのは、遷移時のコールバックや状態遷移の永続化(イベントソーシング)が必要な場合だ。
まとめ
| パターン | 概要 |
|---|---|
| enum型に遷移ルールを集約 |
CanTransitionTo() で許可された遷移を1箇所に定義 |
| ドメインメソッドで遷移実行 |
Quote.Send(), Invoice.MarkPaid() にガード条件を内包 |
| 状態に応じた操作制限 |
IsModifiable(), IsCancellable() でUI制御にも活用 |
| ガード条件の一元化 |
CanConvertToOrder() で複数条件の判定を集約 |
| ファクトリメソッドでの変換 |
NewOrderFromQuote() でエンティティ間の変換を型安全に |
| 自動遷移 |
RecordPayment() 内で全額支払い時に自動的に paid へ |
「statusを文字列で管理する」実装は手軽だが、エンティティが増えると破綻する。ステートマシンをドメインモデルに組み込むことで、不正な遷移を型レベルで制限し、遷移ルールの散在を防げる。
特に、複数のステートマシンが連携するSaaSの商取引では、各エンティティが自分の遷移ルールに責任を持ちつつ、ガード条件とファクトリメソッドで安全に連携する設計が有効だ。
シリーズ記事
- 第1回: 保守不可能な複雑さに自動化で立ち向かう
- 第2回: CI環境でのWebAuthnテスト自動化
- 第3回: Next.js × Go モノレポアーキテクチャ
- 第4回: PostgreSQL RLSによるマルチテナント分離
- 第5回: マルチポータル認証の落とし穴
- 第6回: Claude Codeで20万行のSaaSを1人で開発している話
- 第7回: セルフホストCI/CDで踏んだ地雷と解決策
- 第8回: Claude Code Agent Teamで「1人チーム開発」を実現する
- 第9回: pnpm + Next.js Standalone Dockerの落とし穴
- 第10回: GitHub Copilot × Claude Code × GitHub Actionsでコードレビューを自動化する
- 第11回: SaaSの商取引をステートマシンで設計する(この記事)
Discussion