◆ 1 つの保存操作で複数の API を呼び出すと一部が保存されないケースがある
◆ リクエストごとにトランザクションを作るのではなくクライアント側でトランザクション制御できるようにする

以前書いたこれを実際に動く形でやってみました

やること

API をフォームごとに作りたくないです
でもそうすると UI の都合で 保存するときに複数 API を呼び出さないといけなかったりします
普通にやるとリクエストごとに別のトランザクションなので エラーが発生すると フォームのこの部分は保存されてるのにこの部分は保存できてないってことが発生します
だからといってフォームごとに専用 API を用意するのは気が進みません

そもそもの前提としてリクエストごとにトランザクションを作るのが良くなくて 保存ボタンを押したときに複数 API を呼び出すならそれらをひとつのトランザクションにまとめるべきなんです
なので API として BEGIN や COMMIT を用意することにしました
まとめて保存したい場合は BEGIN の API を呼び出してトランザクションを始めます
その次に各 API を呼び出して最後に COMMIT の API を呼び出します

実装

BEGIN 時にトランザクションの ID となる UUID を受け取り 以降の API では ID を POST に含めます
ユーザーの操作でリクエストされるものということを考えれば同時に複数の処理が起きることは考えづらいので Cookie 管理でもいいかなという気はしましたが 一応 ID で管理にします

簡単な例を作ってみました
index.html のページに A,B,C の 3 つの input があります
これらの更新はそれぞれ別の API で行う必要があります

更新時の処理はこんな感じです

const trx_id = await api.post("begin")
await Promise.all([
api.post("a", { data: a.value, trx_id }),
api.post("b", { data: b.value, trx_id }),
api.post("c", { data: c.value, trx_id }),
])
await api.post("commit", { trx_id })

タイムアウトが設定されていてコミットせずに放置すると時間が切れるとロールバックします

A,B,C ともに text というデータは重複するので保存できないようにしています
どこかに text という入力をいれてみるとそのリクエストがエラーとなります
以降のリクエストでは エラーになったものと同じ ID を使っているとエラーになります

トランザクション実行タイミング

楽に作れるという理由で各 API の呼び出し時にデータベースでクエリを実行してレスポンスを返しています
これだとコミットしない場合に開いたままのトランザクションが残ってしまいます
タイムアウトがあるにしても開いたままのトランザクションが多いとパフォーマンス的に不安もあります

基本クライアント側ではトランザクションを開始後にまとめて API を呼び出してすぐにコミットを想定していて 未コミットの状態が長く続くことは想定していません
ですが API なので直接呼び出せてしまいますからね

実際クライアント側で制御するトランザクションで 1 つめのクエリのレスポンスに応じて 2 つめの API に送信するパラメータを変更するというケースは特に思い当たりません
基本は同時に保存リクエストを送るだけです
なので コミットされるまでは実際にデータベースへクエリの発行は行わず コミット時にまとめて行うほうがベストではありそうです

ただ最初に書いたように今回やった方法の方が楽なんですよね
トランザクションに含めずに 独立して実行したい場合ももちろんあるわけで まとめて実行にするとトランザクションに含めるかどうかで扱いが変わってきてしまいます

意図的にコミットしない悪質なリクエストや効率を考えれば そういう工夫もしたほうがいいのでしょうけど 大した規模でもなくそもそも攻撃しようなんて人もまずいなさそうなものだとそこまでする必要はない気もしています
規模が大きなものだと クライアントとサーバの間にサーバ側のレイヤを追加で設けて そこで処理することが多いと思います
そういうのは使ったことないですが 以前見かけたものだとクライアントはフォームに対して 1 つのリクエストだけで 中間の部分がサーバへのリクエストを複数行い最終結果をクライアントに返すようなものみたいでした
Next.js みたいな SSR ツールを使うと実質そういう感じの 3 層になってますが フォームや画面にたいしてクライアントが呼び出すのは 1 つの API でいいようにまとめるかは別だと思うので SSR ツールを使っていてもそういう仕組みになってるのかはわかりません

私の場合はそんな大きなものでもなく 追加のレイヤなんて面倒なものを作りたくもないのでこの方針にしようと思ってます
と言っても 本当にトランザクションが必要な部分は 1 つの API の中で行っているので UI 都合のところくらいです
部分的な更新になってもエラーメッセージが「一部のデータが保存できませんでした 再度実行してみてください」にしておけばあえて機能を作るほどでもないかなと思ってたりもします