UPSIDER Tech Blog

AI時代のプロダクトアウト/マーケットインの捉え方|Product DeepLive イベントレポート

こんにちは!UPSIDERでHRをしていますNarisaです。 当社VPoP森(@diceK66)が『Product DeepLive』に登壇しましたので、イベントレポートをまとめます!

Podcastをすぐ視聴されたい方は、以下よりご視聴ください!

product-deepdive.connpass.com

Product DeepLiveとは?

Product DeepLiveとは、プロダクトマネージャー向けPodcast『Product DeepDive』の公開収録イベントです。Product DeepDiveはプロダクトマネジメントに関する深い洞察を提供するPodcast番組で、毎週水曜日の朝に配信されています。プロダクトマネージャーの蜂須賀さん(@PassionateHachi)とプロダクトコーチの横道さん(@ykmc09)のお二人がパーソナリティを務め、プロダクトマネジメントに関わる様々なテーマを掘り下げて”探求”されています。 今回のProduct DeepLiveでは、公開収録の視聴と参加者同士の交流がありました。 来場者特典として登壇者のカードをもらえて、中にはキラカードが用意されていたようです。

「AI時代のプロダクトアウト/マーケットインの捉え方」をテーマに収録

アマゾン ウェブ サービス ジャパン合同会社AWS)様から会場および懇親会時の飲食をご提供いただきました!開放的なスペースでとても素敵な会場でした。

今回のイベント収録では、AI時代のプロダクトアウト/マーケットインの捉え方をテーマにお話ししました。

収録内容では、以下のようなテーマに触れました。

  • AIがプロダクトアウトとマーケットインにどう影響するのか
  • AIによるインターフェースと体験の変化
  • AIの活用における現実的なアプローチ、リアルな課題と解決方法
  • AIプロダクトの開発と顧客対応の課題

詳細はぜひPodcastをご視聴ください!

▼前編

open.spotify.com

▼後編

open.spotify.com

収録の合間のQ&Aで生まれる盛り上がりはオフラインイベントの見どころの一つ。

収録は前後半に分けて実施され、その合間にはQ&Aタイムが設けられました。 Q&Aの内容は、残念ながら、完全なオフレコになるのですが、質問者様の日々の業務における課題をもとにした質問をたくさんいただきました。

質問者様には本のプレゼントというサプライズも

公開収録部分だと具体的にお話しすることが難しかったご質問にも、微妙なニュアンスを補足しながら回答することができ、オフライン会場ならではのコンテンツの一つになったのではないでしょうか。

Podcastをご視聴いただき、ご興味をもっていただきました方はぜひ株式会社UPSIDERのカジュアル面談にぜひお越しください!

herp.careers

UPSIDER Engineering Deckはこちら📣

speakerdeck.com

リリース2年半で累計決済額 700億円を突破した「支払い.com」を支える開発手法、開発チーム体制について

株式会社UPSIDERの「支払い.com」でエンジニアリングマネージャーをしている大聖寺谷です。

支払い.comとは請求書の支払いをクレジットカードで支払うことができ、 中小企業や個人事業主の資金繰りの改善を行うことができるサービスです。

リリースから今まで右肩上がりに成長を続けており、最近では累計決済額が700億円を突破しました。

prtimes.jp

今回のブログでは、支払い.comの成長を支え続ける、開発チームの体制や開発手法について紹介します。

支払い.comの開発チームと開発スタイル

支払い.comの開発チームは全体で10名以下のスモールな開発組織です。

メンバーの居住地はバラバラであり、フルリモートやフルフレックスを中心とした働き方になっています。

そのため開発手法やMTGなどについてもフルリモートかつ時間的に非同期であることを念頭において設計を行っています。

スクラムではなくカンバンで開発する

支払い.comではカンバンのような開発手法を採用しています。エンジニアの手が空いたタイミングでNotionで管理しているプロダクトバックログから優先度の高いタスクをアサインして開発を進めています。

多くの企業で採用されているアジャイル開発のスクラムですが、支払い.comの開発チームでは採用していません。理由としては、総合的に見て現在の支払い.comの開発チーム規模感やフェーズでは適さないと判断しているためです。

一方で、今後さらに人数が増えていった際やスクラム経験が豊富なメンバーがジョインした場合などは、改めて開発方式を検討したうえで、より最適なものを探したいと思っています。

プロダクトバックログ管理はNotionで行う

UPSIDERでは開発チームに限らず全社的にNotionを活用しているため、タスク管理ツールについてもNotionのDBを使っています。

以前はGitHub Projectsで管理していましたが、エンジニア以外 (PdMや事業責任者など)のメンバーがバックログを確認する機会も増えており、Notionに移行して良かったと感じています。

プロダクトバックログは、主に1つのEPIC (例: xxxの機能を開発する ) に複数の具体的なTASK(例: xxxの画面を実装する, xxxのAPIを実装する)が紐づくような形で管理しています。

プロダクトバックログはNotionのProject機能を使っており、EPICがProjectに該当します。

下記はプロダクトバックログの一部となります。

Epicのアイコンは絵文字を活用しており、遊び心も大事にしています

Epicのアイコンは絵文字を活用しており、遊び心も大事にしています

どんなタスクでどのチームが対応する必要があるかが分かるようにタグづけして管理しています。

実装に際しての要件や設計検討したものなどは各EPICに記載するようにしており、タスクアサインされたメンバーがすぐに開発に取り掛かれるようになっています。

下記は実際に今対応中のとあるEPICのキャプチャになります (見せられない箇所が多いのですが 😭)

フォーマットはEPICによってバラバラだったりしますが、課題背景や想定しているユーザーストーリー、想定している対応内容などを記載しています。

タスク管理ツールでありがちなものとして、タスクステータスの更新漏れなどがありますが、NotionとGitHubを連携しており、GitHub PRの状況に応じてタスクステータスが自動で 対応中レビュー中リリース待ちリリース完了 と遷移するようになっています。

こういった地道な開発運用改善などはメンバーが積極的に行なってくれています。 (いつもありがとうございます!)

また「今すぐにはやらないけど、いつかはやりたい!」「アイデアベースだけどこんなことやれたら良いかも」といったものは プロダクトバックログとは別の要望DBで管理しています。

要望DBについては開発チーム以外のメンバーもチケット追加が自由に可能なルールとなっており、カスタマーサポートやセールスメンバーを中心に、それぞれ自由に追加しています。

フルリモートを念頭においたコミュニケーションカルチャー

支払い.comの開発チームでは、フルリモート・フルフレックスの働き方を最大限活かしつつ、開発者が集中して作業できる時間を確保するために、定期的な同期ミーティングの機会を少なくしています。

しかし、同期コミュニケーションを完全に排除するわけではなく、週に3回の朝会や週1回の振り返りを行っています。

朝会では、各メンバーが現在の状況やタスクの進捗を共有する場としていますが、それだけではなく、「ひとこと」という欄を設けています。この「ひとこと」では、各自が最近の出来事や趣味(例えば、漫画やゲーム、スポーツなど)について自由に話す場としており、プライベートな雑談を織り交ぜながら運営しています。これにより、各メンバーが自己開示を少しずつできるようにしており、より円滑なコミュニケーションを促進する工夫しています。

さらに、チーム内では、SlackのHuddle機能を使って気軽に質問をしたり、一緒に作業したりするカルチャーも根付いています。

Huddleは、必要に応じてすぐに短時間のミーティングやペア作業ができ、オフィスで少し離れた席のメンバーに相談しにいくような気軽さで話せるのがとても便利だなと感じています。

こういった同期・非同期のハイブリッドなコミュニケーションを行うことで、フルリモートであっても高い生産性を実現できていると感じています。

開発チームとビジネスチームがフラットにそして強固に連携するカルチャーがある

私たちのプロダクトは開発して終わりではなく、ビジネス戦略を考えたり、ユーザーのサポートなどが必要不可欠です。

そこで支払い.comでは、開発チーム以外にも主に以下のようなチームが存在しています。

  • セールスチーム
  • カスタマーサポートチーム

チームが違っていても、Slackで誰とでもいつでも気軽にコミュニケーションがとれるようになっており、とても距離感が近く、職種に限らず「全員がユーザー志向を持ち、一丸となってプロダクトを磨き続ける」ということができているなと強く実感しています。

これについては以下のインタビュー記事で詳しく話しているので、ご興味をお持ちいただいた方はぜひ以下のnoteコンテンツにつきましても、お読みいただけると嬉しいです!

note.com

おわりに

本ブログでは現在の支払い.comの開発手法や体制を中心に紹介させていただきました。

一方で正直なところをお話しすると、まだまだ実現したくてもできていないことがたくさんあります。

このブログだけで全てを伝えることは難しく、もし些細なことでも気になることなどがありましたらお気軽にご連絡くださいませ。

herp.careers

herp.careers

課題は上流工程でのQA活動とテスト自動化|AI化された総合金融機関を支える品質保証とは

株式会社UPSIDERにてQAチームのマネージャーをしているNaokiです。昨年末にUPSIDERで就業をはじめQAチームの立ち上げをしています。 これから成長するUPSIDERを支えるQAチームを拡大したいと思い、絶賛採用中なのですがこれまで社外にQAチームの紹介をしたことがなかったので、筆をとりました。 私たちの取り組みを紹介します!

2024年2月に1人目が入社

今回の記事の登場人物

Naoki)SESのテスト会社に入社したところからQAエンジニアとしてのキャリアを歩み始めテスト実施やテスト設計、テスト計画や管理などQA業務に必要な作業をキャッチアップした後にフリーランスのQAエンジニアとして独立。fintech企業やECサイトでのQA経験を重ね2023年10月に業務委託としてUPSIDERにジョイン。2024年2月に社員転換。

Eri)新卒でSierにソフトウェアエンジニアとして入社。CG、医療機器の開発を行う。1度半導体業界へ転職。再びIT業界に戻ってくるのをきっかけにQAエンジニアを始める。第三者検証の会社で経験をつみ、事業会社へ転職。2024年6月からUPSIDERへジョイン。

Fukushima)ゲームの第三者検証の会社で、テスターとしてQAエンジニアのキャリアをスタートする。出向先の会社にてテスト計画、設計、実施やリソース管理などの業務を学ぶ。そこから飲食サイトの会社へ転職しさらにQAエンジニアのキャリアを積む。2024年3月からUPSIDERへジョイン。

ーーみなさんはなぜUPSIDERに入社しましたか?

Naoki) 最初は業務委託で入社しました。UPSDIERは雇用形態による差がほとんどなく、裁量の大きさに驚きました。より責任のある立場で会社とプロダクトにコミットしたいと思い、正社員になりました。もともとFintech企業はどこもお金というユーザーへの影響が大きいプロダクトをもつ性質からか、業界的にプロ意識が強い方が多い印象で、そのような環境が自分自身にあっていると感じ入社を決めました。

Eri) もともとソフトウェアエンジニア出身なのですがキャリアに悩んでいたところ偶然テストポジションに出会ったことから、QAエンジニアになりました。第三者検証という立場で働くなかで事業会社へ興味をもつようになり、UPSIDERはメンバーの人柄の良さ・裁量の大きさ、また英語環境という点に惹かれて入社しました。

Fukushima)私はNaokiさんに誘われたことがきっかけです。UPSIDERの急成長している環境に興味をもち入社を決めました。

品質保証だけではなくより良い顧客体験をつくりたい

ーー現在のQAチームのミッションを教えてください。

Naoki)品質保証チームは顧客満足のバックボーンにあたると考え、品質保証を超えて、“保証”だけではなくよりよい顧客体験を提供することを目指しています。直近では、セキュリティやコンプライアンスの基準を満たしつつ、アジャイルに開発を進めるため柔軟かつ効率的なQAプロセスの構築を目指しています。特に、リグレッションテストの自動化とスクラムへの早期関与がチームの強化ポイントです。 2024年8月ごろからはチームメンバーも増えたので、一定のSLOレベルのインシデント発生件数を品質保証水準の目安として掲げ、定量的な改善活動に取り組んでいます。

Naoki) 自社製品の品質を向上することは、業界全体の製品品質向上や、最終的に私たちの身の回りの製品向上につながると考えています。現在のQAチームは5名体制ですが、今後は10名規模のチームに成長させ、開発チームとの連携をさらに強化する予定です。

人数が増えたらより潜在的な不具合にもアプローチしたいと考えていて、バグバッシュやモンキーテスト、アドホックテストなど手法から新メンバーと一緒に考えたいです。

QAチームに限らず開発チーム全体で品質意識をもつ

ーー金融業界としてQAに求められる品質保証活動にはどのような特徴がありますか?

Naoki)やはりユーザーの”お金”に直接影響してしまう領域なので、限度額の設定や決済など特にお金の流れに関わる部分に関してはQAに限らず開発チーム全体で品質意識を持ってシビアに判断するようにしています。 開発チーム全体の意識の高さからか、QAに対する期待は高く、各QAメンバーに少しでも不安や懸念が生じたり、まずいと思ったときにはQA側の判断でリリースを止められるような裁量も持っています。

Eri)”サービスを止めない”はかなり意識をしています。当たり前ではありますが、気になる動作があった場合は懸念点が解消できるまで確認します。

Fukushima) ユーザーの事業活動にも影響しますし、ひいては自社の売上にも影響するので一定の緊張感はあります。

ーー業務の進め方には特徴はありますか?

特に明確な特徴はまだないかもしれません。 QAプロセス的には開発のスクラムチームに帯同できるところは帯同して上流工程から一緒に開発を進めています。 各機能テストやリグレッションテストは定常的なスケジュールをひきつつ、ある程度状況を見ながら臨機応変に対応する方針となっています。

今のプロセスが完璧かというと必ずしもそうではないところもあるので、開発やPdMを巻き込んで随時ブラッシュアップしていきたいですね。

エンジニアとともにテストケースを議論

ーーFukushimaさんは現在開発チームにて上流フェーズからQAとして開発に入っているとのことですが、開発フローとそのなかでのQAの具体的な業務内容を教えてください。

Fukushima) UPSIDERのQAチームでは、スクラムチームに帯同し、上流工程から品質保証の活動に携わっています。現在、開発チームでは2週間のスプリントを採用しており、QAはエンジニアと密接に連携して開発に取り組んでいます。 具体的な業務として、QAはリファインメントやプランニングに参加し、リリースに向けた仕様の確認や調整を行います。テストケースの作成はエンジニアが主に担当します。QAはそのレビューを行い、品質の観点からフィードバックを提供しています。 基本的にエンジニアによって手動テストも行ってもらっていますが、QAもGoogle Meetなどで話しながらエンジニアとテストを実施することで、テスト精度を高める役割を担っています。またレトロスペクティブや仕様の勉強会に参加し製品理解を深めています。 開発プロセスに入り込むことで、テストケースに対する気づきはたくさん得られます。「ここまでテストすべきでは?」「こういう理由があるので、ここのテストは不要」など開発チーム全体で議論ができています。

仕様理解に努めつつ、曖昧な場合は直接のコミュニケーション

ーー金融業界として求められるセキュリティ基準の高さと、アジャイルに開発をする柔軟性が求められる環境だと思いますが、QA業務を進めるなかでの難しさ・工夫を教えてください。

Fukushima) 開発チームにおけるQA業務では、金融業界特有のセキュリティ基準に対応するため、特に請求や仕訳といった複雑な機能の理解が求められます。これらの仕様をしっかりと把握し、インシデントを防ぐための事前の確認作業は非常に重要です。

アジャイル開発プロセスの中で柔軟に対応する必要がありますが、リリース期日が厳しく固定されていないため、リリースのタイミングを調整しながら、品質水準を担保しています。

開発チームとのコミュニケーションでは、正確な状況報告と迅速な対応を心がけています。また、必要に応じて直接話し合うことで、認識のズレを防ぐよう工夫しています。話が長くなりそうな場合や、伝えるのが難しい内容、または自分が理解できていない場合は、直接対話するようにしています。 あとはフランクなメンバーが多いので必要以上にかしこまらないようにしていますね。

アウトプット練習の場、ライトトークセッションなど横のつながりも

ーーQAチームメンバー同士はコミュニケーションをとりますか?

Naoki) QAチーム内ではフランクなコミュニケーションを大切にし、ウィークリーで定例を設けて取り組みや進捗の報告をしています。 また2週間に1回は振り返り会や、ライトトークセッションを設けています。本を読んだ、こういう体験をした、などを共有する時間として活用しています。 社内外での登壇練習にもなるので、フランクにアウトプットできる場を設けています。 またUPSIDERは英語が飛び交う環境でもあるので、チーム内の英語力向上の取り組みの一環として日報を英語で書く、といったことも行なっています。

テストカバレッジの向上と安定したリリースフローの確立を目指す

ーーEriさんは現在自動テストの推進をしているとのことですが、具体的な業務内容を教えてください。

Eri) 現在はテストツールを活用しながらリグレッションテストの自動化を進めています。 MagicPodはすでに運用されており、毎日テストが実行される一方、変更や不安定な部分が発生した場合にはメンテナンスを実施しています。

さらに、テストの実行速度や今後の拡張性を考慮し、Playwrightの導入をしました。Playwrightを利用することで、UIベースの確認に加えてコードベースのテストも可能になり、より効率的なテスト環境を構築しています。自動化項目が増え続ける中、開発メンバーもPlaywrightを積極的に活用しており、全社的に自動化の取り組みをサポートする動きが進んでいます。

ーーなぜPlaywrightを選んだのでしょう?

まずMagicPodやPlaywrightの導入をしていますが、手探りの状態というのが正直なところなので、より高い専門性をお持ちの方が入社したら、あるべき姿から一緒に議論したいですね。 前段の要素に加え、料金体系が影響しており、自動テストを回しやすい心理状態をつくりたかった、というのもあります。VPoEのIzumiの考え方としてコストをかけて品質が向上するなら投資をすべきだと考えているので、議論しながら最適な環境を作りたいと思っています。

ーーテストの自動化における課題があれば教えてください。

現状の環境下での課題としては、リグレッションテストの項目に優先順位をつけ、どの部分から自動化を進めるかを慎重に判断しています。全てのテストを自動化できるわけではないため、特に決済機能などのデータの準備が難しい部分は手動でのテストが依然として必要です。

また、自動化に関する知見や技術がまだ限られているため、特にPlaywrightの運用には経験が必要であり、チーム内のスキル向上も今後の課題です。自動化を進めることで、リグレッションテストにかかる時間を短縮し、新機能のテストにリソースを割くことができる理想的な体制を目指しています。今後は、ツールへのさらなる投資と技術のキャッチアップにより、テストカバレッジの向上と安定したリリースフローを確立していく計画です。

真面目な話と雑談がある環境ー緩急をつけて働きたい方へ

ーーどんな方に入社してほしいですか?

UPSIDERのQAチームでは、直近で入社する方に対して、いくつかの重要なミッションをお任せする予定です。

  • スクラムチームに帯同し、上流工程からの品質保証活動を行うこと
  • プロダクト全体に対するリグレッションテストの実施
  • ツールを活用したテスト自動化の推進

など、これらを通じて開発スピードと品質の両立を目指しており、開発チームの全てに専任のQAを配置する体制の実現が目標です。自走していける環境で裁量を持って働きたい方に、チームの品質保証活動をリードしていただきたいです。

  • 仕事の話と雑談、業務と学習(社内勉強会)など、緩急をつけて仕事したい方
  • 裁量をもって働きたい方
  • 改善や変更に対して柔軟に対応できる方

はUPSIDERにマッチしていると思います。

メンバーのバックグラウンドも多様なので、これまでの経歴や国籍に偏見がないメンバーが揃っている点もアピールポイントかもしれません。 ご興味をもっていただけた方はぜひカジュアル面談へのご応募をお待ちしています!

herp.careers

UPSIDER Engineering Deckはこちら📣

speakerdeck.com

メンバーの個人インタビューもぜひご覧ください!

note.com

note.com

ZodとOpenAPI Generatorで日付文字列をBrand型で扱う

UPSIDERでエンジニアをしている太田です。 (@Hide55832241)

この記事ではZodとOpenAPI Generatorを使用し、日付文字列をBrand型で扱う方法について紹介します。

TypeScriptを使用したフロントエンドのアプリケーション開発で日付をstring型で扱う際に困ることがあります。
日付をstring型として扱う際、APIから返却された未フォーマットの文字列と、UI用にフォーマットされた文字列を区別することが難しく、誤った表示や変換処理が発生しがちです。
またAPIリクエストでは正しいフォーマットで送信されているかどうかを保証できないため、不具合の原因になることがあります。

たとえばUIで表示する日付文字列が yyyy年MM月dd日 であった場合、この文字列をさらにフォーマットしようとすると想定外のエラーとなります。

// date-fnsのformatを想定
// APIレスポンスの日付文字列を想定
format(new Date('2024-01-20'), 'yyyy年MM月dd日') // Success

// 変換後の文字列を誤って指定
format(new Date('2024年01月20日'), 'yyyy年MM月dd日') // Error: Invalid Date

私たちが開発している 支払い.com でもこれらの課題を抱えており、Brand型を導入しました。
このプロダクトではOpenAPI Generatorを使用してコードを生成しています。そのため自動生成されるコードにBrand型をマッピングする必要がありました。
またFormやURLパラメータの検証にはZodを使用しています。Brand型をZodと組み合わせることでより扱いやすくなりました。

今回の記事では 支払い.com でBrand型を導入した知見を共有します。
大きくライブラリに依存した内容になりますが参考になれば幸いです。

設計

APIレスポンスの日付文字列を表示

// 表示コンポーネントではDate型で受け取ることで、フォーマットが必須であることを明確にする
const DateField = ({ value }: { value: Date }) => {
  return <p>{format(value, 'yyyy年MM月dd日')}</p> // formatはdate-fns想定
}

type ApiDateString = string & { __brand: 'ApiDateString' }

type Order = {
  // APIレスポンスの日付はBrand型で定義する
  shipmentDate: ApiDateString
}

const ShipmentDate = ({ shipmentDate }: { shipmentDate: Order['shipmentDate'] }) => {
  return <DateField value={new Date(shipmentDate)} />
}

ユーザーによる日付入力の値をAPIリクエストパラメータとして使用する

※ 以下の例ではuseStateを使用したサンプルですが、支払い.comではReact Hook Formを使用しています

const InputDate = (props: {
  value: string,
  onChange: (v: string) => void
}) => { /* 実装は省略 */ }

type ApiDateString = string & { __brand: 'ApiDateString' }

// APIリクエストパラメータの日付はBrand型で定義する
type PostParams = { shipmentDate: ApiDateString }

const postDate = (args: PostParams) => { /* 実装は省略 */}

const Form = () => {
  const [formData, setFormData] = useState<{ shipmentDate: string }>({ shipmentDate: '' })

  const handleSubmit = () => {
    // バリデーション
    if (!formData.shipmentDate) {
      return
    }
    postDate({
      ...formData,
      date: format(formData.shipmentDate, 'yyyy-MM-dd') as ApiDateString
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <InputDate
        value={formData.shipmentDate}
        onChange={
          (v) => setFormData(
            prev => ({ ...prev, shipmentDate: v })
          )
        }
      />
    </form>
  )
}

これらの設計のとおりBrand型を使用することで安全に日付文字列を扱えるようになりました。
実際にプロジェクトの導入する例を紹介します。

Zodを使用する

Brand型のZodスキーマを定義し、スキーマから型を得る

const API_DATE_STRING = z.string().brand('ApiDateString')
type ApiDateString = z.infer<typeof API_DATE_STRING> // string & z.BRAND<"ApiDateString">

// あとはZodを使用しない場合と同様に使用できる
type Order = {
  shipmentDate: ApiDateString
}

type PostParams = { date: ApiDateString }

Zodスキーマから得た型を使用しBrand型が定義できることはわかりましたが、まだ以下の問題があります。

  • ユーザー入力値の検証の処理を曖昧にしている
  • APIリクエストのパラメータを渡す際に、日付文字列の項目の詰替えが必要になっている
  • asを使用しBrand型にキャストしている

これらについてZodを使用し解決していきます。

ユーザー入力値の検証、型と値の変換

const FORM_SCHEMA = z.object({
  date:
    z
      .string() // 未入力状態など考慮し、入力はstringで受け付ける必要がある
      .refine(v => /* 文字列が日付として正しいか検証 */)
      .transform(v => format(v, 'yyyy-MM-dd')) // APIで扱う日付フォーマットに変換
      .pipe<API_DATE_SCHEMA> // 検証が完了し、フォーマットされた日付文字列はApiDateStringとして扱う
})

定義したスキーマを使用して検証し、後続処理で検証後の値を使用する。

  const handleSubmit = () => {
    // バリデーション
    const res = FORM_SCHEMA.safeParse(formData)
    if (!res.success) {
      return
    }
    postDate(res.data) // 検証後の日付文字列はApiDateString型となっているため、詰め替えが不要になった
  }

asの代わりにpipeを使うようにしただけではないかと言われるとそのとおりだと思いますが、検証、型と値の変換の定義を一箇所で行うことができ、シンプルで扱いやすくなったと感じていますがどうでしょうか?

transformでApiDateStringのフォーマットに変換すべきか

むずかしいところ
多くのユースケースでsubmitした値を使用し、APIリクエスト、その結果を使用してなんらかの後続処理を行うことを考えると有りだと思う。
しかしsubmitした値をQuery文字列へpushし、Query文字列から取得した値を使用しAPIリクエストを行うなど必ずしもApiDateStringへ変換すべきではないと思う。

FORM_SCHEMAから得られる型

検証前の値はstring型、検証後の値はApiDateString型として扱うことができる。
検証前は任意の値 (デフォルト値は空文字) を扱う必要があるのでstring型であることが望ましい。

  type Input = z.input<typeof FORM_SCHEMA> // string
  type Output = z.output<typeof FORM_SCHEMA> // string & z.BRAND<"ApiDateString">

OpenAPI Generatorで日付文字列をBrand型にマッピングする

APIスキーマからAPIのリクエストとレスポンスの型を自動生成することがあります。
OpenAPI Generatorを使用し、日付文字列をBrand型にマッピングする方法について説明します。

設定ファイル

typeMappings:
  date: ApiDateString

APIスキーマファイル

Order:
  type: object
  required:
    - shipmentDate
  properties:
    shipmentDate:
      type: string
      format: date

これで以下の型を生成できるようになります。

type Order = {
  shipmentDate: ApiDateString
}

ただし生成したファイルからApiDateStringをimportしていないため、エラーとなってしまいます。 生成したコードに追記するなどなんらかの方法でApiDateStringをimportするか、globalに定義することで解決できます。

今回はglobalに定義する方法を紹介します。

global.d.ts

// @/lib/zod/brandにApiDateStringを定義したものとする
declare type ApiDateString = import('@/lib/zod/brand').ApiDateString

また日時は以下の設定でコード生成できます。

設定ファイル

typeMappings:
  DateTime: UnixtimeNumber

APIスキーマファイル

Order:
  type: object
  required:
    - createdAt
  properties:
    createdAt:
      type: string
      format: date-time

生成されるコード

type Order = {
  createdAt: UnixtimeNumber
}

これでOpenAPI Generatorを使用したプロジェクトで日付文字列をBrand型で扱うことができるようになりました。

あとがき

日付の扱いは一見単純に思えても、プロジェクトが大きくなるにつれて複雑さが増していく部分です。
特にAPIとのやり取りやUIでのフォーマットなど、少しのミスで意図しない不具合を引き起こすことも少なくありません。
今回紹介したBrand型やZodやOpenAPI Generatorのような素晴らしいライブラリに助けられました。

もしこの記事が少しでも参考になれば幸いです。
フィードバックがあればお待ちしております。

Reactで絞り込みFormの作り方

UPSIDERでエンジニアをしている太田です。 (@Hide55832241)

この記事では、Reactを使用した絞り込みフォームの作り方をご紹介します。

Formはアプリケーションのコア機能の1つとなりえる一方、検証やQuery Stringとの連携など複雑な要素が多く、実装者によって大きな差が生じやすい部分であると感じています。

今回は絞り込みFormの作り方を例に、FormとQuery Stringの連携方法や、ユーザーが自由に入力するFormとQuery Stringの値の扱い方について1つの実装方法を紹介します。

前提

記事中で紹介するコードは以下のツールの使用を想定していますが、SPAであれば他のツールでも活用できると考えています。

以下のライブラリを想定したコードも一部使用しますが、コードを簡潔に記述するものであり主旨とは関係ありません。

伝えたいこと

  • Form、Query Stringでユーザーによって指定される値の扱い方
  • FormとQuery Stringを併用する際の実装方法

作るもの

以下の画像のような絞り込みFormと一覧部分で構成される画面を作成します。
絞り込み条件やページネーションはQuery Stringと連動します。
取引日はFrom、Toともに必須項目とします。

ユースケース

以下の2つのシナリオを想定します。

Formに絞り込み条件を入力して絞り込みボタンを押す

  • Formの値を検証する
    • 不正な場合、エラーを表示する
  • 検証が成功した場合、Query Stringへ反映する
  • Query Stringに反映されたForm入力値をパラメータとして、一覧取得APIを呼び出して一覧を表示する

URLを直接開いた場合

  • Query Stringを検証する
    • 不正な場合、Query Stringをあらかじめ決めたデフォルト値で置き換える
  • 検証が成功した場合
    • Query Stringの内容をFormに反映する
  • Query Stringをパラメータとして一覧取得APIを呼び出して一覧を表示する

今回は上記仕様としますがURLを直接開いた場合、APIを呼ばずにFormへセットするだけにするなど要件に合わせて変更できます。

処理の流れ

Form

以下を考慮する必要があります

  • 入力コンポーネントの型に依存した入力値を扱えること
  • デフォルト値を扱えること
  • 入力値の検証が行えること
  • 検証後、後続処理で扱いやすい任意の型で扱うことができること

入力を想定した型

スキーマから生成するのでこの型は実際には定義しません

type FormInput = {
  transfer_date_from: Date | null
  transfer_date_to: Date | null
  amount_from: string
  amount_to: string
}

日付入力コンポーネントは制御コンポーネントDate | null を扱い、数値入力コンポーネントは非制御コンポーネントstring を扱うことを想定します。

必須項目である取引日は最終的にはnullは許容しませんが、デフォルト値やユーザーによる未入力状態への変更を考慮する必要があるため nullable を指定します。

任意項目である金額はoptionalを付与する必要がありますが、必ずdefaultValuesを指定する設計にすることで、未設定 = 空文字となるためoptionalを付与する必要はありません。

出力を想定した型

スキーマから生成するのでこの型は実際には定義しません

type FormOutput = {
  transfer_date_from: string
  transfer_date_to: string
  amount_from?: number
  amount_to?: number
}

日付項目は YYYY-MM-DD などの string、数値項目は number で扱うことを想定します。

また、必須項目と任意項目をそれぞれ扱えるようにします。

入力と出力を考慮したスキーマを定義する

import { z } from 'zod'

const FORM_SCHEMA = z.object({
  transfer_date_from: z
    .date()
    .nullable()
    .refine((v) => v !== null)
    .pipe(z.date().transform((v) => v.toLocaleDateString())),
  transfer_date_to: z
    .date()
    .nullable()
    .refine((v) => v !== null)
    .pipe(z.date().transform((v) => v.toLocaleDateString())),
  amount_from: z
    .string()
    .refine((v) => v === '' || !isNaN(Number(v)))
    .transform((v) => (v === '' ? undefined : Number(v)))
    .pipe(z.optional(z.number())),
  amount_to: z
    .string()
    .refine((v) => v === '' || !isNaN(Number(v)))
    .transform((v) => (v === '' ? undefined : Number(v)))
    .pipe(z.optional(z.number())),
})
import { z } from 'zod'

z.input<typeof FORM_SCHEMA>
// {
//   transfer_date_from: Date | null;
//   transfer_date_to: Date | null;
//   amount_from: string;
//   amount_to: string;
// }

z.output<typeof FORM_SCHEMA>
// {
//   transfer_date_from: string;
//   transfer_date_to: string;
//   amount_from?: number;
//   amount_to?: number;
// }

先述した入力を想定した型、出力を想定した型を考慮してスキーマを定義します。 ZodEffects (transformなど) が発生するスキーマは検証前の入力値と検証後の出力値で得られる型が異なります。 z.inputを使用することで入力値の型、z.outputを使用することで出力値の型を得られます。 これによりstring型で入力された値を、検証後はnumber型で扱うようなことが可能になります。

またpipeを使用することで stringで受け付けた値をnumberとして検証することが可能になります。

z.string().positive() // Property 'positive' does not exist on type 'ZodString'
z.string().pipe(z.number().positive()) // OK

defaultValues

上記スキーマの説明においても、必ずdefaultValuesを指定する設計について書きましたが、React Hook FormのuseFormを使用してFormコンポーネントを作成する場合、defaultValuesを必ず設定することをおすすめします。

非制御コンポーネントを使用した場合に、UI上差が無いにもかかわらず、undefinedと空文字をそれぞれ扱わないといけなくなるなど、予想が難しいふるまいをスキーマで表現しないといけなくなります。

export const Form = ({
  defaultValues,
}: {
  defaultValues: z.input<typeof FORM_SCHEMA> // defaultValuesを必須とする
}) => {
  useForm<
    z.input<typeof FORM_SCHEMA>,
    unknown,
    z.output<typeof FORM_SCHEMA>
  >({
    defaultValues,
  })
}

また、defaultValuesとvaluesを併用することは避けることをおすすめします。

今回のサンプルからは外れますが、既存のデータを更新するFormについて考えます。

更新Form

defaultValuesに対し、valuesはリアクティブにFormへ反映されるため、既存データを取得してFormへ反映させる場合に扱いやすいです。

同一項目をもった新規作成Formと更新Formを共通化することも容易にできます。

しかし、以下の理由からvaluesを使用せずにdefaultValuesのみを使用することをおすすめします。

  • defaultValuesを使用してFormが描画される → ユーザーの入力を受け付ける → APIから既存データの取得が完了しFormに反映される → ユーザーが入力した情報が失われる

APIから必要なデータの取得が完了したあと(defaultValuesが確定したあと)、Formコンポーネントをマウントするとよいでしょう。

取得が完了するまでスケルトンを表示するなど、取得中のUIの工夫が必要です。

データ取得完了前にどうしてもFormを表示しておきたい場合は、ローディングなどを表示しユーザー操作を受け付けないようにした方が良いかもしれません。

export const BaseForm = ({
  defaultValues,
}: {
  defaultValues: z.input<typeof FORM_SCHEMA>
}) => {
  useForm<
    z.input<typeof FORM_SCHEMA>,
    unknown,
    z.output<typeof FORM_SCHEMA>
  >({
    defaultValues,
  })
  return <form>{...}</form>
}

const UpdateForm = () => {
  const { data } = useFetchData()
  
  // データ取得が完了してからマウントする
  return data ? <BaseForm defaultValues={{
    transfer_date_from: new Date(data.transfer_date_from),
    transfer_date_to: new Date(data.transfer_date_to)
    amount_from: `${data.amount_from}`,
    amount_to: `${data.amount_to}`,
  }} /> : <p>{'loading...'}</p>
}

話が脱線しましたので元に戻します。

Formの内容をQuery Stringへ反映する

Form Submitをトリガーに入力値を検証し、不正な場合はエラーメッセージを表示するなど適切な処理をします。

正常な場合は、Query Stringへ反映します。

処理の流れに記載した通り、この値を直接APIのパラメータとしては使用しません。

import { useRouter } from 'next/router'

export const Form = ({
  defaultValues,
}: {
  defaultValues: z.input<typeof FORM_SCHEMA> // defaultValuesを必須とする
}) => {
  const router = useRouter()
  const { handleSubmit } = useForm<
    z.input<typeof FORM_SCHEMA>,
    unknown,
    z.output<typeof FORM_SCHEMA>
  >({
    resolver: zodResolver(formSchema),
    defaultValues,
  })
  
  const onSubmit = (d: z.output<typeof FORM_SCHEMA>) => {
      router.push({ query: { ...d } })
  }
  
  return <form onSubmit={handleSubmit(onSubmit)}>{...}</form>
}

Query String

Formの値をQuery Stringに反映できたので、次はQuery Stringについて見ていきます。

以下を考慮する必要があります

  • Query Stringを扱うライブラリから取得できるQuery Stringの型 (next/router の場合、 useRouter().query の型 string | string[] | undefined ) に依存した入力値を扱えること
  • 値の検証を行えること
  • 検証後は後続処理で扱いやすい任意の型で扱うことができること
  • Query Stringの値をFormに反映できること

入力を想定した型

スキーマから生成するのでこの型は実際には定義しません

type QueryStringInput = {
  transfer_date_from: string | string[]
  transfer_date_to: string | string[]
  amount_from?: string | string[]
  amount_to?: string | string[]
}

すべての項目が string | string[] | undefined となりますが、要件が許す場合は必須項目のundefinedを許可しない方が扱いやすいと思います。

今回は必須項目の undefined を許可しない形とします。

出力を想定した型

スキーマから生成するのでこの型は実際には定義しません

type QueryStringOutput = {
  transfer_date_from: string
  transfer_date_to: string
  amount_from?: number
  amount_to?: number
}

日付項目は YYYY-MM-DD などの string、数値項目は number で扱うことを想定します。

また、必須項目と任意項目をそれぞれ扱えるようにします。

入力と出力を考慮したスキーマを定義する

import { z } from 'zod'

const isString = (v: unknown): v is string => {
  return typeof v === 'string'
}

const QUERY_STRING_SCHEMA = z.object({
  transfer_date_from: z
    .union([z.string().min(1), z.array(z.string())])
    .refine((v) => isString(v))
    .transform((v) => {
      // string型以外の場合、z.NEVERを返すことで後続処理ではstring型に絞り込まれる (z.NEVERを返す代わりにthrowしても同様の結果が得られる)
      if (!isString(v)) {
        return z.NEVER
      }
      return v
    }),
  transfer_date_to: z
    .union([z.string().min(1), z.array(z.string())])
    .refine((v) => isString(v))
    .transform((v) => {
      if (!isString(v)) {
        return z.NEVER
      }
      return v
    }),
  amount_from: z
    .optional(
      z
        .union([z.string(), z.array(z.string())])
        .refine((v) => isString(v))
        .transform((v) => {
          if (!isString(v)) {
            return z.NEVER
          }
          return v
        })
        .refine((v) => !isNaN(Number(v)))
        .transform((v) => (v === '' ? undefined : Number(v)))
    )
    .pipe(z.optional(z.number())),
  amount_to: z
    .optional(
      z
        .union([z.string(), z.array(z.string())])
        .refine((v) => isString(v))
        .transform((v) => {
          if (!isString(v)) {
            return z.NEVER
          }
          return v
        })
        .refine((v) => !isNaN(Number(v)))
        .transform((v) => (v === '' ? undefined : Number(v)))
    )
    .pipe(z.optional(z.number())),
})
z.input<typeof QUERY_STRING_SCHEMA>
// {
//   transfer_date_from: string | string[]
//   transfer_date_to: string | string[]
//   amount_from?: string | string[]
//   amount_to?: string | string[]
// }

z.output<typeof QUERY_STRING_SCHEMA>
// {
//   transfer_date_from: string
//   transfer_date_to: string
//   amount_from?: number
//   amount_to?: number
// }

Query Stringの変更

Query Stringが変更された場合(もしくは直接URLを開いた場合)、上記スキーマを使用して検証します。

検証

検証結果が不正な場合は、デフォルト値をセットすることで常に正しい値パラメータを使用できる状態にします。(この例ではQuery Stringの一部でも不正な場合、すべての項目に対してデフォルト値が設定されるため要件に合わせて変更してください。)

useEffect(() => {
  const res = QUERY_STRING_SCHEMA.safeParse({
    ...queryString,
  })

  if (!res.success) {
    return router.push({
      query: {
        transfer_date_from: format(startOfMonth(new Date), 'yyyy-MM-dd'),
        transfer_date_to: format(endOfMonth(new Date), 'yyyy-MM-dd'),
      }
    })
  }
}, [
  router,query, router.push
]

Query StringをFormに反映

Query StringをFormに反映します。

URLを直接開いた場合、Query StringをFormへ反映する必要があります。

Form Submitした場合も同じ処理が実行されますが、Formの検証を先に行っているためUI上変化はないはずです。(途中のformat等の処理によりForm内容が置換される可能性があるが、大きな問題はないと思っています。ただし、Form Submitせずに入力値をリアルタイムでQuery Stringに反映させていく場合は、この設計をそのまま使うことはできないかもしれません。)

const { reset } = useForm({})

useEffect(() => {
  const res = QUERY_STRING_SCHEMA.safeParse({
    ...queryString,
  })

  if (!res.success) {
    return router.push({
      query: {
        transfer_date_from: format(startOfMonth(new Date), 'yyyy-MM-dd'),
        transfer_date_to: format(endOfMonth(new Date), 'yyyy-MM-dd'),
      }
    })
  }
  
  reset({
    transfer_date_from: new Date(res.data.transfer_date_from),
    transfer_date_to: new Date(res.data.transfer_date_to),
    amount_from: res.data.amount_from === undefined ? '' : `${res.data.amount_from}`,
    amount_to: res.data.amount_from === undefined ? '' : `${res.data.amount_to}`,
  })
}, [
  router,query, router.push
]

検証後のパラメータを使用してAPIを呼び出し

import { useQuery, skipToken } from '@tanstack/react-query'
import { stringify } from 'qs'

const params = useMemo(() => {
  const res = QUERY_STRING_SCHEMA.safeParse({
    ...router.query,
  })

  if (!res.success) {
      return null
    }
    return res.data
}, [router.query])

const { data } = useQuery({
  queryKey: ['transactions', params],
  queryFn: params
    ? () => fetch(`https://api.example.com/transactions?${stringify(params)}`)
    : skipToken,
})

まとめ

以上の設計により、FormからSubmitされた場合もURLを直接開いた場合も、常に「URL → API呼び出し」の流れが確立され、処理の流れがわかりやすくなります。

指摘や他に良い設計があれば是非教えて下さい。

Go Conference 2024 にBronze スポンサーとして協賛 & slogについて登壇しました! #gocon

UPSIDERでエンジニアリングマネージャーをしているMiki (@m_miki0108) です!

2024年6月8日(土)に開催されたGo Conference 2024に登壇し、Bronzeスポンサーで協賛いたしました!

Go Conferenceとは?

Go Conferenceとは、年に一度開催されるGo言語に関するカンファレンスです。

今年は数年ぶりのオフライン開催で、キャパシティ400名のところキャンセル待ちが数百人という大人気かつ大規模なイベントになりました。 当日はセッションはもちろん、オフラインならではの交流やイベントブースなど盛り沢山な内容でした!

登壇した内容

「Custom logging with slog Making Logging Fun Again!」という内容で20分の英語セッションをいたしました。こちらがスライドです。

内容は、「Goの1.21から標準ライブラリとして導入されたslogのCustom Handlerを作ってログ出力をカスタムしてみよう!」というAll Levelのセッションです。 slogを触ったことのない人も楽しめるように以下のような内容でお話ししました。

  • slogの基本
  • slogのアーキテクチャ
  • シンプルなslogのCustom Handlerを作るデモ
  • Performance関連の考慮事項

デモに関して、Custom Handlerを作るライブコーディングをやりたかったのですが今回は20分のセッションということで時間的になかなか厳しそうでした。

なので実装をステップに分けてGitの差分を見てもらいながら理解してもらう方針でやってみました。結果として「初見の人にもわかりやすい内容になったかな?」と思っています。

また、今回のGo Conferenceは株式会社サイバーエージェントさんのAbema Towersを会場に使わせていただいたということで、サンプルコードにこそっとAbema.tvを忍び込ませてみました。

こういう遊び心を忘れないように日々仕事しています!

UPSIDERでの取り組み

当社ではGoで書かれたマイクロサービスがたくさん存在しています。

会社全体のGoのプロジェクトで共通的に使っているものも多いですが、各チームで持っているサービスの特性や非機能要件、作られた時期や開発者の方針によってはアーキテクチャや利用しているライブラリが違うこともよくあります。 ロギングについても同様で、現状slogを使っているプロジェクトもあれば、standard libraryのlogをカスタムして使っていたり、サードパーティのzapを使っているところもあります。

個人的にはGoのプロジェクト全体で統一・標準化して開発スピードを落とさずに、より調査しやすく必要十分なロギングが実現できるようにしたいと思っています。

slogの良いところは、標準ライブラリでありlogとの後方互換性も保たれていること、サードパーティのロギングpackageでも共通の処理(バックエンド)を利用することができることです。(セッションでお話ししたように、APIをログメッセージの必要な情報を収集するフロントエンドとそれらを処理するバックエンドに分けるような構成になっています。)

これらのメリットを踏まえつつ、全体でどういう形にするのが最適なのかはこれから模索していきたいところです!

We are hiring!

UPSIDERではGoのエンジニアの採用中です! より具体的な業務事例をNoteで発信しています。ぜひご覧ください! note.com

ご興味をもっていただいた方はぜひカジュアルにお話ししましょう! pitta.me herp.careers

UPSIDERのこれからを担うFlutterアプリのアーキテクチャ

こんにちは、UPSIDERで日々モバイルアプリ開発をしているふっくです。

UPSIDERでは今後、よりアプリ開発に注力し決済プラットフォームの中核的な役割を果たすことを目指しています。

今回は、今後の開発・運用を目指して考えたFlutterアプリ向けのアーキテクチャを紹介します。

ネイティブアプリの世界で触れてきた色々なアーキテクチャフレームワークを参考に、開発の後半でも順調にスケールさせることができるように、工夫を凝らしました。

アーキテクチャで作ったサンプルアプリもあるので、ぜひ以下のリンクから見てみてください。

https://github.com/upsidr/flutter_architecture_blueprint

デモはこちら

https://upsidr.github.io/flutter_architecture_blueprint/

対象読者

この記事は、Flutterアプリの設計に興味がある方を対象としています。また、以下の知識を持っている前提となっています。

目指すところ

サービスの成長に応じてアプリを破綻なくスケールさせるためには、適切なレイヤー分割とその疎結合化が必要になります。

アーキテクチャは、

  • Presentation / Domain / Data の3層レイヤーを採用する
  • View と ViewModel (このブログでは以降Notifierと呼びます) の責務を明確に分離する
  • Notifierの内部では、StateとActionなどを用いて整理し、アプリの振る舞いを予測可能なものにする

の3つを前提に設計を行いました。

持続可能な形で安全にアプリ開発を進めることができるよう、テストコードフレンドリーになるよう設計しました。とくに、Presentation層のテストがしやすい形を目指しました。

参考にしたもの

全体的な作りは、モバイルアプリで流行りの実装手法を参考にしました。

Now in Android

Now in Androidは、Google Androidチームによる開発者向けの技術記事のシリーズ名です。同タイトルで、Android開発におけるベストプラクティスを示すリファレンスアプリが公開されています。

https://developer.android.com/series/now-in-android?hl=ja

関心事で分離するモジュール

関心事で分離するモジュール構成は広く知られています。Flutterにおいてもディレクトリの切り方として扱いやすいため、参考としました。

https://github.com/android/nowinandroid/blob/main/docs/ModularizationLearningJourney.md

モックライブラリに深く依存しないテストダブル

DIを活用してテストダブルにFakeを使用することで、モックライブラリに深い依存をせずにテストコードを記述することができます。これにより、テストコードがシンプルになり、忠実度の制御が容易になります。

https://github.com/android/nowinandroid?tab=readme-ov-file#testing

    class TestUserDataRepository : UserDataRepository {
        /**
         * The backing hot flow for the list of followed topic ids for testing.
         */
        private val _userData = MutableSharedFlow<UserData>(replay = 1, onBufferOverflow = DROP_OLDEST)
     ...
         /**
         * A test-only API to allow setting of user data directly.
         */
        fun setUserData(userData: UserData) {
            _userData.tryEmit(userData)
        }
    }

The Composable Architecture

The Composable Architecture (TCA)は、Point-Freeチームが開発しているiOS向けのアーキテクチャフレームワークです。

https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/

UIの抽象化

TCA はUIを State / Action で抽象化しています。他の宣言型UIの世界でも見られる手法であり、単方向データフローを構築しやすい・テスト容易性が向上するなどのメリットがあります。

@Reducer
struct Feature {
  struct State { /* ... */ }
  enum Action { /* ... */ }
  var body: some ReducerOf<Self> {
    // ...
  }
}

swift-dependencies

TCA では swift-dependencies というDIライブラリを使っています。このライブラリでは、メソッドをクロージャープロパティで定義し、本番用・テスト用などと実装を差し替え可能にする手法が紹介されています。

https://swiftpackageindex.com/pointfreeco/swift-dependencies/main/documentation/dependencies/designingdependencies#Struct-based-dependencies

struct AudioPlayerClient {
  var loop: (_ url: URL) async throws -> Void
  var play: (_ url: URL) async throws -> Void
  var setVolume: (_ volume: Float) async -> Void
  var stop: () async -> Void
}

ライブラリ選定

アーキテクチャに関わるパッケージを以下のように選定しました。

目的 パッケージ
状態管理 hooks_riverpod, rxdart
モデル freezed
ルーティング auto_route

状態管理

3層レイヤーを採用するため、レイヤー間の繋ぎ込み(DI)に riverpod を使いました。

rxdartは、主にBehaviorSubjectを使ってData層から Presentation層 までのデータの伝搬に使用します。

モデル

Dart3では、代数的データ型(直和型・直積型)を言語レベルで扱えるようになりました。

https://dart.dev/language/patterns#algebraic-data-types

これに freezed を組み合わせると、より簡単に記述することができるようになるので採用しました。

https://pub.dev/packages/freezed#legacy-union-types-and-sealed-classes

ルーティング

go_router / auto_route から、書き心地の好みで auto_route を採用しました。

アーキテクチャ解説

アーキテクチャにおいて特徴的なPresentation層に焦点を絞って解説をします。

Domain・Data層の詳細はサンプルコードをご参照ください。

https://github.com/upsidr/flutter_architecture_blueprint

Presentation層

アーキテクチャのデータフローは以下のようになります。

アーキテクチャーのデータフロー

図のように、UIState / Action / Effect を使って View・Notifier間の単方向データフローを実現します。

■ Contract

ここでは、TCAのUI抽象化 を参考に画面が取りうる全ての状態を抽象化して記述し、クラスとして定義します。

  • UIState - 画面の表示要素
  • Action - UIの操作イベント
  • Effect - 画面遷移などの画面イベント

この3要素をまとめて Contract という名前で扱います。

TCAは State と Action の2つを定義しますが、本アーキテクチャでは State を UIState と Effect に分割しています。

これは、SwiftUIがダイアログ表示や画面遷移を1つの State として扱えるのに対し、FlutterではUIの宣言とは別に手続き的に表示・遷移処理が必要であり、Effect が独立している方が扱いやすいためです。

サンプルのタスク一覧画面の Contract がこちら

abstract class BaseContract<UiState, Action, Effect> {
  void consume();
  Future<void> send(Action action);
}

typedef TaskListContract
    = BaseContract<TaskListUiState, TaskListAction, TaskListEffect>;

// 画面の表示要素
@freezed
class TaskListUiState with _$TaskListUiState {
  const factory TaskListUiState({
    @Default([]) List<EditableUserTask> taskList,
  }) = _TaskListUiState;
}
//  UIの操作を定義
@freezed
sealed class TaskListAction with _$TaskListAction {
  const factory TaskListAction.onAppear() = OnAppear;
  const factory TaskListAction.newTaskButtonTapped() = NewTaskButtonTapped;
  const factory TaskListAction.taskTapped(EditableUserTask task) = TaskTapped;
  const factory TaskListAction.toggleIsCompleted(EditableUserTask task) =
      ToggleIsCompleted;
  const factory TaskListAction.onTaskSwiped(EditableUserTask task) =
      OnTaskSwiped;
}
// 画面遷移などの画面イベントを定義
@freezed
sealed class TaskListEffect with _$TaskListEffect {
  const factory TaskListEffect.none() = None;
  const factory TaskListEffect.goDetail({
    required EditableUserTask? task,
  }) = GoDetail;
  const factory TaskListEffect.showAlert({
    required AlertState state,
  }) = ShowAlert;
}

■ Notifier

ここでは

  • UIState・Effect の管理
  • Action のハンドリング

を行います。

Notifier は AutoDisposeNotifierProvider で実装し、UIState を state として扱います。

Effect は別途 StateProvider を作って管理します。

サンプルのタスク一覧画面の Notifier はこちら

(riverpod_generator による自動生成)

final taskListEffectProvider =
    StateProvider((ref) => const TaskListEffect.none());

@riverpod
class TaskListNotifier extends _$TaskListNotifier implements TaskListContract {
  late final StreamSubscription _taskListSubscription;
...
  @override
  TaskListUiState build() {
    _taskListSubscription = _taskListUseCase()
        .listen((list) => state = state.copyWith(taskList: list));
    ref.onDispose(_taskListSubscription.cancel);
    return const TaskListUiState();
  }

  @override
  void consume() {
    _updateEffect(const TaskListEffect.none());
  }

  @override
  Future<void> send(TaskListAction action) async {
    switch (action) {
        case NewTaskButtonTapped():
        _updateEffect(const TaskListEffect.goDetail(task: null));
            case ...:
              state = state.copyWith(...)
    }
  }

  _updateEffect(TaskListEffect effect) =>
      ref.read(taskListEffectProvider.notifier).update((state) => effect);
}

■ View

ここでは

  • UIState の表示
  • Effect のハンドリング
  • Action の送信

を行います。

Extensionプロパティを定義して、 ref.notifier.send() の形で Action を送信できるようにしています。

Effect は ref.listen で購読し、画面イベントを副作用として処理します。

サンプルのタスク一覧画面の View はこちら

extension _TaskListEx on WidgetRef {
  TaskListNotifier get notifier => read(taskListNotifierProvider.notifier);
}

@RoutePage()
class TaskListPage extends HookConsumerWidget with AlertStateCompatible {
  const TaskListPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    ref.listen(taskListEffectProvider,
        (_, effect) => handleEffect(context, ref, effect: effect));
    ...
    return Scaffold(
      appBar: AppBar(
        title: const Text('ToDo'),
        centerTitle: true,
      ),
      body: const _TaskListBody(),
      floatingActionButton: FloatingActionButton(
        onPressed: () =>
            ref.notifier.send(const TaskListAction.newTaskButtonTapped()),
        child: const Icon(Icons.add),
      ),
    );
  }

  Future<void> handleEffect(
    BuildContext context,
    WidgetRef ref, {
    required TaskListEffect effect,
  }) async {
    switch (effect) {
      case None():
        break;
      case GoDetail():
        ref.notifier.consume();
        context.router.push(EditTaskRoute(task: effect.task));
      case ShowAlert():
        ref.notifier.consume();
        handleAlertState(context, effect.state);
    }
  }
}

class _TaskListBody extends HookConsumerWidget {
  const _TaskListBody();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final taskListCompleted = ref.watch(
      taskListNotifierProvider.select(
          (value) => value.taskList.where((t) => t.isCompleted)),
    );
    ...

    return CustomScrollView(
      slivers: [
        ...
        SliverPadding(
          padding: const EdgeInsets.symmetric(vertical: 16),
          sliver: SliverList(
            delegate: SliverChildListDelegate(
                buildList(ref, taskList: taskListCompleted)),
          ),
        ),
      ],
    );
  }
...
}

ディレクトリ構成

Now in Andoirdをベースとしています。

ディレクトリ構成

├── app_router.dart
├── app_router.gr.dart
├── core
│   ├── data
│   │   ├── network
│   │   │   └── fake_todo_api_client.dart 👈 サンプルでは実際の通信はナシ
│   │   └── repository
│   │       └── todo
│   │           ├── fake_todo_repository.dart
│   │           ├── fake_todo_repository.freezed.dart
│   │           ├── todo_repository.dart
│   │           └── todo_repository.freezed.dart
│   ├── domain
│   │   ├── model
│   │   │   ├── editable_user_task.dart
│   │   │   └── editable_user_task.freezed.dart
│   │   └── todo
│   │       ├── edit_task_usecase.dart
│   │       ├── edit_task_usecase.freezed.dart
│   │       ├── task_list_usecase.dart
│   │       └── task_list_usecase.freezed.dart
│   ├── model
│   │   ├── user_task.dart
│   │   ├── user_task.freezed.dart
│   │   └── user_task.g.dart
│   └── util
│       ├── alert_state.dart
│       ├── alert_state.freezed.dart
│       ├── base_contract.dart
│       ├── datetime_formatted.dart
│       └── stream_extensions.dart
├── feature
│   └── todo
│       ├── edit_task
│       │   ├── edit_task_contract.dart
│       │   ├── edit_task_contract.freezed.dart
│       │   ├── edit_task_notifier.dart
│       │   ├── edit_task_notifier.g.dart
│       │   ├── edit_task_page.dart
│       │   └── ui_components
│       │       ├── task_text_field.dart
│       │       └── toggle_complete_button.dart
│       └── task_list
│           ├── task_list_contract.dart
│           ├── task_list_contract.freezed.dart
│           ├── task_list_notifier.dart
│           ├── task_list_notifier.g.dart
│           ├── task_list_page.dart
│           └── ui_components
│               └── task_list_item.dart
│               └── task_list_placeholder.dart
├── main.dart
└── main_device_preview.dart

feature 直下のディレクトリは関心事の単位で切ります。タスク一覧画面のファイルの場合は以下の箇所になります。

👉 lib/feature/todo/task_list

── task_list
   ├── task_list_contract.dart
   ├── task_list_contract.freezed.dart
   ├── task_list_notifier.dart
   ├── task_list_notifier.g.dart
   ├── task_list_page.dart
   └── ui_components
       ├── task_list_item.dart
       └── task_list_placeholder.dart

テストについて

アーキテクチャは、Presentation層のテストに重きを置いています。

画面の実装では、Contract のモデリングを真っ先に行うことで Notifier をテスト駆動開発(TDD)で実装することが可能になります。

基本的に Notifier のユニットテストは以下の流れで実装します。

  1. notifier.sendメソッドで Action を送信
  2. Notifierが処理した結果の UIState / Effect を評価する

サンプルのタスク一覧画面の Notifier 「タスク作成ボタンをタップしてタスク編集画面に遷移する」のテストがこちら

group('Navigation', () {
  test('Tap NewTaskButton, navigate detail', () async {
    final (notifier, uiState, effect) = buildAccessors();
    
    // 👇 Data層のFakeRepositoryの挙動を変更
    todoRepository.handler.fetchTaskList = () async =>
        fakeTodoState.update((value) => value.copyWith(taskList: []));

    await notifier.send(const TaskListAction.onAppear());
    expect(uiState().taskList.isEmpty, true);

    await notifier.send(const TaskListAction.newTaskButtonTapped());
    expect(
      effect(),
      const TaskListEffect.goDetail(task: null),
    );
  });
  ...

setUp にてFakeをDIしています。

void main() {
  ...
  buildAccessors() {
    final subscription = container.listen(taskListNotifierProvider, (_, __) {});
    addTearDown(subscription.close);
    return (
      container.read(taskListNotifierProvider.notifier),
      () => container.read(taskListNotifierProvider),
      () => container.read(taskListEffectProvider),
    );
  }
  ...
  setUp(() {
    fakeTodoState = BehaviorSubject.seeded(const FakeTodoRepositoryState());
    todoRepository = FakeTodoRepository.from(fakeTodoState);
    container = ProviderContainer(overrides: [
      todoRepositoryProvider.overrideWithValue(todoRepository), // 👈 ココでDI
    ]);
    ...
  });

  tearDown(() => container.dispose());
...

FakeTodoRepositoryの実装

swift-dependencies DIのアイデアを使っています 👍

  class FakeTodoRepository implements TodoRepository {
    const FakeTodoRepository._(this.fakeState, this.handler);

    factory FakeTodoRepository.from(
        BehaviorSubject<FakeTodoRepositoryState> fakeState) {
      return FakeTodoRepository._(
          fakeState, FakeTodoRepositoryHandler(fakeState));
    }

    final BehaviorSubject<FakeTodoRepositoryState> fakeState;
    final FakeTodoRepositoryHandler handler;

    @override
    Stream<List<UserTask>> get taskList =>
        fakeState.map((state) => state.taskList);

    @override
    Future<void> fetchTaskList() async {
      await handler.fetchTaskList();
    }

    @override
    Future<void> addTask({required UserTask task}) async {
      await handler.addTask(task);
    }

    @override
    Future<void> updateTask({required UserTask task}) async {
      await handler.updateTask(task);
    }

    @override
    Future<void> removeTask({required String id}) async {
      await handler.removeTask(id);
    }
  }
    
  class FakeTodoRepositoryHandler {
    FakeTodoRepositoryHandler(this.fakeState);
    
    final BehaviorSubject<FakeTodoRepositoryState> fakeState;
    
    late AsyncCallback fetchTaskList = () async {};
    late AsyncValueSetter<UserTask> addTask = (task) async {
      fakeState.update(
          (value) => value.copyWith(taskList: value.taskList.appending(task)));
    };
    late AsyncValueSetter<UserTask> updateTask = (task) async {
      fakeState.update(
          (value) => value.copyWith(taskList: value.taskList.replaced(task)));
    };
    late AsyncValueSetter<String> removeTask = (taskId) async {
      fakeState.update(
          (value) => value.copyWith(taskList: value.taskList.deletedBy(taskId)));
    };
  }
    
  @freezed
  class FakeTodoRepositoryState with _$FakeTodoRepositoryState {
    const factory FakeTodoRepositoryState({
      @Default([]) List<UserTask> taskList,
    }) = _FakeTodoRepositoryState;
  }

今後に向けて

Flutter Web + device_preview の体験

今回のサンプルは device_preview を使って Flutter Web で公開してみました。

👉 https://upsidr.github.io/flutter_architecture_blueprint/

https://pub.dev/packages/device_preview

device_preview を Flutter Web と組み合わせることで、

  • アプリ外観の確認が容易になる
  • 誰でもすぐに成果物を閲覧できる

などの利点を強く感じました。

公開範囲の制限など考えないといけないこともありますが、開発フローの要所要所で活かしていきたいと思いました 👍

実装方針を決めてみた感想

プロダクトで実際に運用することになれば、色々な課題が出てくるでしょう。

ボイラープレートの自動生成をしてみる、状態の変更に対する網羅的なテストを実装できるようにするなど、チームのみんなと一緒に改良を続けていければと思います。

最後に

今回は運用検討中のFlutterアーキテクチャの紹介でした。

UPSIDERでは、「挑戦者を支える世界的な金融プラットフォームを創る」をミッションに世界中の挑戦者を支えるチャレンジをしています。UPSIDERが提供する法人カード「UPSIDER」のアプリは多くのユーザーにご活用いただいており、しつ高い顧客体験を提供すべき、日々開発に奮闘しております。より利便性の高いアプリ開発を通じて、ユーザー体験を向上させ、一緒に挑戦者を支えるチャレンジをしませんか?

ご興味を持っていただけたら、ぜひカジュアル面談でお話ししましょう!

herp.careers

余談

サンプルアプリを公開するにあたって、リポジトリ名をどうするかで少し議論になりました。

flutter_architecture_blueprintflutter-architecture-blurprint

結論、チームによっていろんな判断がありそうでした。