この記事の位置づけ
この記事は アプリケーション設計の説明 を目的とします。デスクトップで画面を抱えつつ、永続データと画面上の状態をひとつの流れで説明したい という場面での 構成の一例 として、Tauri、Rust、SQLite を組み合わせた自作ソフト Ordita の実装を題材にします。
Orditaそのもの説明はZennに記載しています。
https://zenn.dev/kesseract61/articles/2b3ee511831b39
リポジトリはこちら
https://github.com/Kesseract/Ordita
扱う設計は、おおむね次の 二本柱 です。
- 状態の一元管理 … ディスク上のデータ(SQLite)と、アプリ側で「いまどう見せるか」を司る状態(Rust 内の UI 状態)を分けずに 同じ処理のパイプラインで更新・表示する。
-
操作の集約 … 画面から来るすべての確定操作を
enum TriggerとApplication::handleの一点 に寄せ、以降は Controller・Service・DB の既知のレイヤに落とす。
「唯一の正解」ではありません。似た問題を運ぶ人の設計メモ・比較材料 にしてもらえるとと思います。
具体例としての Ordita(背景だけ)
Ordita は 設計情報が複数の Excel に散らばる運用への代替 という文脈で作られました。Excel は セル・自由記述中心になりやすく非構造化になりがち なので、SQLite で構造化データとして持つ ことで、将来 AI エージェントと設計コンテキストをやりとりしやすくする 目論見もあります。
製品としての機能紹介や使い方はZennの別稿を想定しており、この記事では 構成の読みどころ に絞ります。
スタックと外枠(Ordita の場合)
| 領域 | 採用 |
|---|---|
| デスクトップシェル | Tauri 2 |
| バックエンド言語 | Rust 2021 |
| 本体フロント | HTML + CSS + JS |
| DB | SQLite + rusqlite |
Cargo の features で HTML→レンダリング用の hyper-render などを切り替えられるようにしている点も、構成の一例です。
この記事では、スタック詳細より 状態と操作がどう流れるか を優先します。
柱① … 状態の一元管理(永続レイヤと UI 状態の正本)
狙い
- 永続データ(SQLite)はもちろん、タブや選択・ダイアログなど画面上の状態も、すべて Rust 側の Application レイヤまで落ちてきてから説明できる と、デバッグやテストが楽になります。
- WebView は
invokeと JSON の往復に特化させ、「いまのアプリ状態は何か」 の正本は Rust が、UiSnapshotの観点で保持します。
正本としての UiSnapshot と、フロントへの投影 FrontendSnapshot
-
アプリ内の UI 状態の正本は
UiSnapshotです。 -
フロントに渡すのは、
FrontendSnapshot(Serializeしたビューモデル)です。
フロントは入力中などのローカル状態を持ってよいものの、確定した変更は Trigger 経由で Application が反映したあと、get_snapshot で再取得するサイクルを基本にしています。これで SQLite に書いた内容 と 「いまユーザーに見せている状態」 の説明が、同じ状態機械の上でつながります。
※ 状態フローを図で追うときは、この記事後半の 「レイヤとデータフロー」 を参照してください。
柱② … Trigger + Application::handle に集約する構造
狙い
- 「どこからでも Service を直接呼んではいけない」「画面用のフラグだけ別バスで飛ばさない」のではなく、そもそも入口を物理的に一つに近づける。
-
Application::handle(Trigger::…)に集約すると、トランザクションの開始・終了、UiSnapshotの更新、Controllerの呼び出し を同じ関数族で順序だてて書けます。
Trigger のかたち
enum Trigger が、一覧・編集・ドラッグ・ダイアログ遷移など ユーザー操作の型になります。
ディスクリミネイテッドユニオンなので、match の網羅で処理漏れに気づきやすい反面、バリアント数が増える負担があります。
索引としてファイルを置き、統合テストで Application::handle まで踏む ことで安全性を担保する、というセットがこの例での落としどころです。
コマンドの数と入口の関係
Tauri 側には #[tauri::command] が複数あり得ますが、その奥のミューテーションは Application::handle に収束させる 方針にしています。「ルールは Application に一本」 と決めると、Controller / Service / DB の境界がブレにくくなります。
レイヤとデータフロー
「柱①・②」をつなぐ全体像の例は次のとおりです(Orditaでは下記の形で作っています)。
[往路] 操作が下流へ(ミューテーション・検証の呼び出し)
src_front(表示・操作)
↓ Tauri invoke
src/commands.rs(JSON 変換・スレッド境界)
↓ Application::handle
lib/application/mod.rs(Trigger の唯一の入口・トランザクション・UiSnapshot 更新)
↓
lib/controller/(解釈・検証・Service 呼び出し)
↓
lib/service/
↓
lib/db/(Repository・SQL)
↓
SQLite
[復路] ① 永続層から Application へ(読み取り/書き込み結果の反映)
SQLite
↓
lib/db/(Repository・SQL)
↓
lib/service/
↓
lib/controller/
↓
lib/application/mod.rs(結果の集約・UiSnapshot 更新)
[復路] ② UI への投影(正本は UiSnapshot → フロントは FrontendSnapshot)
lib/application/mod.rs(UiSnapshot)
↓ get_snapshot 等で取得
src/commands.rs(FrontendSnapshot の組み立て・JSON・スレッド境界)
↓ invoke の戻り・または明示的な get_snapshot
src_front(スナップショットに基づく DOM 更新)
設計としての要点は、往路でも復路でも 「ミューテーションの意味のある操作は Trigger で Application に入れる」 ことです。
復路②は 状態の読みだしであり、SQLite と UiSnapshot の両方を材料に FrontendSnapshot に畳む ところに意味があります。
IPC とアプリケーションスレッド
Tauri から呼ばれる #[tauri::command] 側(WebView の invoke に直結する入口)と、ずっと生き続ける Application と SQLite 接続は、別の実行コンテキストに分けています。
Rust では「Web から飛んできた一発の関数」と「常駐して DB と話すオブジェクト」を 同じスレッドで抱え込む と、所有権や Send、トランザクションの境界が説明しづらいので、リクエストと結果を std::sync::mpsc(チャンネル)で往復させる形にしています。
流れをざっくりいうと、次のようなイメージです。
- フロントが
invokeする。 -
表側:
commandsが JSON を Rust の値に直し、必要ならTriggerなどに組み替える。 -
裏側:そのメッセージを専用スレッド上の
Applicationに渡す。ここだけで DB の読み書き・UiSnapshotの更新が完結する。 -
表側へ戻すとき、巨大なストラクチャをそのまま戻り値にするのではなく、チャンネルの
Senderをメッセージに渡しておき、裏側が組み立てた塊(例:FrontendSnapshot)をそこに送る というパターンも使います。GetSnapshotとSender<FrontendSnapshot>が対になっているのはそのためです。
まとめると、commands が担っているのは次の 2 つです。
-
JSON とドメイン表現の境目。Web には
SerializeできるFrontendSnapshotなど だけを見せ、Rust 内部の型はここで揃える。 -
「DB と仲良くしている処理」は
Application用スレッドに閉じる。rusqlite::Connectionをコマンド関数のローカル変数で握り回さない イメージです。そうすると「いつトランザクションが開いて閉じたか」を コードのレイヤで追いやすいです。
UI を Web に寄せた理由
最初に実装をしたときには UI は egui でしたが、Ordita が担う 画面設計の役割として HTML モックを実 DOM で取り込み、要素と紐づけて操作する 必要がありました。
egui のみでは 実 DOM 上の HTML モックをそのまま扱う ところまでを満たしにくく、Tauri + WebView + 素の Web フロント に寄せました。
Trigger と Application が既にいた ので、レイヤ構造ごと潰さずに表示だけ差し替えられています。
トレードオフとして、チーム規律が効きにくいので get_snapshot と invoke の約束は docs/ で固定しておく 必要があります。
テスト … 状態と操作が一点に寄っているからこそ効く検証
Trigger → Application::handle に振る舞いが集まっているので、ソフトを起動しなくてもCLIで cargo test から同じ経路を叩ける 点が大きいです。
WebView 上の フル E2E は環境も重く、失敗時の切り分けも大変になりがちです。
だから 「ドメイン〜 Application までを CLI の統合テストで担保する」 という発想と、フル E2E に頼り切らない という二分法を、設計の一部として採りやすくなります。
おわりに
状態を SQLite と UiSnapshot で一元に説明する と同時に、操作を Trigger と Application::handle に集約する。
この二本柱は、Ordita が Excel より構造化データを運びたかったという文脈と相性がありますが、その用途に縛られるものではなく、Tauri でラップしたツールでも「バックエンドをちゃんとしたドメインレイヤにすること」ができる という例になっています。
似た構成を自分の製品で検討するとき、この記事が レイヤ分割と状態の境界のたたき台 になれば十分です。
参考:Ordita とこの記事について
Ordita は OSS としてのコントリビューション手順を整えているわけではありません。
個人利用の範囲での改造・カスタマイズは自由に行ってよい一方、改造に伴う不都合について筆者は責任を負いません。また、再配布に関しても制限を行っております。
UI・執筆の背景として、実装は Cursor での開発を主体に進めました。
筆者は Rust に精通しているわけではなく、この記事は 熟練者の作法集ではなく、現状構成を説明したもの です。
-
この記事とリポジトリがずれることがあります。最終的にはリポジトリの
docs/とソースが正本です。