こんにちは(読む時間決めてすみません) バクラク申請・経費精算 ネイティブアプリエンジニアのyoheiです。
最近Amazonでセールになっていた将太の寿司をイッキ読みして、寿司の奥深さ素材の鮮度の重要性を再認識いたしました。寿司職人はすごい。
この記事はLayerXテックアドカレ2024の22日目の記事です。
先日、Flutterアーキテクチャ設計の最前線 - 4社が語る実務での挑戦と工夫 で複雑な仕様に立ち向かうアーキテクチャ の話をさせていただきました。
アーカイブ動画もあるのでぜひ御覧ください。
今回は複雑な仕様に立ち向かうためにどのような実装をしているかを書きたいとおもいます。
なぜ複雑な仕様が問題になるのか
バクラクではそもそもドメインが難しいのに加え、大変なことはシステムに任せ、人間が楽できるような考え方で開発を進めています。
そのため、難しい事象を解決するために仕様がどんどん複雑になってきます。また、仕様は日々更新され、アラートやバリデーションが追加・変更され続けます。
まずはとても単純なサンプルです。私たちが開発している申請経費精算のアプリの実際の画面です。
経費精算における明細の金額は以下の2パターンを取ることができます。
- 自由入力(編集可)
- 手当などにより計算され自動入力(編集不可)
この仕様を満たすために必要なパラメーターは以下の2つになるかなと思います。
- 金額入力値
- 編集可かどうかのフラグ
実際にDartのコードで書くと以下のようになります。
class PaymentAmountState { /// 金額入力値 final int input; /// 編集可能かどうか final bool isEditable; }
実際にFormを表現すると以下のようになります。
final controller = useTextEditingController(text: paymentAmountState.input); TextField( controller: controller, readOnly: !paymentAmountState.isEditable, // onChangeなど );
この段階では非常にシンプルな実装で済みます。しかし、新しい仕様が追加されると、状況は複雑になっていきます。 例えば、バクラクではレシートなど証憑画像をアップロードすると、AIがOCRを実行し明細の金額を自動で入力してくれる機能があります。 追加の仕様で、AI-OCRから金額が読み取られたときには金額の横にバッジを表示したい、AIが値をいれたことがわかるようにしたい(編集可)となりました。このような場合、どのように実装すればよいでしょうか?
単純にフラグを追加するとこのようになります。
class PaymentAmountState { /// 金額入力値 final int input; /// 編集可能かどうか final bool isEditable; /// AIにより値が入力された final bool filledByAi; }
これだと、仕様上ありえない「AIが入力した場合に編集不可」な状態が作れてしまいます。 これらのありえない値を考慮したうえでのハンドリングが必要になる上、新しい開発者が入ったときに毎回「AIが入力した場合に編集可」という説明が必要になり、仕様をコード上で表現できていません。
こんなときに使うのがSealed classです。
Sealed classとは
Sealed classとは何かをChatGPTさんに聞きました。
Sealed classは、Dart 3で正式に導入された 新しいクラス修飾子で、クラスの継承関係を厳密に管理することができます。これにより、ドメイン上の特定の状態やバリエーションを「漏れなく」「定義通りに」表現しやすくなります。
Sealed classのポイント
継承関係の制約
Sealed classは、同一ライブラリ内でのみサブクラス化が許可されています。つまり、Sealedクラスを継承できるクラスは、定義元と同じファイル、または同じライブラリ内に限られます。これにより、想定外のクラスが外部から自由に継承することを防ぎ、状態やバリエーションを厳密にコントロールすることが可能になります。
パターンマッチングとの組み合わせで強力なエラーチェック
Sealedクラスとサブクラスによって定義された状態は、
switch
やwhen
文で網羅的にチェックできます。これにより、開発者は「考慮漏れ」がないことをコンパイル時に保証できます。例えば、
Result
というSealedクラスがSuccess
、Error
、Loading
という3つのサブクラスを持っている場合、switch
でResult
を分岐する際、これら3ケースすべてをハンドリングしないとコンパイラが警告を出します。enumと異なる柔軟性
enumは状態や定数値の集合を表すのに向いていますが、enum自体は値を保持することは可能でも、複雑なロジックや複数のプロパティを内包するにはやや限定的です。
一方でSealedクラスは「クラス」であるため、フィールドやメソッドなどの複雑なロジックやデータを持たせることができます。
また、複数のSealedクラスサブタイプで異なる型の情報を保持し、異なる振る舞いを実装することが可能となり、よりリッチな状態表現ができます。
ドメインロジック表現の明確化
Sealedクラスを活用すると、アプリケーション固有のドメイン状態(エラー状態やデータ取得状況、ユーザー操作ステータスなど)を型レベルで明示し、コンパイル時に正しく処理されているかを保証できます。
これによって実行時エラーや状態の不整合を未然に防ぐことができ、コーディング時点で網羅性・整合性を確保することが可能です。
まさに4番が今回のケースに当てはまりますね。
Sealed classを使って改善
先程のこちらのコードをSealed classを使って改善したいと思います。
// Before class PaymentAmountState { /// 金額入力値 final int input; /// 編集可能かどうか final bool isEditable; /// AIにより値が入力された final bool filledByAi; }
改善後はこちらになります。
//After sealed class PaymentAmountState { PaymentAmountState(this.input); /// 金額入力値 final int input; bool get isEditable; } /// 手入力 編集可 class ManualInputPaymentAmountState extends PaymentAmountState { ManualInputPaymentAmountState(super.input); @override bool get isEditable => true; } /// 自動入力 編集不可 class AutoFillPaymentAmountState extends PaymentAmountState { AutoFillPaymentAmountState(super.input); @override bool get isEditable => false; } /// AIにより読み取られた class FilledByAiPaymentAmountState extends PaymentAmountState { FilledByAiPaymentAmountState(super.input); @override bool get isEditable => true; }
これにより仕様を満たすコードを表現できることになります。
PaymentAmountState
はManualInputPaymentAmountState
, AutoFillPaymentAmountState
, FilledByAiPaymentAmountState
の3つの状態を取ることができ、それぞれ編集可能かどうかがクラスをみるとわかります。
更にAIにより読み取られた場合には他に読み取られ候補の値を表示したいなどの仕様が追加になったとしてもFilledByAiPaymentAmountState
に他の選択肢をもたせることもできます。(このクラスに持たせるのが正しいのかは別として😅)
class FilledByAiPaymentAmountState extends PaymentAmountState { FilledByAiPaymentAmountState(super.input); /// AIにより読み取られた値の選択候補の数値 final List<int> candidates; @override bool get isEditable => true; }
更にSealed classのいいところとして、パターンマッチがあります。
バクラクでは外貨対応もしており、日本円のみ入力の場合と外貨入力が可能な金額フォームを作ることができます。
その場合の金額は以下のようにSealedクラスを使って実装しています。
/// 金額 sealed class PaymentAmount {} /// 日本円のみ class JpyPaymentAmount implements PaymentAmount { /// 日本円 final int amount; } /// 日本円+外貨 class ForexPaymentAmount implements PaymentAmount { /// 外貨コード final String currencyCode; /// 入力された金額 final double amount; /// 日本円に変換するレート final double rate; }
金額をAPIに送出する場合にswitch文でパターンマッチを行いパラメーター作ってます。
PaymentAmountParameter createParameter(PaymentAmount paymentAmount) { return switch(paymentAmount) { JpyPaymentAmount(:final amount) => PaymentAmountParameter.jpy(amount), ForexPaymentAmount(:final amount, :final currencyCode, :final rate) => PaymentAmountParameter.forex(amount, currencyCode, rate), }; }
パターンマッチが強力な点は全ケースを網羅しない限りコンパイルエラーが出るため、仕様変更時に新しい状態を追加しても漏れがなくなる点です。
例えば、外貨でレートはなく外貨と日本円が固定のケースが追加となった場合、APIパラメーター作成の関数がエラーになります。開発者はパターン追加による影響範囲を知っておく必要はなく、コンパイルエラーになった箇所の修正をするだけでよくなります。
※is
や switchのdefault
ケースを使ってる場合、コンパイルエラーにならないので最低限のハンドリングのみ必要という明確な確証がない場合は利用に注意が必要なため、すべてのケースを網羅して書いておくことをおすすめします。
/// 外貨と日本円が固定 class FixedForexPaymentAmount implements PaymentAmount { /// 外貨コード final String currencyCode; /// 外貨金額 final double forexAmount; /// 日本円 final int jpyAmount; } PaymentAmountParameter createParameter(PaymentAmount paymentAmount) { // `FixedForexPaymentAmount`のcaseを追加しないとコンパイルエラーになる return switch(paymentAmount) { JpyPaymentAmount(:final amount) => PaymentAmountParameter.jpy(amount), ForexPaymentAmount(:final amount, :final currencyCode, :final rate) => PaymentAmountParameter.forex(amount, currencyCode, forexAmount), FixedForexPaymentAmount(:final currencyCode, :final forexAmount, :final jpyAmount) => PaymentAmountParameter.fixedForex(jpyAmount, currencyCode, forexAmount), }; }
強力なSealed classをバクラクでは他にも以下のようなパターンで使用しています。
- フォーム種別のパターン
- 支払申請、経費精算など
- 明細のパターン
- 手入力、交通費、手当など
- 手入力された要素なのか、選択された要素なのか
まとめ
複雑化するビジネス仕様に対して、コード上でドメインルールや状態を明示的に表現することは、堅牢な実装の鍵となります。
Dart 3で導入されたSealed classは、ありえない状態を型システムで防ぎ、パターンマッチングによる網羅性チェックを可能にします。これにより、ロジック漏れや不整合な状態を発生しづらくし、将来的な仕様変更に対しても強い耐性を持つコードを書くことができます
こうした型レベルの表現は、保守性・可読性を高め、チーム内の共通理解をサポートしまます。結果として、バグや仕様漏れの減少、リファクタリングの容易化、迅速な仕様変更への対応など、長期的な品質と開発効率向上をしていきます。
終わりに
モバイル開発について気になることがあれば何でもお話しましょう!LayerXのアプリ開発はどんなことやってるの?でも大丈夫です〜
採用情報はこちら↓