所有者を置かない共有設計と、二段構えの更新ゲート
子ども向けの習慣化アプリで、家族が同じボードを一緒に見る機能を作り直しました。あわせて、アプリの更新をサーバーから促す仕組みを入れています。
どちらも地味な裏側の話ですが、決めないほうが設計が小さくなるという判断が共通していたので、そこを中心に書きます。
所有者を置かない、という設計
誰のものか、を決めていた
最初の設計では、共有ボードは「作った人のもの」でした。作成者のデータのぶら下がりとしてボードの中身を置き、ほかの参加者はそこを見に行く。素直な作りです。
ただ、この「所有者」をひとつ置いた瞬間に、付随する設計がぞろぞろ生えてきます。
- 所有者がアプリをやめたら、ボードはどうなるのか
- 誰かに引き継ぐなら、誰に渡すのか(最古参? 手動指名?)
- 引き継ぎ先が無料プランだったら、プレミアム限定の設定はどう扱うのか
- 新しい所有者に、所有者になったことを知らせるのか
どれも「所有者がいる」という前提からしか出てこない問いです。そしてどれも正解がない。プロダクトとして「こうします」と決めるしかなく、決めたぶんだけコードと説明責任が増えます。
非対称なモデルは、経路の数だけ抜けを生む
実装上はもうひとつ問題がありました。作成者と、あとから招かれた人で、参加の記録のされ方が違っていたことです。
作成者は自分のドキュメントの参加リストにボードを持ち、招かれた側は別のフィールドに持つ。さらに古い共有は、ボードの中の配列に参加者を書き込んでいました。
通知の宛先を集めるコードは、こうなります。
// 作成者は「ボードの置き場所」から
// 招かれた人は名簿のサブコレクションから
// 古い共有はボード内の配列から
const recipients = new Set([
...resolveOwner(boardId),
...(await fetchRoster(boardId)),
...legacySharedIds(board),
]);
参加者の種類ごとに出どころが違う。3 本の経路のうち 1 本でも欠けると、特定の立場の参加者にだけ通知が飛ばないという、偏った欠け方をします。全員に飛ばないなら気づきますが、「招かれた人にだけ飛ばない」は気づきにくい。
非対称なモデルは、経路の数だけ抜けの種類を増やします。
参加の証を一本にする
作り直しでは、所有者という概念そのものを外しました。
ボードに識別子を持たせ、その識別子の配下にボードの中身を置く。参加者は全員、自分のドキュメントの参加リストに同じ形でそのボードの識別子を持つ。作成者も招かれた人も区別しません。
// 参加リストにそのボードが入っていること自体が、参加の証
const members = await users
.where("memberOf", "array-contains", boardId)
.get();
宛先の解決が 1 クエリになりました。立場によって出どころが変わらないので、偏った欠け方をしなくなるのが大きい収穫です。
決めないことで、消えた設計
副産物として、冒頭に並べた問いが全部消えました。
所有者がいないので、やめた人から引き継ぐ相手を決める必要がない。検討していた「最古参へ自動移管する」「移管先のプランによって扱いを変える」「新しい所有者へ通知する」は、どれも実装せずに済んでいます。
退会の挙動も素直になりました。ある人がアプリをやめても、ほかの参加者のボードはそのまま残ります。 自分の参加リストから抜けるだけなので、他人のデータに触らない。
ここから一般化できる判断はひとつです。所有者を置くと、移管・継承・権限委譲という設計がセットで付いてくる。 それが本当に要るのかを、置く前に問う価値があります。共同で使うものに「代表者」を置きたくなるのは自然ですが、代表者が要るのは代表者にしかできない操作がある場合だけです。今回は、削除も編集も参加者全員ができればよかったので、代表者は不要でした。
一点だけ、子どもには引き続き制限を残しています。自分が追加したものだけ消せて、大人が追加したものは消せない。これは「所有」ではなく、役割による権限として別に持たせています。全員対等にするのは、同じ役割の人どうしの話です。
二段構えの更新ゲート
もうひとつは、アプリの更新をサーバーから促す仕組みです。
データの持ち方を変えるような更新をすると、古いアプリは新しい形を読めません。そのとき「更新してください」と伝える手段が要る、というのが出発点でした。
OS ごとに閾値を持つ
最初に決めたのは、閾値を OS ごとに分けて配信することです。
iOS と Android ではストアの審査・公開のタイミングはずれます。共通の閾値にすると、片方がまだ公開されていない間に、その OS の利用者全員をブロックしてしまう。更新したくても更新先が存在しない状態になります。
{
"ios": { "minimum": "2.2.0", "recommended": "2.2.0" },
"android": { "minimum": "2.1.0", "recommended": "2.2.0" }
}
片方だけ先に上げられる形にしておけば、公開できた OS から順に締めていけます。
強制と推奨を分ける
閾値を 2 つ持たせました。最低バージョン未満は閉じられない全画面、推奨バージョン未満は「あとで」で閉じられる案内です。
ここで効いたのは、推奨のほうを同じバージョンについては一定時間あけて再提示すると決めたことです。
// 同じ案内を毎起動出すなら、実質強制と変わらない
if (lastPromptedVersion == config.recommended &&
elapsedSinceDismiss < const Duration(days: 1)) {
return UpdatePrompt.none;
}
// 新しい案内は、間隔に関係なく届ける
毎回出すと、利用者にとっては「閉じても閉じても出てくる」ものになり、強制と区別がつきません。二段階に分けた意味が消えます。逆に、見送られたことを永久に覚えてしまうと二度と届きません。同じ案内は間隔をあける、新しい案内は即届ける、という線引きにしました。
判定できないときは、素通りさせる
ここは特に注意しました。設定が読めないこと・読めても解釈できないことを、失敗として扱わない。
Future<VersionConfig?> fetchConfig() async {
try {
return await repository.fetch();
} catch (_) {
return null; // 判定不能。何も出さずに通す
}
}
バージョン文字列のパースも同じです。想定外の形が入っていたら null を返し、呼び出し側は「判定できない」として扱って何も出しません。
この仕組みは、設定しだいでアプリを起動不能にできるものです。だからこそ、不確かなときに倒れる方向を「止める」ではなく「通す」に固定しておく必要があります。設定ミスやネットワークの不調で全利用者の起動が止まるほうが、更新案内が一度出ないことよりはるかに重い。
関連して、この設定はサインインより前に読める場所に置いています。認証が通らない端末でも更新案内だけは出せるようにするためです。認証の不調が「更新案内も出せない」に連鎖すると、利用者から見て手の打ちようがなくなります。
割り込む場所を選ぶ
最後に、常時監視はしないと決めました。設定を引くのは起動時と、アプリがフォアグラウンドに戻ってきたときだけです。
購読しっぱなしにすると、入力の途中で設定が更新された瞬間に全画面が割り込み、書きかけを捨てさせることになります。更新を促すことと、目の前の操作を壊さないことなら、後者が優先です。
あわせて、比較に使うのは表示用のバージョン番号(X.Y.Z)だけにして、ビルド番号は見ていません。ビルド番号は OS ごとに独立して採番されるので、横に並べて比較する意味がないからです。
共通していた判断
ふたつの機能は無関係ですが、判断の形は同じでした。
所有者を置かないのは、置いた瞬間に「誰に引き継ぐか」を決めなければならなくなるからです。決めずに済む構造にすれば、その設計はまるごと要らなくなる。
判定できないときに素通りさせるのも、「判定できない」を一つの答えとして扱い、無理に白黒つけないという話です。
どちらも、分からないことや決まっていないことを、無理に決めないという選択でした。設計で足すものを減らすと、説明すべきことも、壊れ方も減ります。