会計システムのアーキテクチャとモデリング ~会計というドメインを Rust で表現している話~

はじめに

こんにちは。 バックエンドエンジニアの松本です。今回は、会計システムの開発を通じて、 CADDi におけるプロダクト開発の様子を紹介します。

2024年3月現在、CADDiでは2つのサービスを提供しています。1つは図面データ活用クラウド「CADDi Drawer」で、もう1つは加工品製造サービス「CADDi Manufacturing」です。

今回、後者の加工品製造サービス「CADDi Manufacturing」向けに、 会計システムを構築しました。これは、生産管理システムや拠点管理システムから取得した各種情報を基にして、会計仕訳データを生成し、経理部門に公開する役割を持ちます。

会計システムのアーキテクチャとその狙い

「CADDi Manufacturing」は、以下の特徴があり、会計システムとしての難しさはここにあります。

  • 多品種小ロットの取引のため、1つ1つの取引ごとの数量が少なく取引数が多い
  • 多くの顧客、多くのサプライパートナーと取引を行うため、サプライチェーンが複雑

計算処理を少しずつ進める

システムは生産管理システムや拠点管理システムがデプロイされているKubernetesクラスタ上にCronJobとしてデプロイされています。

CronJobの処理が始まると、対象月の入出荷などのイベントを上流システムのBigQueryから抽出します。そのイベントを会計データに変換し、アプリケーションのCloudSQLに永続化します。最後に、その月の会計データとして経理部門が参照するBigQueryに転送します。

flowchart LR
    上流システムのBigQuery -- イベント --> CronJob
    CronJob -- 会計データ --> CloudSQL
    CloudSQL -- 会計データ --> 経理部門のBigQuery

会計システムは月に一度、「締め」を行い計算結果を確定し、BigQueryのデータをバランスシートなどを生成するシステムに登録します。月末になり、全てのイベントが上流システムで登録されないと、その月の会計データは確定しません。しかし、後続の会計プロセスが存在するために、「締め」は翌月上旬の数日間のうちに実施する必要があります。

実際にはユーザの入力不備やシステムの不具合が発生することも考えられますから、かなりタイトなスケジュールで原因を特定し修正する必要があります。そこで、もっと早期にこれらの問題を発見できないかと考え、CronJobを毎日実行するようにして、対象月の初日から実行した日の前日までの会計計算を行う仕組みとしました。

gantt
    dateFormat MM-DD
    axisFormat %d
    tickInterval 1month
    section 3月2日の処理
        3月1日まで計算 :2014-03-01, 1d
    section 3月3日の処理
        3月2日まで計算 :2014-03-01, 2d
    section 3月4日の処理
        3月3日まで計算 :2014-03-01, 3d
    section 3月5日の処理
        3月4日まで計算 :2014-03-01, 4d

この仕組みにより、月末を待つことなく、毎日少しずつ増えるイベントを対象に実際の処理を実行し、チェックを行うことができるようになりました。結果として、「締め」を余裕を持って行うことができるようになっています。

達人プログラマー第二版 Tip 42 「少しずつ進めること―――常に」

会計数値の妥当性をダッシュボードに表示する

「CADDi Manufacturing」では毎月大量の取引を行っており、人間による妥当性チェックには限界があります。できるだけ自動的に検証することはできないかと考えて、検証機能をデザインしました。

検証機能の1つを紹介しますと、一定期間中の製品の入庫と出庫のイベントによって変動した在庫数量の合計と、その期間の開始と終了の間の在庫数の差分が一致しているかをチェックしています。

flowchart LR
    1a[入庫: 2個] --> 1b["イベントの合計: (2 - 1 = 1) 個"]
    1c[出庫: 1個] --> 1b
    1d[開始時点の在庫数: 1個] --> 1f["在庫数の差分: (2 - 1 = 1) 個"]
    1e[終了時点の在庫数: 2個] --> 1f
    1b --> 1g[1 == 1: OK]
    1f --> 1g

    2a[入庫: 2個] --> 2b["イベントの合計: (2 - 2 = 0) 個"]
    2c[出庫: 2個] --> 2b
    2d[開始時点の在庫数: 1個] --> 2f["在庫数の差分: (2 - 1 = 1) 個"]
    2e[終了時点の在庫数: 2個] --> 2f
    2b --> 2g[0 != 1: NG]
    2f --> 2g

この検証機能により、次の項目を検証することが可能になりました。

  • 上流システムが、ヌケモレやダブりなく、入庫、出庫イベントを送信しているか?
  • 会計システムが、間違いなく入庫、出庫イベントを会計データに変換しているか?

この検証結果は Datadog 上にダッシュボード化されていて、一目で異常が発生したかどうか、異常の発生した割合がどれくらいかが分かる仕組みとなっています。

Datadog Dashboard

会計システムのモデリングと最初の開発

開発初期は以下の流れで設計を進めました。

  1. 仕訳*1の流れを整理する
  2. ドメインモデルとデータベースモデルを作る
  3. 最初の開発をどの機能にするか決める

仕訳の流れを整理して、ドメインモデル、データベースモデルを作る

まず、以下の様な図で仕訳の流れを整理しました。

flowchart LR
    k[買掛金] -- 入荷 --> s[仕掛品]
    s -- 製品完成 --> p[製品]
    p -- 原価計上 --> 売上原価

イベントによって、どのように仕訳の勘定科目が移り変わって行くのかを図示しています。例えば1つ目の矢印では、「入荷」というイベントによって、「買掛金」という勘定科目の金額が増えるととともに、「仕掛品」という勘定科目の金額が増えることを示しています。

この図を用いて、生産管理システムで発生する入荷や製品完成などのイベントによって、どのような仕訳が生まれるのかを経理部門と認識を合わせます。 Miro上に描かれたラフなポンチ絵を使っておおまかに擦り合わせていきます。

そして、以下のようなドメインモデルとデータベースモデルを初期に作成し、経理部門にレビューしてもらいながら進めていたのですが、ここで違和感を感じ始めます。

データベースモデル

ドメインモデル

ユーザーの言葉で話す

レビュー会では目立った指摘を受けることなく設計が進んでいました。手戻りが少ないのは嬉しいですが、正しいものがきちんと設計できているのか、不安視する声もエンジニアからは上がってきます。

そんなある日、とあるレビュー会で処理の内容を説明するために、仕訳の表を用いて説明をしたときのことです。経理部門からはいつもよりも多くの発言を頂き、とても有意義なディスカッションが実施できたのを記憶しています。

仕訳の表

考えてみれば、ドメインモデルやデータモデルはエンジニアの言語です。仕訳の表は経理部門の言語です。経理部門の言語でエンジニアが会話したことにより、経理部門の理解が進んだ結果、有意義なディスカッションが発生したのだと考えています。

ドメインエキスパートの日々の仕事内容にまで踏み込んで会話して初めて、良いプロダクトができる、ということを実感したエピソードでした。

達人プログラマー第二版 Tip 78 「ユーザーとともに働き、ユーザーのように考える」

最初の開発をどの機能にするか検討する

設計は進めていたものの、開発すべき仕訳の種類は多種多様で、どこから手をつければ良いか全く検討がついていませんでした。ただ、チームでは次に該当する機能を開発してリリースしよう、と話をしていました。

  • 仕訳はごく一部にしぼる
  • システムアーキテクチャ全体を串刺す
  • 一部でもビジネスに貢献できる

最終的に、製品仕訳についてイベントを収集して検証する機能を開発することに決定しました。

  • 製品仕訳に関わるイベントの収集
  • 製品仕訳に関わる仕訳の生成と保存
  • 製品仕訳と在庫数の検証

製品仕訳について、システムアーキテクチャ全体を串刺して開発することにより、アーキテクチャに起因するリスクを早期に洗い出す狙いです。この機能はうまく完成し、その後は取り扱う仕訳の種類を増やしていくことで開発を進めることができました。これは、「曳光弾」と呼ばれる開発手法です。

達人プログラマー第二版 Tip 20 「目標を見つけるには曳光弾を使うこと」

会計というドメインを Rust で表現する

New Type Pattern と Phantom Type Pattern

金額や数値、IDなどの単純な項目は基本的に "New Type Pattern" を使用しています。"New Type Pattern"を使用することで、在庫数を金額に代入してしまうような、単純な代入のミスによる不具合の発生を防ぐことができます。

同種の値は同じようなロジックを持つ事が多いですから、 "Phantom Type Pattern" の利用も積極的に行います。 "Phantom Type Pattern" については以下の記事を参照ください。

caddi.tech

下の例をご覧ください。加工後の数であるProcessedQuantityと在庫数であるInventoryQuantityを別の型として表現しています。さらに、"Phantom Type Pattern"を使用してi32との相互変換処理は共通のものを定義しています。

use std::marker::PhantomData;

pub struct TaggedQuantity<T: quantity_type::QuantityType> {
    value: i32,
    quantity_type: PhantomData<T>,
}

pub type InventoryQuantity = TaggedQuantity<quantity_type::Inventory>;
pub type ProcessedQuantity = TaggedQuantity<quantity_type::Processed>;

pub mod quantity_type {
    use std::fmt::Debug;

    // Trait 制約をつけるための trait
    pub trait QuantityType: Eq + PartialEq + Debug {}

    // PhantomData の型パラメータに渡すための抽象的な型
    #[derive(Debug, Eq, PartialEq)]
    pub struct Inventory;
    impl QuantityType for Inventory {}

    // PhantomData の型パラメータに渡すための抽象的な型
    #[derive(Debug, Eq, PartialEq)]
    pub struct Processed;
    impl QuantityType for Processed {}
}

impl<T: quantity_type::QuantityType> TaggedQuantity<T> {
    pub fn signum(&self) -> i32 {
        self.value.signum()
    }
}

impl<T: quantity_type::QuantityType> From<i32> for TaggedQuantity<T> {
    fn from(value: i32) -> Self {
        Self {
            value,
            quantity_type: PhantomData::<T> {},
        }
    }
}

会計台帳を Rust で表現する

会計台帳を表現する会計仕訳のコードサンプルは以下です。

// 一定期間の台帳全体
pub struct AccountingJournal {
    id: JournalId,
    transactions: Vec<AccountingTransaction>,
}

// 台帳の1行
pub struct AccountingTransaction {
    id: AccountingTransactionId,
    accounting_date: AccountingDate,
    occurred_at: EventDateTime,
    entries: AccountingEntrySet,
}

pub enum AccountingEntrySet {
    // 製品完成というイベントに対応するレコード
    ProductComplete(
        AccountingInventoryEntry,
        AccountingWorkInProcessProductEntry,
    ),
    
    // ・・・ 各種イベントごとの定義が続く
}

// 台帳の1行を構成する要素で、勘定科目「製品」の金額を示す
pub struct AccountingInventoryEntry {
    id: EntryId,
    amount: TotalAmount,
    quantity: InventoryQuantity,
}

// 台帳の1行を構成する要素で、勘定科目「仕掛品」の金額を示す
pub struct AccountingWorkInProcessProductEntry {
    id: EntryId,
    amount: TotalAmount,
    quantity: InventoryQuantity,
}

台帳全体を表すAccountingJournal、台帳の一行を表すAccountingTransaction、1つの金額と勘定科目をセットにしたAccounting**Entryなどの要素を用いて台帳という概念を表現しています。

最終形に至るまで何度もこのドメインの設計は見直しを行っています。最初はチームに会計知識が少ないところからスタートしましたが、開発を経るごとに知識が高まり、以前に書かれたコードの見直しが必要になったためです。

ドメイン知識をRustのような言語で厳密にコード化すると、コンパイラに指摘された箇所からドメインへの理解が曖昧な点が分かることがあります。そのような気づきからドメイン知識をアップデートしてコードを改善し、ドメインへの理解を深めていく活動はとても楽しいものです。

達人プログラマー第二版 Tip 65 「早めにリファクタリングすること、そしてこまめにリファクタリングすること」

State Machine を型で表現する

もう1つコード例を紹介しましょう。

バッチ処理は以下の流れで実行されます。

  1. 初期化
  2. イベントから仕訳(Journal)を生成する
  3. 検証してReportを生成する

以下は、1回のバッチ処理の進捗状況を示すクラスです。

// 初期化後の状態
pub struct CreationSetInitialized {
    id: JournalCreationSetId,
    target_month: YearMonth,
}

impl CreationSetInitialized {
    pub fn create_journal(self, journal_id: JournalId) -> CreationSetJournalCreated {
        CreationSetInventoryCreated {
            id: self.id,
            target_month: self.target_month,
            journal_id,
        }
    }
}

// 仕訳(Journal)生成後の状態
pub struct CreationSetJournalCreated {
    id: JournalCreationSetId,
    target_month: YearMonth,
    journal_id: JournalId,
}

impl CreationSetJournalCreated {
    pub fn create_report(
        self,
        report_id: ReportId,
    ) -> CreationSetReportCreated {
        CreationSetReportCreated {
            id: self.id,
            journal_id: self.journal_id,
            report_id,
        }
    }
}

// Report生成後の状態
pub struct CreationSetReportCreated {
    id: JournalCreationSetId,
    target_month: YearMonth,
    journal_id: JournalId,
    report_id: ReportId,
}

状態ごとに別々の型を定義しています。処理が進むに従って情報が追加されるので、フィールドが増えていくようにしています*2。このような実装にすることで、以下のメリットがあります。

  • 状態ごとに型が定義できるので可読性が高くなる
  • Optionを排除して分岐を少なく記述できる

おわりに

今回は、会計システムのアーキテクチャと設計の進め方、Rustの実装サンプルを紹介しました。

会計システムでは、モノづくり産業のほんの一部である会計という世界をシステムに落とし込む難しさ、面白さに向き合うことができました。CADDiでは、「リアルな世界をシステムに落とし込む難しさ×面白さ」に向き合う開発エンジニアを募集しています。

エンジニア向け採用情報

*1:企業のお金の流れを記録するもの

*2:Domain Modeling Made Functional: Tackle Software Complexity with Domain-Driven Design and F#という本を参考にしました