複式簿記とソフトウェア設計

単式簿記で失われる情報と、堅牢なアプリケーション設計の共通点


Posted on 2026年 9月 8日 (火)
Tags accounting, software-architecture, event-sourcing, database, cowork-with-llm
accounting, software-architecture, event-sourcing, database, cowork-with-llm

複式簿記とソフトウェア設計

確定申告のために複式簿記で記帳していると、これは会計固有のルールというより、状態管理の設計パターンに近いと感じる。単式簿記を複式簿記へ変えたとき、増えるのは記帳の手間だけではない。記録される情報の量と構造が変わる。

この記事では、単式簿記では失われてしまう情報が複式簿記では何として残るのかを整理し、それがソフトウェア設計のどの考え方に対応するのかをまとめる。

まとめ

  • 単式簿記は残高を上書きする設計、複式簿記は取引を追記する設計だと考えられる
  • 単式簿記で失われるのは相手勘定、つまり「金額が動いた原因」と「その裏側で何が増減したか」という対の情報
  • 仕訳は immutable な record として扱う。誤りは書き換えではなく逆仕訳で打ち消す。これは event sourcing とほぼ同じ構造になっている
  • 残高は仕訳を畳み込んだ導出値であり、決算や月次の締めは snapshot に相当する
  • 借方合計と貸方合計が一致することは常に成り立つ不変条件で、二重記入という冗長性がエラー検出を支えている
  • 元入金や事業主貸借は、事業という閉じた世界と外界をつなぐ限られた出入口になっている。外部との入出力を境界に集約する設計と発想が近い
  • 会計をそのまま模倣するのではなく、「追記のみ」「保存則」「境界の明示」の3点を借りると、堅牢なアプリケーション設計に寄せられる

前提

  • 会計用語は日本の個人事業主の簿記を前提にする。法人であれば元入金は資本金、事業主貸借は役員貸付金や配当などに読み替える
  • 掲載するコードは Go 1.27 系で go test を通したものを使う
  • 税務や制度の扱いは改正で変わる場合がある。実務では公式情報や税理士に確認する
  • 想定読者は、簿記の細部よりも設計の考え方に興味がある開発者とする

単式簿記と複式簿記で残る情報の差

単式簿記は残高の履歴しか持たない

単式簿記は、現金や預金といった1つの側面だけを追う。家計簿がわかりやすい例で、次のように書く。

日付摘要入金出金残高
4/1元手を入れる300,000300,000
4/10備品を買う20,000280,000
4/30入金あり100,000380,000

これでも現金残高は正しく求まる。しかし、4/30 の入金が何なのかはわからない。今月の売上かもしれないし、先月の売掛金の回収かもしれないし、借入かもしれない。摘要欄の自由記述に頼るしかなく、機械的には区別できない。

複式簿記は取引の両側を書く

複式簿記では、1つの取引を必ず借方と貸方の2面で書く。同じ出来事は次のようになる。

日付借方金額貸方金額
4/1現金300,000元入金300,000
4/5売掛金100,000売上100,000
4/10消耗品費20,000現金20,000
4/30現金100,000売掛金100,000

4/30 の入金は「売掛金の回収」であり、売上ではないことが記録から読み取れる。売上そのものは 4/5 に発生していて、現金の動きとは別の日付を持つ。

単式簿記で失われる情報

差分を並べると、失われているのは次の情報になる。

  • 相手勘定: 現金が増えた原因は何か。売上か、回収か、借入か、元手か
  • 因果の対: 資産が増えるとき、同時に何が減った、あるいは何が増えたのか
  • 現金以外の残高: 売掛金、買掛金、借入金といった、まだ現金化していない権利や義務
  • 発生の時点: 売上が立った日と、入金された日の区別
  • 損益と財産の分離: 手元の現金が増えたのは儲かったからか、借りたからかの区別
  • 検算の材料: 二重に書いていないため、記入漏れを内部矛盾として検出できない

まとめると、単式簿記は「残高」というスカラー値の履歴しか持たないのに対して、複式簿記は「どの勘定からどの勘定へ、いつ、いくら動いたか」という構造を持つ。ここからソフトウェアの話に接続できる。

flowchart LR
  subgraph S[単式簿記]
    s1[取引] --> s2[現金残高を更新]
  end
  subgraph D[複式簿記]
    d1[取引] --> d2[仕訳を追記]
    d2 --> d3[借方: 何が増えたか]
    d2 --> d4[貸方: 何が減ったか]
    d3 --> d5[残高は畳み込みで導出]
    d4 --> d5
  end

特徴1: 仕訳は immutable な record である

訂正は書き換えではなく逆仕訳で行う

簿記では、記帳済みの仕訳を消したり書き換えたりしない。誤りが見つかったときは、元の仕訳を打ち消す逆仕訳、いわゆる赤伝を追加してから、正しい仕訳を入れ直す。帳簿には「間違えた」「取り消した」「入れ直した」の3件が残る。

これは、税務調査や監査で履歴の改竄を防ぐための実務上の要請でもある。しかし、設計として見ても筋が通っている。

  • 過去の残高を後から再現できる
  • 誰がいつ何を訂正したかが追える
  • 訂正そのものが分析対象になる。どの科目で誤りが多いかが見える

event sourcing との対応

このやり方は、ソフトウェアでいう append-only log や event sourcing とほぼ同じ構造になっている。

簿記ソフトウェア
仕訳イベント、コミット、ログレコード
仕訳帳イベントストア、追記専用ログ
逆仕訳(赤伝)補償イベント、revert コミット
総勘定元帳、残高projection、read model
月次や年次の締めsnapshot、チェックポイント
試算表整合性チェック、reconciliation

同じ発想は身近な実装にも多い。Git は commit を書き換えず、取り消しには git revert で打ち消しコミットを積める。データベースの WAL は変更を追記してから状態に反映する。Kafka のログも追記のみで、消費側が自分の位置を持つ。

上書き更新で失われるもの

行を UPDATE で書き換える設計は、単式簿記と同じ問題を抱える。

  • なぜその値になったのか、原因が残らない
  • 障害調査のときに、途中経過を再現できない
  • 集計の定義を変えたときに、過去に遡って計算し直せない
  • 同時更新が衝突したとき、どちらの意図が失われたのかを判断できない

「あとから聞かれそうな問いに答えられるか」を基準にすると、追記のほうが安全な場面は多い。特に、金額、在庫、権限、契約状態のように、後から説明責任が発生する領域では効いてくる。

特徴2: 残高は導出値であり、締めは snapshot である

複式簿記では、残高は帳簿に書いてある「事実」ではなく、仕訳の集合から計算される導出値になる。

1
残高(勘定, 時点T) = Σ 仕訳明細.金額 (勘定が一致し、日付 <= T)

つまり、残高は仕訳列の畳み込み(fold)で表せる。ここから2つの性質が出てくる。

  1. 任意の時点の残高を再現できる。過去の帳簿を「その日の状態」として復元できる
  2. 集計軸をあとから増やせる。部門別、プロジェクト別、税区分別といった見方を、元帳を作り直さずに追加できる

一方で、毎回すべての仕訳を畳み込むのは効率が悪い。そこで簿記では、期末に決算して繰越残高を確定させ、翌期は開始残高から積み上げる。これは、event sourcing で長いイベント列を毎回再生しないために snapshot を取るのと同じ発想で、次のように整理できる。

  • snapshot は導出値なので、壊れても元のログから作り直せる
  • ただし、締めた期間のイベントを後から追加すると snapshot と食い違う。簿記でも、締めた期に遡る修正は特別扱いになる
  • そのため「どこまで確定したか」を明示する境界が必要になる

特徴3: 貸借一致という不変条件

常に成り立つ等式

複式簿記には、常に成り立つ不変条件がある。

1
2
3
すべての仕訳について: 借方合計 = 貸方合計
帳簿全体について: 資産 = 負債 + 純資産
損益まで含めると: 資産 + 費用 = 負債 + 純資産 + 収益

借方を正、貸方を負として符号付きで持てば、これは「全明細の合計が常に 0」と表現できる。試算表で借方合計と貸方合計を突き合わせるのは、この不変条件が破れていないかを確認する作業になる。

ここで重要なのは、二重記入が冗長性を生んでいる点だ。同じ取引を2箇所に書くので、片方を書き忘れると合計が合わなくなる。誤りが「静かに間違った値」ではなく「検出可能な矛盾」として現れる。パリティビットやチェックサムと同じ考え方といえる。

Go で最小の台帳を書く

不変条件は、コメントに書くのではなく、型と関数で守るほうが確実になる。仕訳を作る唯一の経路で検証すれば、不正な仕訳はそもそも存在できない。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package ledger

import (
	"errors"
	"fmt"
	"slices"
	"time"
)

// Amount は最小通貨単位(日本円なら1円)の整数で金額を表す。
type Amount int64

// Account は勘定科目。
type Account string

// Entry は仕訳明細の1行。借方を正、貸方を負として持つ。
type Entry struct {
	Account Account
	Amount  Amount
}

func Debit(a Account, v Amount) Entry  { return Entry{Account: a, Amount: v} }
func Credit(a Account, v Amount) Entry { return Entry{Account: a, Amount: -v} }

// Journal は1つの取引を表す仕訳。フィールドを公開せず、作成後は変更しない。
type Journal struct {
	id      string
	date    time.Time
	desc    string
	entries []Entry
}

var ErrUnbalanced = errors.New("借方と貸方が一致しない")

// NewJournal は貸借一致を検証してから仕訳を作る。
func NewJournal(id string, date time.Time, desc string, entries ...Entry) (Journal, error) {
	// 呼び出し側のスライスと backing array を共有しないようコピーする
	j := Journal{id: id, date: date, desc: desc, entries: slices.Clone(entries)}
	if err := j.validate(); err != nil {
		return Journal{}, err
	}
	return j, nil
}

func (j Journal) ID() string   { return j.id }
func (j Journal) Desc() string { return j.desc }

func (j Journal) validate() error {
	if len(j.entries) < 2 {
		return fmt.Errorf("仕訳には2行以上が必要: %s", j.id)
	}
	var sum Amount
	for _, e := range j.entries {
		if e.Amount == 0 {
			return fmt.Errorf("金額が0の明細がある: %s", j.id)
		}
		sum += e.Amount
	}
	if sum != 0 {
		return fmt.Errorf("%w: %s (差額 %d)", ErrUnbalanced, j.id, sum)
	}
	return nil
}

// Reverse は訂正用の逆仕訳を作る。元の仕訳には手を触れない。
func Reverse(j Journal, id string, date time.Time) Journal {
	rev := make([]Entry, len(j.entries))
	for i, e := range j.entries {
		rev[i] = Entry{Account: e.Account, Amount: -e.Amount}
	}
	return Journal{id: id, date: date, desc: "取消: " + j.desc, entries: rev}
}

// Balances は仕訳の列を畳み込んで勘定科目ごとの残高を作る。
func Balances(js []Journal) map[Account]Amount {
	b := make(map[Account]Amount)
	for _, j := range js {
		for _, e := range j.entries {
			b[e.Account] += e.Amount
		}
	}
	return b
}

Go には immutable を宣言する構文がない。const が使えるのは基本型の定数だけなので、フィールドを公開せず、コンストラクタを唯一の入口にして近似する。可変長引数で受け取ったスライスを slices.Clone でコピーしている点も重要で、これを省くと呼び出し側と backing array を共有するため、検証を通した後から金額を書き換えられる。外部パッケージへ渡す場合も、内部のスライスをそのまま返す getter は作らない。

テストでは、個々の計算結果に加えて、不変条件そのものを検証しておくとよい。全勘定の残高合計が 0 になることは、どんな仕訳列に対しても成り立つ性質になる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func TestBalancesAndTrialBalance(t *testing.T) {
	js := []Journal{}
	add := func(id, desc string, d int, es ...Entry) {
		t.Helper()
		j, err := NewJournal(id, date(d), desc, es...)
		if err != nil {
			t.Fatal(err)
		}
		js = append(js, j)
	}
	add("j1", "元入れ", 1, Debit(cash, 300000), Credit(capital, 300000))
	add("j2", "掛け売上", 5, Debit(ar, 100000), Credit(sales, 100000))
	add("j3", "売掛金の回収", 30, Debit(cash, 100000), Credit(ar, 100000))
	add("j4", "備品購入", 10, Debit(supply, 20000), Credit(cash, 20000))

	b := Balances(js)
	if got := b[cash]; got != 380000 {
		t.Errorf("cash = %d, want 380000", got)
	}

	var total Amount
	for _, v := range b {
		total += v
	}
	if total != 0 {
		t.Errorf("trial balance = %d, want 0", total)
	}
}

実行結果は次のようになる。

1
2
$ go test ./...
ok  	ledger	0.005s

データベース側で追記のみに寄せる

永続化層でも、上書きを構造的に禁止しておくと安心できる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
CREATE TABLE journal_line (
  journal_id UUID        NOT NULL,
  line_no    INT         NOT NULL,
  account    TEXT        NOT NULL,
  -- 借方は正、貸方は負。金額は最小通貨単位の整数で持つ
  amount     BIGINT      NOT NULL CHECK (amount <> 0),
  occurred_at TIMESTAMPTZ NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (journal_id, line_no)
);

-- 追記のみに制限する。訂正は逆仕訳の INSERT で行う
REVOKE UPDATE, DELETE ON journal_line FROM app_user;

注意点として、「1仕訳の中で合計が 0」という条件は、行単位の CHECK 制約では書けない。実現するには、次のような選択肢がある。

  1. 書き込み経路をアプリケーション側の1関数に絞り、そこで検証してからトランザクションで挿入する
  2. 仕訳単位のトリガや、遅延評価される制約(DEFERRABLE な制約)で検査する
  3. 仕訳ヘッダに合計額を持たせ、明細の合計と突き合わせるバッチを定期実行する

どれを選んでも、「壊れた状態を作れない経路」と「壊れていないことを後から確認する経路」の両方を持たせるのが安全になる。

特徴4: 元入金は外界との境界を管理する仕組み

事業と個人を分ける

個人事業主の簿記では、事業と個人の財布を分けて考える。事業を始めるときに個人の資金を投入すると、元入金として記録する。法人でいう資本金にあたる。

1
(開業時)現金 300,000 / 元入金 300,000

事業を続けている間に、個人の口座から事業の支払いをしたり、逆に事業の現金を生活費に回したりすることもある。この場合は事業主借、事業主貸という勘定を使う。

1
2
(個人の財布から事業の備品を買った)消耗品費 5,000 / 事業主借 5,000
(事業の現金を生活費に使った)事業主貸 50,000 / 現金 50,000

重要なのは、事業という閉じた世界の外と資金がやりとりされるとき、必ず専用の勘定を通ることだ。外界との出入りは、どこか適当な場所で起きるのではなく、決められた口を通る。この前提があるおかげで、次の性質が保証される。

  • 事業内部の取引だけでは、純資産の総量は変わらない。現金で備品を買っても、資産の内訳が入れ替わるだけになる
  • 純資産が動くのは、損益が出たときか、外界との資本取引があったときに限られる

これは物理でいう保存則に近い。系の内部で何が起きても総量は保存され、境界を越える出入りだけが総量を変える。

flowchart LR
  outside([外界: 個人の財布・出資者・金融機関])
  subgraph biz[事業という閉じた系]
    capital[元入金・事業主貸借]
    assets[資産・負債]
    pl[収益・費用]
    capital --> assets
    assets <--> pl
  end
  outside -- 資金の投入 --> capital
  capital -- 引き出し --> outside

ソフトウェア設計での対応

同じ構造は、堅牢なアプリケーション設計にそのまま現れる。

  • 境界を1箇所に集める: 外部 API、決済、ファイル、時刻、乱数といった外界に触れる処理を、境界の層に閉じ込める。ports and adapters や、functional core / imperative shell と呼ばれる考え方に近い
  • 内部は閉じた不変条件を守る: 境界の内側は純粋な計算にして、不変条件をテストで検証できる状態にする
  • 出入口を数える: 外界と接続する経路が限られていれば、監査やモックへの差し替えが容易になる。逆に、どこからでも外部呼び出しが起きるコードは、状態の由来を追えない
  • 入ってくるデータは境界で検証する: 元入金の記帳が事業の外から来た資金に名前を与える作業だとすれば、入力バリデーションは外部データに型を与える作業になる

金額を扱うシステムでは、この対応は比喩以上の意味を持つ。たとえば決済代行サービスと連携するとき、外部から入ってくる入金は必ず「外部システム」勘定を経由させると、次の点が扱いやすくなる。

  • 自社の帳簿と外部の明細を突き合わせる作業(reconciliation)が、勘定残高の比較になる
  • webhook の重複配信は、同じ取引 ID の仕訳が既にあるかどうかで冪等に処理できる
  • 未達の入金は、突き合わせで残高が残る形で表面化する

特徴5: 勘定科目は型であり集計軸である

勘定科目は、資産、負債、純資産、収益、費用の5つに分類される。この分類が決まると、借方と貸方のどちらが増加を意味するかも決まる。

分類増加する側ソフトウェアでの見方
資産借方現金、売掛金、備品保有しているリソース
負債貸方買掛金、借入金将来引き渡す義務
純資産貸方元入金、資本金外界から持ち込まれた元手
収益貸方売上、雑収入期間中に増えた価値のフロー
費用借方消耗品費、支払手数料期間中に消えた価値のフロー

科目名を自由記述ではなく決まった集合から選ぶ点は、文字列ではなく列挙型や値オブジェクトで表すことに対応する。実装でも Accountstring の別名にとどめず、登録済みの科目だけを許す構造にしておくと、集計漏れや表記ゆれを防げる。

さらに、資産や負債は「ある時点の状態(stock)」、収益や費用は「期間の変化(flow)」を表す。この2つを混ぜないという規律は、状態と差分を分けて設計する感覚に近い。貸借対照表は状態のスナップショット、損益計算書は期間の集計であり、両者は当期純利益で接続する。

特徴6: 発生主義は未確定の状態を明示する

現金主義では、現金が動いたときだけ記録する。発生主義では、取引が発生した時点で記録し、入出金は別の事象として扱う。この差が売掛金や買掛金という勘定を生む。

1
2
(4/5 役務を提供した)売掛金 100,000 / 売上   100,000
(4/30 入金された)    現金   100,000 / 売掛金 100,000

売掛金は「まだ受け取っていないが、受け取る権利がある」状態を表す。ソフトウェア設計では、次のような状態に対応する。

  • 決済の与信確保だけを済ませ、確定は後で行う二段階の処理
  • 分散トランザクションにおける未確定の状態や、補償処理を前提にした saga
  • 予約済みだが未消費の在庫

ここでの教訓は、「中間状態に名前を付けて残高として持つ」ことにある。中間状態を暗黙にすると、処理が途中で落ちたときに、どこまで進んだのかがわからなくなる。売掛金という残高があれば、回収されていない取引は残高として見える。滞留している中間状態は、集計するだけで検出できる。

対応表としてのまとめ

複式簿記の性質ソフトウェア設計での対応何が守られるか
仕訳を追記し書き換えないappend-only log、event sourcing履歴の追跡、再現性
誤りは逆仕訳で打ち消す補償イベント、revert監査可能性
残高は仕訳の畳み込みprojection、read model集計軸の追加、再計算
決算や月次の締めsnapshot、チェックポイント性能と確定範囲の明示
借方合計 = 貸方合計不変条件、型と検証、テスト壊れた状態を作らせない
二重記入の冗長性チェックサム、突き合わせ誤りの検出
相手勘定を必ず書く原因と結果を対で記録する変更理由の追跡
元入金と事業主貸借外部 I/O の境界集約、入力検証外界の影響範囲の限定
勘定科目の5分類型、値オブジェクト、集計軸表記ゆれと集計漏れの防止
発生主義の売掛金や買掛金中間状態の明示、saga、予約未完了処理の可視化

注意点

  • 会計をそのまま実装しない: 業務システムに複式簿記を導入すると、開発と運用のコストは確実に増える。借りるべきは「追記のみ」「保存則」「境界の明示」といった考え方であり、勘定科目体系まで持ち込むかは要件次第になる
  • 金額に浮動小数点数を使わない: 金額は最小通貨単位の整数か、10進の固定小数点型で持つ。float64 の丸め誤差は貸借一致を静かに壊す
  • 多通貨は別の話になる: 通貨をまたぐと、通貨ごとに貸借一致を保ちつつ、換算差額を別勘定で受ける設計が必要になる。単純に合計すると一致しない
  • immutable と削除要件は衝突する場合がある: 個人情報を含むログでは、削除請求への対応が必要になることがある。金額そのものと個人情報を分離し、参照側だけ削除できる構造にしておくと逃げ道を作りやすい。法令の適用は事案によって異なるため、公式情報や専門家に確認する
  • 追記のみはストレージを消費する: 保持期間、アーカイブ、snapshot の運用方針を最初に決めておく。無限に貯め続ける前提には無理がある
  • 不変条件は検証してこそ意味がある: 制約を書くだけでなく、定期的な突き合わせジョブや、property based test に近い形の検証を用意する
  • 税務上の判断は範囲外: この記事で扱うのは設計の話であり、記帳方法の妥当性や控除の可否には触れていない。制度は改正されるため、実務では最新の公式情報を確認する

参考

Share


See also