2026/07/02

Google AntigravityにClaude Codeを入れてトークン分散開発

Google AntigravityにClaude Codeを入れて、トークンを2社に分散しながら開発している

週の真ん中でGeminiのクォータが尽きて、AIエージェントが一切動かなくなったことがある。締め切り前だったので正直かなり焦った。それ以来、自分はGoogle Antigravityの中にClaude Codeを同居させて、トークン消費をGoogleとAnthropicの2社に分散させる運用にしている。導入自体は拡張機能とnpmコマンド1本で終わる。その手順と、どうタスクを振り分けているか、また詰まったところの話。

Antigravityだけで回していた頃

Antigravityは2025年11月にGoogleが出したエージェント開発環境で、公開当初は無料プレビューながらGemini 3 Proがかなり太いクォータで使えた。エディタ自体はVS Codeがベースになっていて、Agent Managerで複数のエージェントを並列に走らせられるのが特徴。自分もリリース直後に乗り換えて、調査からコード生成まで全部Antigravityに寄せていた時期がある。

ただ、無料で気前がいい状態は長く続かなかった。2026年に入ってから無料枠やProプランのクォータが大きく絞られた時期があって、Googleの公式フォーラムでも「5時間でリセットされるはずの上限が数日戻らない」という報告をいくつも見かけた。自分の環境でも、週の半ばで上限に到達してそのまま数日エージェントが使えなくなったことが実際にあった。その後2026年5月にGoogleがAntigravityのGeminiモデルの使用上限を2回にわたって3倍に引き上げたので今はだいぶマシになったんだけど、「1社のクォータに全部乗せるのは危ない」というのがこのとき骨身に染みた。

AntigravityにClaude Codeを入れる

で、保険としてClaude Codeを同じエディタに入れることにした。AntigravityはVS Codeベースなので、拡張機能がそのまま使える。手順は素直で、左のサイドバーから拡張機能ビューを開いて「Claude Code」で検索し、「Claude Code for VS Code」をインストールするだけ。

拡張機能を入れる前提として、Claude Code本体(CLI)が必要になる。ターミナルからnpmで入れておく。

# Claude Code CLI をグローバルインストール
npm install -g @anthropic-ai/claude-code

# バージョン確認(インストール成否のチェック)
claude --version

# 初回起動。ブラウザが開いて Anthropic アカウントの認証が走る
claude

初回起動時にAnthropicアカウントでのログインを求められる。自分はClaude Proのサブスクリプションで使っているので、APIキーではなくサブスク認証を選んだ。ここで課金体系が分かれていて、サブスク認証ならPro/Maxプランの利用枠内で使い放題(レート上限あり)、APIキー認証なら従量課金になる。毎日使うならサブスク一択だと思う。

拡張とCLIの関係

ちょっと分かりにくいのが、拡張機能は本体ではなくUI統合レイヤーだという点。実体はターミナルで動くCLIで、拡張機能はそれをエディタのパネルに埋め込んで、開いているファイルの文脈を渡したり差分をエディタ上に表示したりしてくれる。なので極端な話、拡張機能を入れなくてもAntigravityの統合ターミナルで claude と打てば普通に動く。

どっちに何をやらせるか

2つのエージェントが1つのエディタに住んでいる状態になったので、タスクの振り分けを決めた。いま自分はこんな分担で使っている。

タスク 担当 理由
技術調査・比較検討 Antigravity(Gemini) エージェントを並列に走らせて調査させるのが得意
実装・リファクタリング Claude Code 既存コードの読解と差分編集の精度が高い
使い捨てスクリプト生成 Antigravity(Gemini) 精度要求が低いのでGemini枠を消費させる
デバッグ・原因調査 Claude Code コマンド実行と仮説検証のループが速い

要は、精度が要る作業かどうかでトークンの使い先を変えている。調査系や使い捨てコードはGemini側の枠で回して、コードベースに手を入れる作業はClaude側に寄せる。こうするとどちらか一方のクォータだけが突出して減ることがなくなって、週の後半に枯渇するリスクが下がる。Anthropic側も2026年5月にPro/Maxプランの5時間レート上限を2倍に引き上げたので、実装作業を寄せてもそう簡単には詰まらなくなった。

あと地味に効くのが、上限のプールが完全に独立していること。Claude Codeは5時間ごとのレート上限と週次上限の二段構えで、AntigravityのGemini枠も5時間のローリング枠と週次ベースラインの組み合わせ。仕組み自体は似ているんだけど、消費のカウントは当然別々なので、片方が上限に当たってももう片方は生きている、という状態を作りやすい。

クォータが尽きたときに実際やったこと

導入して終わりではなくて、一度ちゃんと詰まったのでその話も書いておく。ある日Antigravity側で Model quota exceeded 系のエラーが出てエージェントが止まった。表示上はリセットまでの時間が出ていたのに、その時間を過ぎても復活しない。調べたら同様の報告がGoogleのフォーラムに複数上がっていて、クォータ管理側の不具合で複数日ロックされるケースがあるらしかった。

このとき取った対処は3つ。まず走らせていた調査タスクをClaude Code側に移した。次にAntigravity側は、使えるうちからGemini 3 Proではなく軽量なFlash系モデルに落として、クォータ消費のペース自体を下げるようにした。あとは、ロックが解けない間の作業をClaude Codeのプランモードで設計だけ進めておいて、実装を後から流すようにしたくらい。2社分散にしていたおかげで、開発自体は止まらずに済んだ。逆に言うと、分散していなかった頃のクォータ切れは本当にただの休業日だった。

わざわざ2社に課金する意味はあるのか

この運用の欠点ははっきりしていて、Google AIのプランとClaude Proの二重課金になること。月あたりのコストは普通に増える。1社に絞ってその分上位プランに上げた方が、単純なトークン量では得になる可能性も全然ある。

それでも自分が分散を選んでいるのは、クォータ切れ・障害・突然のプラン改定みたいな「片方が使えなくなるリスク」への保険としての価値が大きいから。2026年前半のクォータ騒動を経験した身としては、AIエージェントが開発フローの中核に入り込んでいる状態で供給元が1社というのは、単一障害点そのものなんだよね。モデルの得意分野が違うのでタスクごとに使い分けられるという実利もあるし、少なくとも自分の使い方では二重課金分の元は取れていると思ってる。

APIの従量課金で好きなモデルを都度呼ぶという代替案も検討したけど、エージェント用途はトークン消費が読めないので、上限が事前に分かるサブスクの方が精神衛生上よかった。ここは開発スタイル次第かもしれない。

いまの落としどころ

AntigravityにClaude Codeを入れる作業自体は、拡張機能の検索とnpmコマンド1本で終わる。難しいのは導入ではなくて、その後の「どっちに何をやらせるか」の設計の方だった。自分の場合は、調査と使い捨てはGemini、実装とデバッグはClaudeという分担に落ち着いて、クォータ切れで開発が止まる日はなくなった。

AIコーディングツールはどれも進化が速くて、クォータや料金の条件は数ヶ月単位で変わる。実際Antigravityのクォータも、リリースからの半年ちょっとで絞られたり3倍に戻ったりを繰り返してきた。だからこそ特定の1社に依存しない構成にしておくのが、いまのところ一番堅い選択だと感じている。

あと何秒生きられる?『LifeTicker』で命のカウントダウンを始めたら、人生の解像度が上がった話

あと何秒生きられる?『LifeTicker』で命 of カウントダウンを始めたら、人生の解像度が上がった話 lifeticker app review

毎日を慌ただしく過ごしていると、時間はいくらでもあるような錯覚に陥る。しかし、私たちの人生の時間は一秒ごとに確実に減り続けている。自分も毎日なんとなくスマホを見て時間を溶かしていたのだけれど、自分の命の残り時間を秒単位で可視化するアプリ『LifeTicker(ライフティッカー)』を入れてから、時間の見え方がガラリと変わった。アプリの美しいデザインや人生年表機能を紹介しつつ、自分がどんな風に時間の価値を再認識したのかを書いてみたい。

なんとなく過ぎていく毎日に、静かな警鐘を鳴らすアプリとの出会い

朝起きて、満員電車に揺られ、仕事をこなし、夜はなんとなくスマホを眺めて眠りにつく。そんな風に毎日をただ消費している自分に、どこか焦りを感じていた。時間は無限ではない。頭では分かっているのに、どこか他人事のように捉えていたのだ。そんなときに巡り会ったのが『LifeTicker』というアプリだった。

このアプリの最大の特徴は、生年月日と期待寿命を入れるだけで、自分の「命の残り時間」が秒単位でリアルタイムに減っていくタイマーが表示されることだ。アプリを立ち上げて最初に入力を終えた瞬間、画面に表示された「残り約35年、12,775日、306,600時間……keys」という数字が、サラサラと激しくカウントダウンしていくのを見た。その瞬間、背筋に冷たいものが走るような感覚があった。数字として目の前で減り続ける私の人生は、ただの抽象概念ではなく、紛れもない現実なのだと突きつけられた。

命の残量を目にするということ:4つの異なる時間表現

ただ数字が減るだけなら、単に恐ろしいタイマーでしかない。しかし、『LifeTicker』は時間の有限性を、心を揺さぶるような美しい4つのビジュアルテーマで表現している。気分やその時の心の状態に合わせてテーマを選べるようになっていて、このデザインのクオリティが素晴らしい。

砂時計 (HOURGLASS)

自分が一番好んで設定しているのがこのテーマだ。画面の上から下へ、サラサラと音もなく落ちていく淡いピンクの砂。それが自分の命そのものであるかのように思えてくる。下に少しずつ溜まっていく砂を見るたびに、「ああ、この一瞬も二度と戻らない私の砂の一粒なのだ」と実感させられる。無機質な数字よりも、直感的に時間の流れを愛おしく感じさせてくれる表現だ。

ろうそく (CANDLE)

静かに揺らめくろうそくの炎と、時間とともに少しずつ短くなっていく白いキャンドルが描かれる。仕事で行き詰まった夜にこの画面を眺めていると、不思議と心が穏やかになる。私たちの命もまた、いつかは消えゆく小さな灯火なのだ。だからこそ、今こうして暖かく燃えている時間を、何に使おうかと考えさせてくれる。非常にエモーショナルで美しいビジュアルだ。

HPゲージ (HP_BAR)

ゲーム好きにはたまらないのが、人生の残り時間を「命のHP」に見立てたこのテーマだ。RPGの戦闘画面のようにHPバーが表示され、ライフステージに合わせた優先度の高いタスク(Lv.3など)を設定できる。そして、そのやりたいこと目標を達成すると「SLAYED(討伐)」と画面に表示される仕掛けがある。死という重いテーマを、ポップかつ遊び心のあるスタイルに昇華させており、タスク管理のモチベーションを上手く刺激してくれる。退屈なタスク消化が、まるで自分の人生というゲームのクエストをクリアしていくような感覚に変わるから不思議だ。

カレンダー (NUMERICAL)

余計な装飾を削ぎ落とした、シンプルで極めてモダンな数値表記のテーマ。白と黒の洗練されたUIで、静かに残り時間がカウントダウンされていく。余計なビジュアルによる感情の揺れを抑え、冷徹に「時間」というファクトだけに向き合いたいときに最適なデザインになっている。

自分の現在地を24時間でとらえる「人生フェーズカード」

残り30年と言われても、それが人生のどのあたりに位置するのかピンとこないこともある。そこで面白いのが、人生全体を「24時間の一日」に換算して表示してくれる「人生フェーズカード」という機能だ。

例えば、期待寿命を80歳とした場合、30代の自分は「午前9時前後」を生きていることになる。カードに表示された「午前9時3分:朝の通勤を終え、本格的な活動を開始する時間」というテキストを見たとき、妙に納得感と安心感を覚えた。「もう30代か」と焦る気持ちがあったが、24時間で考えればまだ午前中の早い時間帯なのだ。これからいくらでも新しいことを始められるし、人生の本番はまさにここからスタートするのだと思えて、なんだか力が湧いてきた。自分が今どのライフフェーズ(青春期なのか、実りの秋なのか)に立っているのかを可視化してくれるため、年齢を重ねることへのネガティブなイメージが払拭された気がする。

「過去」と「未来」を一本の直線でつなぐ「人生年表」の美しさ

多くのタスク管理ツールやバケットリスト(やりたいことリスト)は、単にやることの箇条書きになりがちだ。しかし『LifeTicker』の「人生年表(Life Timeline)」は、時間の捉え方が根本的に違っている。

誕生日というスタート地点から始まり、過去の楽しかった思い出や人生の転機を登録していく。そして【今日(TodayMarker)】という現在地を挟んで、その先には未来の夢ややりたいこと目標が並び、最終的な寿命というゴールへと収束していく。これらが一本の美しい縦のラインで結ばれているのだ。この縦のタイムラインを上下にスクロールしていると、自分がこれまでに歩んできた道のりと、これから進んでいく未来が地続きであることを強烈に意識させられる。過去の自分がいたからこそ今日があり、今日の選択が未来の夢へとつながっている。リストの羅列ではなく、時系列のコンテキストの中に夢を置くことで、「いつかやりたい」ではなく「何歳のときにこれを達成する」という具体的なタイムリミットを自然と意識するようになった。

WorkManagerによる優しい朝の通知と、端末内完結の安心感

技術的な視点からも、このアプリは非常によく作り込まれていると感じる。毎朝、静かに「今日も一日を大切に」という優しいメッセージとともに今日の残り時間を通知してくれる機能があるのだが、これはAndroidのWorkManagerを使用してバックグラウンドで安定して動作している。過度に主張せず、しかし毎朝のちょっとしたマインドフルネスの習慣として、一日の始まりに意識をチューニングするのにとても役立っている。

さらに、何より自分が信頼を置いているのが、**「完全無料・広告なし」**であり、すべてのデータがサーバーへ送信されず**「端末内のみで保存される」**という徹底したプライバシー設計だ。生年月日や過去の思い出、これから叶えたい極めて個人的な夢や目標といったセンシティブなデータを、どこの馬の骨ともわからないサーバーに預けるのは抵抗がある。このアプリは一切のネットワーク通信を行わず、ローカルのデータベースのみで完結しているため、安心して自分のすべてを書き出すことができる。

『LifeTicker』を使ってみて、自分の行動がどう変わったか

このアプリをスマホのホーム画面の一等地に置いてから、明らかに時間の使い方が変わった。かつてはベッドの中でダラダラとSNSを1時間眺めてしまうようなことがよくあった。今でもそうした時間を過ごしてしまうことはあるけれど、その最中にホーム画面に戻って『LifeTicker』の激しく減り続ける秒数を見たとき、「このダラダラしている1秒も、自分の命が削られている瞬間なんだ」とハッとするようになった。

しかし、それは自分を追い詰めるような苦しい焦りではない。むしろ、「このダラダラする時間も、自分が選んで楽しんでいるならそれでいい。でも、もっとやりたいことがあるなら、そっちに時間を使おう」という、前向きな選択肢を与えてくれる感覚だ。実際に、長年「いつか行きたい」と口にするだけだった海外旅行の計画を具体的に立て始め、英語の勉強も再開した。未来の年表にその目標をピン留めしただけで、ぼやけていた夢がくっきりとした「予定」に変わったのだ。

まとめ

『LifeTicker』は、単に時間を測るタイマーではなく、私たちが忘れがちな「今この瞬間の命の価値」を静かに教えてくれる、人生のコンパスのようなアプリだ。時間を意識することで、初めて私たちは主体的に自分の人生を生き始めることができるのではないだろうか。

  • リアルタイムの秒単位カウントダウンが、時間の有限性を強烈に実感させる
  • 砂時計やろうそくなど、情緒的な4つのビジュアルテーマで時間を愛おしく可視化する
  • 人生年表(タイムライン)により、過去から未来へのつながりと現在地を俯瞰できる
  • 完全無料で広告がなく、データは端末内完結のためプライバシーが完全に保護される

時間を大切に使うということは、自分自身の命を大切にするということだ。今日という日は、残りの人生の最初の一日。まずは自分の残り時間を調べて、本当にやりたいことを書き出してみることから始めてみてはいかがだろうか。

気になった方は、Google Playストアから無料でダウンロードできるので、自分の目で「命の残量」を確認してみてほしい。→ LifeTicker - Google Play のアプリ

2026/06/25

Google Play Console個人デベロッパーのテスター12人要件と対策

Google Playの個人開発者を阻む「テスター12人」の壁と向き合う

個人でコツコツ開発したAndroidアプリをいざリリースしようとしたとき、自分の前に立ちはだかったのがGoogle Play Consoleの「14日間連続で12人以上のテスターによるクローズドテスト」というルールだった(元々は20人だったが、2024年12月に12人へと緩和された)。2023年11月以降に作成された個人デベロッパーアカウントには、例外なくこの条件が適用される。友人や家族に頼むだけでは届きにくいこの数字に、自分も頭を抱えてしまった。この記事では、個人開発者がこのテスター要件をクリアするための現実的な解決策と、テストを進める中で自分が実際に詰まった落とし穴についてまとめている。

なぜ「12人・14日間」が必要なのか

Googleがこのルールを導入した背景には、アプリストア全体の品質向上と、スパム的な低品質アプリの排除があると言われている。企業アカウントにはこの制限はないが、個人用デベロッパーアカウントで新規アプリを作成する場合は、最初の本番リリースを行う前に必ずクローズドテストを経る必要がある。

この要件の厄介なところは、単に「12人のメールアドレスを登録する」だけでは終わらない点だ。登録されたテスター全員がアプリをオプトイン(テスト参加)し、さらに14日間連続で利用されていることがGoogle Playのダッシュボード上で確認されなければならない。もし途中でテスターがアプリを削除したり、全く起動しなくなったりすると、カウントが途切れて期間が延長される原因になる。

現実的なアプローチは「相互テスト」

身内だけで12人を集めるのは意外と大変だ。自分も最初は絶望したが、調べていくうちに同じ悩みを抱える開発者同士が助け合うコミュニティがいくつかあることがわかった。特に効果的だったのが、Discordの個人開発者向けサーバーやX(旧Twitter)での「相互テスト」の呼びかけだ。

Redditや日本の個人開発Discordコミュニティには、テスター募集専用のチャンネルが用意されている。そこで自分のアプリを紹介し、お互いにテスターになり合うのだ。この方法だと、相手も開発者なので「毎日テスト版アプリを立ち上げる」ことの重要性を理解してくれているため、テストの継続率が格段に高くなる。SNSで呼びかける際は、ハッシュタグ「#個人開発」や「#GooglePlayConsole」などを活用して、同じ状況にいる人を検索して直接コンタクトを取るのもいいだろう。

テスト開始までの具体的な設定手順

クローズドテストを始めるためのPlay Console側の設定手順を整理しておく。手順の不備で時間が無駄になるのを防ぎたい。

Googleグループの作成

テスターを個別にメールアドレスで追加することも可能だが、12人以上となると管理が面倒になる。Googleグループを作成し、そこにテスターのGoogleアカウントを登録してもらうのが一番スムーズだ。テスト参加者にはグループのリンクを共有し、各自で参加してもらう形をとる。

Play Consoleでのトラック作成

Google Play Consoleにログインし、対象 of アプリを選択後、左メニューの「テスト」>「クローズドテスト」へと進む。ここで新しくトラックを作成し、テスターの指定欄で先ほど作成したGoogleグループのメールアドレスを入力する。

# Googleグループを利用したテスター追加手順のイメージ
1. https://groups.google.com/ でグループを作成 (例: my-app-testers)
2. Play Console > クローズドテスト > テスターの追加 でグループのメールアドレスを入力
3. ユーザーにテスト用URL(オプトインURL)を共有してインストールを促す

テスト用アプリのビルド(AAB)をアップロードし、審査が通ったら「オプトインURL」が発行される。このURLをテスターに共有し、Web上で「テストプログラムに参加」ボタンを押してもらった後、Playストアからインストールしてもらう必要がある。URLを共有するだけではインストールできないため、手順書を事前に用意して共有すると親切だ。

自分が直面した「期間延長」のトラブルと対策

順調に進んでいるように見えたテストだが、10日目あたりで進行状況のインジケーターが止まってしまった。ダッシュボードを確認すると、テスト日数が正しくカウントされていない。「毎日利用」という基準の厳しさにここで直面したのだ。

Googleは明確な判定基準を公表していないが、どうやら「アプリがフォアグラウンドで起動され、一定のアクティビティがログとしてGoogle側に送信されること」が必要なようだ。ただインストールしたまま放置されている端末は、アクティブなテスターとしてカウントされない可能性が高い。

このトラブルを解決するために、自分は以下の対策を実施した。

  • プッシュ通知の実装: Firebase Cloud Messaging (FCM)を導入し、テスターに向けて1日1回「テストのご協力ありがとうございます。本日のアプリアクティビティをお願いします」といった通知を送信した。
  • ローカル通知でのリマインダー: プッシュ設定が面倒な場合は、アプリ起動時に翌日用のローカル通知をスケジュールする処理をコードに記述した。
  • 予備テスターの確保: 12人ぴったりでテストを回すと、1人でも離脱した瞬間に要件が未達になる。最初から15人〜20人程度をグループに招待し、少々の不稼働メンバーがいても基準を下回らないようにバッファを持たせた。

相互テスト時の個人情報とセキュリティ対策

ネット上の見ず知らずの人とテストを融通し合う場合、セキュリティと個人情報の扱いには細心の注意を払わなければならない。クローズドテストでは、オプトインしたユーザーのメールアドレスが一部開発者側に見えてしまう仕様があるためだ。

情報漏洩のリスクを減らすため、以下のトレードオフと対策を考慮するべきだ。

対策方法 メリット デメリット / トレードオフ
Googleグループの招待制利用 メールアドレスの直接公開を防げる グループの権限設定を誤るとメンバー一覧が見えてしまう
テスト専用エイリアスアドレスの推奨 プライベートのメールアドレスを保護できる テスター側で別アドレスを用意してもらう手間が発生する
メールアドレス登録制(個別リスト) 特定の相手のみに制限しやすい 手動での登録作業コストが高く、管理ミスが発生しやすい

自分はGoogleグループを利用したが、グループの設定で「メンバー一覧の閲覧」を管理者のみに制限することを徹底した。これを行わないと、参加したテスター同士でお互いのメールアドレスが見えてしまうトラブルに発展するため、初期設定時は確実にチェックを行っておく必要がある。

まとめ

2023年11月以降に作成された個人デベロッパーアカウントでの新規アプリ公開は、以前と比べて難易度が跳ね上がった。しかし、しっかりとした準備とコミュニティへの参加で、この壁は十分に突破できる。

  • テスター募集はDiscordやSNSでの「相互テスト」が現実的
  • 14日間のアクティブ利用を維持するため、通知によるリマインドを設計する
  • 離脱対策として15人〜20人程度の予備テスターを確保する
  • Googleグループの設定ではメンバー一覧の非公開化を徹底する

2026/06/23

スマホアプリ向けAPIでのHATEOAS設計は本当に悪手?実務的な妥協点と部分的採用のススメ

スマホアプリ向けAPIでのHATEOAS設計は本当に悪手?実務的な妥協点と部分的採用のススメ

スマホ向けAPIの設計をしていると、「URL変更によるアプリの強制アップデートをどう避けるか」とか、「期限付きトークンの管理をどうスマートに行うか」といった問題にぶつかる。この記事では、RESTの最上位原則とも言われるHATEOAS(Hypermedia as the Engine of Application State)をスマホアプリ向けAPIに適用する際の実務上のメリットやデメリット、そして完全採用で挫折しないための部分的採用という落としどころについて解説する。理想論に偏らず、クライアント開発者の実装コストやトラブル対処まで含めた現実的なAPI設計のヒントが得られると思う。

HATEOASって実際どうなんだろう?REST成熟度と実務のギャップ

HATEOASは、レスポンスの中に次に呼ぶべきAPIのリンク(URL)を埋め込んでおき、クライアントがそのリンクを動的に辿ることでアプリケーションの状態を遷移させる設計思想だ。よくREST APIの定義として引き合いに出される「Richardson Maturity Model(リチャードソン成熟度モデル)」では、最高レベルであるLevel 3に位置付けられている。

実務で使われるWeb APIのほとんどは、リソース単位のURL設計(Level 1)やHTTPメソッドの使い分け(Level 2)で止まっており、Level 3のHATEOASまでしっかりと実装している例は稀なんだよね。なぜなら、「クライアントとサーバーの結合度を極限まで下げる」という理想に対して、実装やメンテにかかるコストが明らかに高すぎると思われているからだ。特に仕様変更のスピードが速いスマホアプリの開発現場では、ドキュメントの更新が追いつかない中で動的なURLパースを入れるのは敬遠されがちだったりする。

ファイルDLフローにおけるHATEOASのレスポンス設計例

言葉だけで考えてもピンとこないため、具体的なユースケースを想定してレスポンス例を見てみよう。今回は「ファイル一覧取得 → 詳細取得 → ファイルダウンロード(DL) → ダウンロード完了確認(ACK)」という段階的なフローをHATEOASライクに表現してみた。

1. 一覧取得APIのレスポンス

まずはファイルの一覧を取得する。この段階で、各アイテムの詳細APIのURLや、ページネーションの次のページのURLを `_links` という標準的なキーの下に埋め込んで返す。

{
  "items": [
    {
      "id": "abc123",
      "name": "document.pdf",
      "size": 1048576
    }
  ],
  "_links": {
    "self": { "href": "/files" }, // 自分自身のエンドポイント
    "detail": { "href": "/files/abc123" }, // 詳細取得のための動的なリンク
    "next_page": { "href": "/files?page=2" } // クライアント側でクエリを組み立てさせないための次ページリンク
  }
}

2. 詳細取得APIのレスポンス

詳細APIを叩くと、ファイルのメタデータに加えて、一時的なダウンロードトークンを含んだ有効期限付きの署名付きURLが `download` リンクとして降ってくる。

{
  "id": "abc123",
  "name": "document.pdf",
  "version": "2.1.0",
  "checksum": "sha256:deadbeef...",
  "_links": {
    "self": { "href": "/files/abc123" },
    "download": { "href": "/files/abc123/download?token=xyz789&expires=1719100000" }, // 期限付きダウンロードURL
    "list": { "href": "/files" } // 一覧へ戻るためのリンク
  }
}

3. ファイルダウンロードAPI(バイナリ送信)

上記の `download` のリンク先へGETリクエストを送ると、ファイル本体がバイナリで返却される。しかし、バイナリデータ(`application/octet-stream`)のボディ部にはJSONリンクを含めることができない。そこで、レスポンスヘッダーを利用して次の完了確認(ACK)用のURLを受け渡す。

HTTP/1.1 200 OK
Content-Type: application/octet-stream
X-ACK-URL: /files/abc123/ack?token=xyz789&download_id=dl_001
Content-Length: 1048576

(バイナリデータ...)

4. ダウンロード完了確認(DL ACK)API

クライアントはヘッダーから抽出した `X-ACK-URL` に対し、ダウンロードが正常に終わったことを通知するPOSTを送る。完了後の遷移先リンクもレスポンスに含まれている。

POST /files/abc123/ack?token=xyz789&download_id=dl_001

{
  "status": "ok",
  "_links": {
    "detail": { "href": "/files/abc123" },
    "list": { "href": "/files" }
  }
}

設計思想の比較:なぜスマホ向けでは敬遠されるのか?

この設計のメリットは明らかで、クライアント側が「次に呼ぶべきAPIのパス」を一切知らなくていい点にある。サーバー側でエンドポイントの設計を変えたり、ダウンロード処理のドメインを別サーバー(CDNなど)へ逃がしたりしても、クライアントのコードを修正して強制アップデートをかける必要がない。また、状態管理がサーバー側で一元化されるため、「特定の状態のときだけダウンロードボタンを活性化する」といった制御が、リンクの有無だけで表現できる。

しかし、スマホアプリの開発でこれが敬遠されるのは、クライアント側の実装が複雑化するからだ。URLをソースコードに文字列定数として定義する(ハードコーディングする)方が圧倒的に直感的だし、多くのモバイル開発チームはその手法に慣れている。また、全レスポンスにリンク情報が乗り続けるため通信容量が微増することや、URLがクエリパラメータなどで動的に変化するためにHTTPキャッシュやローカルキャッシュのキー設計が非常に難しくなるというデメリットもある。

遭遇しがちなトラブルと解決策:OpenAPIスキーマとの相性問題

HATEOASを実務で導入しようとして自分が一番困ったのが、OpenAPI(Swagger)を使った静的なAPIスキーマ定義とクライアントコード自動生成(OpenAPI Generatorなど)との食い合わせの悪さだった。

OpenAPIは、基本的に「どこのURLにどんなスキーマのデータを送れば、何が返ってくるか」を静的に定義するツールだ。しかし、HATEOASのようにURLがレスポンスの値として動的に降ってくる設計だと、クライアント生成ツールが動的な `_links` のパース処理をうまく型定義できないという問題が起きる。特にSwiftやKotlinのコードジェネレータを使うと、レスポンスのオブジェクトがネストする中で循環参照が発生したり、ジェネリクスが解釈できずにコンパイルエラーを吐き出すエラーに直面することがある。

この根本原因は、生成されるモデル定義において、動的リンクを示す `href` のオブジェクトと静的なリソース定義を同じスキーマで表現しようとすることにある。このトラブルを解決するには、API仕様書(yaml/json)側で `_links` 部分を共通のコンポーネント(共通スキーマ)として定義し、かつクライアント側ではURLの解決だけを行う「リンクパーサー」をジェネレータの自動生成対象から除外して別クラスとして手動で定義し、疎結合に分離してパースするのが確実だ。以下のような定義ファイルを切り出しておくことで、コード生成時のエラーを綺麗に防ぐことができた。

# OpenAPI定義ファイルでの共通リンクターゲットの定義例
components:
  schemas:
    LinkObject:
      type: object
      properties:
        href:
          type: string
          format: uri
          description: "次に遷移するAPIの相対パスまたは絶対URL"
    ResourceLinks:
      type: object
      properties:
        self:
          $ref: '#/components/schemas/LinkObject'
        next:
          $ref: '#/components/schemas/LinkObject'

実務での落としどころ:部分的HATEOASの推奨

完全なHATEOASの実装は、学習コストやスキーマ定義のオーバーヘッドを考えると、多くの場合オーバーエンジニアリングになってしまう。だからこそ、実務では**「部分的HATEOAS」**を落としどころにするのがおすすめだと思う。

具体的には、静的なエンドポイント(例: `/files` や `/files/{id}`)はクライアント側で通常通りハードコードして扱い、今回のような「一時的なDLトークン付きURL」や「DL後の通知先(ACK URL)」などのように、状態や時間によって動的に生成しなければならないURLだけをレスポンスに含めるという設計だ。以下のように、リンク専用の `_links` という構造を持たせるのではなく、シンプルなフィールドとして返すのが最も破綻しにくい。

{
  "file_id": "abc123",
  "download_url": "https://cdn.example.com/files/abc123/download?token=xyz789&expires=1719100000",
  "ack_url": "/api/v1/files/abc123/ack?token=xyz789&download_id=dl_001",
  "expires_at": 1719100000
}

これならOpenAPIでの型定義も非常にシンプルで済むし、クライアント側も通常のJSONパースと同様のロジックで対応できる。動的に変わる重要なURL管理のメリットを享受しつつ、実装コストを最小限に抑えることができる実用的な折衷案だと思う。

まとめ

  • HATEOASはAPIレスポンスに次の遷移先URLを含める設計で、RESTの最高成熟度に相当する。
  • サーバー側でURLのルールや状態制御を一元化できるため、クライアントの変更耐性が上がるのが最大のメリット。
  • 一方で、クライアント側の実装負荷やOpenAPIなどのツールチェーンとの相性が悪いという実務上の大きな課題もある。
  • 解決策として、期限付きURLやACK通知先などの動的なパスにだけ絞ってレスポンスやヘッダーに含める「部分的HATEOAS」が実務の落としどころとして最適。

2026/06/17

: GoでLINEスタンプ制作を全自動化するCLIを作った話

白目まで透けて泣いた僕が、GoでLINEスタンプ制作を全自動化した話

LINEスタンプを作るとき、絵を描くのは楽しい。でもそのあとが地獄だった。背景を透過して、文字を入れて、サイズを揃えて、規定に合ってるか確認して、ZIPにまとめる。24枚+メイン+タブを手作業でやると、絵を描いた時間より加工の時間のほうが長くなる。耐えられなくなって、この単純作業を全部やってくれるCLIツールをGoで書いた。`stamp-tool` という名前にした。背景透過の「目が透ける問題」とか、文字のはみ出しとか、ハマりどころが多かったので記録しておく。

とりあえず4つのサブコマンドに分けた

最初から大きく作る気はなくて、自分の作業手順をそのままコマンドに割っただけ。フォルダを切る `init`、画像を加工する `process`、規定チェックの `validate`、申請用ZIPを吐く `zip`。普段やってる流れを写しただけだから迷わなかった。

stamp-tool init   myset      # フォルダと config.json を作る
stamp-tool process myset      # raw画像 → LINE仕様の透過PNGへ
stamp-tool validate myset     # 偶数サイズ・サイズ上限などを検査
stamp-tool zip      myset      # set_01.zip を書き出す

入力は画像生成AIで作った白背景のキャラ素材。これを `raw/` に置いて `process` を回すと、透過・文字入れ・サイズ調整まで終わった状態で `out/` に並ぶ。LINEの仕様を毎回手で確認しなくていいのが、思っていた以上に効いた。

「白を透明にする」だけだと目が消える

最初に書いた透過処理がひどかった。白いピクセルを片っ端から透明にしたら、キャラの白目も、白い服も、歯も、全部透けた。トーク画面で見ると目玉に穴が空いてて完全にホラー。LINEの審査でも背景の抜き残しや変な透過はリジェクト対象になるので、これは直さないとどうにもならなかった。

外側からBFSで「地続きの白」だけ抜く

解決のヒントは単純で、背景の白は必ず画像の端に接しているということ。逆に言うと、キャラの内側にある白は端と繋がっていない。だから画像の四隅・外周から探索を始めて、隣り合う白ピクセルだけを辿っていけば、背景だけを抜ける。いわゆるFlood Fillで、幅優先探索(BFS)で外周から内側へ「白の地続き」を広げていくイメージ。

しきい値はRGBがそれぞれ240/255以上なら白とみなす形にした。Goの `image` だと色は16bitで返ってくるので、240/255に当たる `61680` を境にしている。`visited` フラグで一度見たピクセルを管理して、キューで繋がりを辿るだけ。

// 外周をキューに積んで、地続きの白だけ透明化する
const threshold = 61680 // 240/255 を16bitにした値

for !queue.empty() {
    p := queue.pop()
    if visited[p] { continue }
    visited[p] = true
    r, g, b, _ := src.At(p.X, p.Y).RGBA()
    if r < threshold || g < threshold || b < threshold {
        continue // 白じゃない=キャラの輪郭。ここで止まる
    }
    dst.Set(p.X, p.Y, color.Transparent)
    queue.pushNeighbors(p) // 上下左右へ伝播
}

これでキャラの内側の白目は端と繋がっていないので残る。輪郭線で囲まれている絵柄なら、線がストッパーになってキレイに背景だけ抜ける。逆に輪郭が途切れてると白がキャラ内部までダダ漏れするので、素材側の線はちゃんと閉じておいたほうがいい、というのを身をもって学んだ。

長いセリフが枠からはみ出す

次に詰まったのが文字入れ。スタンプによってセリフの長さがバラバラで、「OK」みたいな短いやつはいいけど、「了解しました!」みたいに長いと横にはみ出る。LINEのスタンプ画像は最大で横370px・縦320pxと決まっているので、ここを超えるわけにいかない。

描画には `github.com/fogleman/gg` を使った。ありがたいことに、描く前に文字のピクセル幅を測れる。なので一回ダミーで測って、370pxに収まらなければフォントを縮める比率を計算して当てる、という形にした。

w, _ := dc.MeasureString(text)        // 描画前に幅を測る
if w > maxWidth {
    fontSize *= maxWidth / w           // はみ出したぶんだけ縮小
    dc.LoadFontFace(fontPath, fontSize)
}

さらに、文字が長いときはキャンバスの幅自体も広げるようにした。キャラの幅と文字の幅、大きいほうを基準にキャンバスを決める。ここで一個落とし穴があって、LINEのスタンプは自動で縮小される都合上、画像サイズが偶数じゃないとダメ。奇数で吐くと地味に弾かれる。なので最後に必ず偶数へ丸める処理を挟んでいる。

どんな背景でも読めるように縁取りを二重にした

LINEのトーク画面って、白背景の人もいればダークモードの人もいるし、パステルの壁紙にしてる人もいる。白文字だけだと明るい背景で消えるし、黒文字だけだと暗い背景で沈む。最終的に「白文字+黒の内フチ+黄色の外フチ」の二重縁取りに落ち着いた。これだと大抵の背景で文字が浮き上がる。

やってることは力技で、文字を少しずつ全方向にずらして描いて縁を作る。`gg` の `DrawStringAnchored` で、角度を変えながら16方向くらいに置いていく。先に外フチ(黄)を太めに、その上に内フチ(黒)、最後に本体(白)を重ねる順番が大事だった。フチ幅はフォントサイズに対する比率で出していて、内フチが約8%、外フチが約15%くらいにしている。

感情を足す漫画エフェクトの合成

焦りの汗とか、怒りマークとか、漫画的なエフェクトを乗せたくなる。これは `config.json` にエフェクト素材のどこを使うか座標とサイズで書いておいて、`image.Rectangle` と `SubImage` で必要なパーツだけ切り出して合成する形にした。キャラの大きさに合わせてアスペクト比を保ったまま拡縮して、右上あたりに置く。設定で位置を変えられるようにしたおかげで、同じ汗素材を使い回せて楽だった。

最後は機械にダメ出しさせる

人間の目視チェックが一番信用できないので、`validate` で機械的に弾くようにした。チェックしてるのはこのあたり。

項目規定
スタンプ画像の最大サイズ横370 × 縦320px 以内
画像サイズ偶数ピクセル
ファイルサイズ1枚あたり1MB以下
形式・透過PNG・背景透過

メイン画像は240×240、タブ(タグ)画像は96×74と別サイズなので、ここも `process` で一緒に生成して `validate` にかける。全部パスしたら `zip` が申請用のパッケージを吐いて終わり。検証で落ちた項目はログに出るので、何を直せばいいか一目でわかる。手作業で1枚ずつプロパティを見ていた頃と比べると、別世界だった。

まとめ

  • 背景透過は「白を全部消す」じゃなく、外周からのBFS(Flood Fill)で地続きの白だけ抜くと白目が守れる
  • 文字のはみ出しは `MeasureString` で事前測定→フォント縮小&キャンバス幅拡張で対応。サイズは必ず偶数に丸める
  • 二重縁取り(白+黒+黄)にすると、ダークモードでもパステル背景でも文字が読める
  • `validate` で最大370×320・偶数・1MB以下・透過を機械チェックしてからZIP化すると、審査前のミスが激減した

2026/06/09

YouTube・Twitchのチャットだけ表示するアプリを作った話

YouTube・Twitchのチャットだけ表示するアプリを作った話

ライブ配信を見るとき、映像とチャットを同じ画面で追わなくてもいい場面って、意外と多いんですよね。配信そのものはテレビやPCの大きい画面で流しておいて、手元のスマホではチャットだけを大きく読みたい。通信量やバッテリーを抑えたくて、映像は切ってコメントの流れだけ眺めていたいこともある。私自身がずっとそういう見方をしていて、その不便さを消すために作ったのが ChatTube という Android アプリです。YouTube と Twitch のライブチャットだけを表示する、それ以外は何もしないアプリで、この記事では何ができて、どう使うのかをまとめておきます。

映像はいらない、チャットだけ読みたいという需要

YouTube や Twitch の公式アプリは、当然ながら映像とチャットがセットで表示される。よくできているけれど、「チャットだけを軽く見たい」という用途には少しオーバースペックなんだよね。映像のデコードが走るぶん端末は熱くなるし、ギガも減る。セカンドスクリーンとしてスマホを置いて、コメントだけを流し読みしたいときには、もっと身軽なものがほしくなる。

ChatTube は、その「チャットだけ」を切り出したアプリです。配信映像は一切表示せず、YouTube と Twitch のライブチャット欄だけを出す。映像を持たないぶん動作が軽くて、コメントの流れに集中できます。

使い方は「URLを貼る」だけ

チャットを開く手順はシンプルです。画面上部の「+」からブックマークを追加して、見たい配信の動画URL、またはチャンネルのURLを貼り付ける。あとは一覧から項目をタップすれば、そのチャットが開きます。

もっと楽なのは共有経由で、YouTube や Twitch のアプリ側で配信ページを開いて「共有」メニューから ChatTube を選ぶと、URLをコピーして貼り直す手間なく登録できます。一度登録した配信はブックマーク一覧に残るので、よく見る配信者を並べておけば次からはタップ一発でたどり着けます。

YouTubeの「配信ごとにURLが変わる問題」を解決する

YouTube を追っている人なら一度は困ったことがあると思うんだけど、YouTube のライブ配信は配信のたびに動画URLが変わる。昨日ブックマークしたURLは、今日の配信ではもう別物になっている。これが地味に面倒で、結局その都度チャンネルを開いて「いま配信中の枠」を探すことになるんですよね。

ChatTube では、動画URLの代わりにチャンネルのURLを登録できます。たとえばこういう形式です。

https://www.youtube.com/@チャンネル名

チャンネルURLを登録すると、一覧にその項目が「チャンネル」バッジ付きで表示される。タップした時点でそのチャンネルがライブ配信中なら、いま流れている配信のチャットを自動で開いてくれます。配信していなければ「配信していません」と通知するだけで、画面は切り替わらない。配信ごとにURLを探し直す必要がなくなるので、毎回同じ配信者を追っている人ほど恩恵が大きい機能だと思う。

配信中かどうかが一覧でわかる

ブックマーク一覧では、各項目の配信状態がアイコンで出ます。🔴 が配信中、⚫ が配信なし。一覧を開いた瞬間に「いま誰がやっているか」がひと目でわかるので、一つずつ開いて確認する手間がない。状態は一覧右上の更新ボタンで取り直せるから、配信が始まったタイミングで押せば最新の状況に追いつけます。

コメントの投稿にも対応

ChatTube は読むだけのアプリではありません。チャットにコメントを書き込むこともできます。投稿にはログインが必要で、画面上部のログインアイコンから YouTube(Googleアカウント)または Twitch にログインする。ログアウトは設定画面からいつでもできます。映像なしの軽い画面のまま、配信に参加できるわけです。

チャットを広く・見やすくするための工夫

チャットだけを表示するアプリなので、その表示領域をどれだけ快適にできるかにはこだわった。チャット表示中は数秒でヘッダーが自動的に隠れて、画面いっぱいにコメントが広がります。もう一度出したいときは画面の上端をタップするだけ。この「没入モード」は設定の「ヘッダー自動非表示」でオン・オフを切り替えられます。

設定画面では、ほかにもテーマ(ライト/ダーク)、表示言語、フォントサイズ、チャットの余白、画面の常時オンを変更できます。チャットの余白はピクチャーインピクチャー(PiP)の小窓表示に合わせるための調整で、映像を別アプリの小窓で流しながら ChatTube でチャットを読む、という使い方にも寄せられる。配信を流しっぱなしで眺めたいときは、画面常時オンにしておくと画面が消えずに済みます。

まとめ

  • ChatTube は映像を表示せず、YouTube・Twitch のチャットだけを軽く読める Android アプリ
  • チャンネルURLを登録すれば「いま配信中の枠」のチャットへ自動で飛べて、YouTubeのURL変化問題を回避できる
  • Google または Twitch にログインすればコメント投稿もできる
  • 没入モードや余白・フォント調整で、チャットを広く見やすく保てる

「映像はいらない、チャットだけ」という人にはちょうどいいはずです。ChatTube は Google Play で公開しているので、気になった人はこちらからどうぞ。

2026/06/05

Webサイトのモックアップ作成はAIにHTMLを書かせるのが速い

Webサイトのモックアップ、もうAIにHTMLを書かせればいいんじゃないか

結論から言うと、Webサイトのモックアップ作成はAIにHTMLを直接書かせるのが一番速い。自分はここ半年くらい、デザインツールでワイヤーフレームを引く工程をほぼやめて、ClaudeやChatGPTに「こういうサイトのモックをHTML 1ファイルで」と頼む方式に切り替えた。実際に使っているプロンプトとハマりどころを、自分の経験ベースで書いていく。

なぜデザインツールではなくHTMLモックなのか

以前はFigmaでモックを作っていた。ただ、自分のような開発寄りの人間にとって、Figmaは正直オーバースペックなんだよね。オートレイアウトの設定に時間を食って、肝心の「どんなサイトにするか」を考える時間が削られていく感覚があった。

HTMLモックには明確な利点がある。ブラウザでそのまま開けるから、クライアントや社内メンバーに「このURLを開いて」で共有が終わる。ウィンドウ幅を縮めればレスポンシブの挙動も実物で確認できる。静止画のモックだと「スマホだとどう見えるんですか」という質問に毎回別画面を用意する必要があったけど、HTMLならその場で縮めて見せるだけ。

そして何より、HTMLを書く作業自体をAIに丸投げできるようになったのが大きい。手書きでHTMLモックを作るのは正直つらいけど、書くのがAIなら話は別だと思う。体感だけど、以前Figmaで半日かけていたレベルの提案用モックが、いまはプロンプトを書いて修正指示を数回出すだけ、30分前後で形になっている。

実際のやり方

基本は「1ファイル完結」で頼む

自分がいつも使っているプロンプトの型はこんな感じ。

以下の要件でWebサイトのモックアップを作って。
- HTML 1ファイル完結(CSSは<style>タグ内に書く)
- 構成: ヘッダー / ヒーロー / 特徴3つ / 料金表 / お問い合わせフォーム / フッター
- ターゲット: 中小企業の経営者
- 配色: 紺をベースに、アクセントは1色だけ
- ダミー画像は使わず、CSSのグラデーションか単色で代用
- 文言は「ここに入ります」ではなく、それらしい日本語を入れる

ポイントは外部依存をなくすこと。CSSを別ファイルにされたり、CDNから画像を引っ張られたりすると、ファイルを渡した相手の環境で崩れることがある。「1ファイル完結」と明示しておけば、メールに添付するだけで誰でも開ける成果物になる。

文言はダミーにしない

地味に効くのが「それらしい日本語を入れて」という指示。モックの段階で「サンプルテキストサンプルテキスト」が並んでいると、見る側はデザインじゃなくて空白の文言が気になってしまう。文言まわりは、業種とターゲットを添えるとぐっと良くなる。

・文言はダミー禁止。税理士事務所のサイトという前提で、
  40〜50代の個人事業主に響くキャッチコピーと説明文を入れて
・キャッチコピーは20字以内、説明文は各80字程度

AIならそれらしいキャッチコピーや説明文を一緒に生成してくれるので、モックの完成度が一段上がる。実際、クライアントに見せたとき「この文言ほぼこのままでいいね」と言われて、コピーライティングの工数まで浮いたことがあった。逆にここをダミーのままにしておくと、打ち合わせの場で文言の話に脱線して、肝心のレイアウトの議論が進まなかったりする。

修正は会話で回す

初回の出力が微妙でも問題ない。修正指示を会話で投げれば、数十秒で修正版が返ってくる。自分が実際によく使う修正プロンプトはこのあたり。

・ヒーローセクションの高さを画面の70%にして、キャッチコピーをもっと大きく
・料金表は3プラン横並び。真ん中のプランを「おすすめ」として少し目立たせて
・全体的に余白が窮屈。セクション間の余白を1.5倍くらいに
・スマホ表示のとき、特徴3つは縦積みにして

Figmaで自分で直すより圧倒的に速い。自分の場合、初回生成から3〜5往復くらいで「見せられるモック」に到達することが多い。

ハマったポイントと対策

いいことばかり書いてきたけど、ハマりどころもある。

まず、AIが作るデザインは放っておくと似たり寄ったりになる。紫系のグラデーション、カード型レイアウト、絵文字アイコン。いかにも「AIが作りました」という見た目になりがちなんだよね。対策としては、配色とトーンを具体的に指定すること。自分はこういう指示を最初のプロンプトに足している。

・配色は #1e3a5f(紺)をベースに、アクセントはオレンジ1色だけ。グラデーション禁止
・絵文字アイコンは使わない
・無印良品の公式サイトみたいな、余白多めで色数を絞った落ち着いたトーン
・フォントは游ゴシック系。丸ゴシックは使わない

「参考サイトの雰囲気を文章で説明する」のが特に効く。デザインの専門用語を知らなくても、「〜みたいな感じ」で伝えるだけでだいぶ変わる。

もうひとつは、モックのHTMLをそのまま本番に流用したくなる誘惑。これはやめたほうがいい。モック用に生成されたコードはアクセシビリティやセマンティクスが甘いことが多くて、本番品質にするには結局書き直しに近い手間がかかる。自分は「モックは使い捨て、本番は別物」と割り切ることにしている。

ツールの選択肢としては、ChatGPTやClaudeのようなチャット型のほか、Vercelのv0みたいなUI生成特化のサービスもある。ただ個人的には、日本語の文言込みで作るなら普通のチャット型AIで十分というのが体感。プレビュー機能つきのチャットAIなら、生成結果をその場で確認しながら会話で直せる。

デザインツールとの使い分け

じゃあFigmaが要らなくなったかというと、そうでもない。デザイナーが入るプロジェクトでは、コンポーネント管理やコメント機能を含めてFigmaのほうが回しやすい。自分の整理はこう。

  • 提案段階の叩き台、個人開発、社内ツール → AIにHTMLモックを書かせる
  • デザイナーと共同作業する本格的な案件 → Figma
  • 「とりあえず見た目のイメージを共有したい」だけ → AIのHTMLモックで十分

体感だと、自分の仕事の8割は上の1番目と3番目で済んでいる。叩き台の段階で時間をかけても、どうせ要件が変わって作り直しになる。だったら30分で出せるHTMLモックを使い捨てる前提で回したほうが、トータルでは速い。モックに対する心理的なコストが下がると、「とりあえず2案作って並べて見せる」みたいな贅沢な使い方もできるようになるしね。

まとめ

  • モックアップはAIにHTML 1ファイルで書かせるのが速い。ブラウザで開けてレスポンシブ確認もできる
  • プロンプトは「1ファイル完結・構成・配色・それらしい日本語の文言」を指定する
  • AIっぽい見た目を避けるには配色とトーンの具体指定が効く
  • モックのコードは使い捨て。本番流用はしない

2026/05/20

広告代理店経由の受託開発がしんどい話

広告代理店経由の受託開発、何がしんどいのか

受けなきゃよかった、と後悔した案件が何件かある。そのほとんどが広告代理店を挟んだ受託開発だった。「大手クライアントの仕事に関われる」という響きに最初は引かれていたけど、今は案件の構造を聞いた時点でかなり慎重に判断するようにしている。中抜き・スケジュール感覚のズレ・体育会系の空気、この3つが揃ったときのしんどさは直接取引とは別次元だと思う。

中抜きの構造と、実際の金額感

まず前提として、広告代理店経由の案件では「お金がどう流れるか」を把握しておく必要がある。典型的な構造はこんな感じだ。

エンドクライアント → 広告代理店 → 制作会社(ウェブ系)→ 自社(受託開発会社)

自分たちが経験した案件で、これが一番リアルに刺さった数字がある。自社が1000万円で見積もった案件が、上位の制作会社に渡った時点で3000万円に化けていた。さらにその上の広告代理店がエンドクライアントに請求した金額は5000万円だったと後から知った。エンドクライアントが5000万円を出して、実際に手元に来るのは1000万円。その比率は20%だ。

各層が普通に利益を乗せているだけで、誰が意地悪をしているわけでもない。広告代理店はクライアントとの折衝・企画立案を担い、中間の制作会社もPMを一人プロジェクトに張り付けている。それぞれが何かしら動いているのは事実だ。ただ、実装のほぼすべてを担う会社が全体の20%以下しか受け取れない構造は、それが「慣行」として成立していること自体がちょっと不思議ではある。

契約形態も確認が必要で、準委任契約であれば月額単価が明示されるから計算できる。ただ請負契約で「このシステム一式、○○万円でお願いします」という形で来る場合は、そもそも適正価格がいくらなのかが見えない。スコープが不明瞭なまま受けると、後から「やっぱりこの機能も追加で」という話が平気で出てくる。

スケジュールの感覚が根本的にズレている

「来週中に」が意味するもの

広告代理店の担当者と仕事をしていて一番つらいのは、スケジュール感覚の違いだと思う。広告業界はスピードを美徳とする文化があるらしくて、メッセージの返信は確かに早い。でも「仕様書を来週中に共有します」という約束が守られたことはほとんどない。

一方で「今週金曜日までに初稿を上げてほしい」という依頼は普通に来る。デザインカンプもない、コピーもない、要件も半分しか固まっていない状態で。自分が経験した中で一番しんどかったのは、金曜の午後3時に「月曜のプレゼン用にランディングページを作ってほしい」という連絡が来たことだ。何を入れるかの情報は「社内で確認してから共有します」と言われて、実際に来たのは土曜の夜10時過ぎだった。

エンジニアリングコストが計算に入っていない

根本的な問題は、広告代理店側に「コードを書く時間」しか見えていないことだと思う。要件のヒアリングに使う時間、仕様の確認と折り返しのやり取り、テスト、修正対応、デプロイ後の検証——こういった工程がすべてゼロコストとして扱われている感じがある。「ページを1枚作るだけでしょ」という感覚で依頼が来るのに、実際は認証が絡んでいたり、既存システムとの連携が必要だったりする。

これは担当者が悪意を持っているわけじゃなくて、単純にソフトウェア開発のことを知らないまま窓口をやっているケースが多い。「この機能はどれくらいかかりますか」と聞いてもらえるならまだいいほうで、工数の確認なしにスケジュールを決めてからこちらに伝えてくる、というパターンも普通にある。

体育会系の空気というか、「できない」が許されない感じ

体育会系という言葉を使う人が多いけど、自分が感じるのは少し違って「上位者の判断を技術的な理由で覆すことが許されない」という空気だと思っている。

広告代理店の担当者は、エンドクライアントから言われたことをそのまま伝達してくる役割を担っている。そこで「それは技術的に難しいです」「セキュリティ上のリスクがあります」と指摘すると、返ってくる言葉が「クライアントがそれでよいと言っているので」だったりする。技術的な問題を、クライアントの意向で上書きする。それが普通の意思決定フローとして機能している。

自分がある案件で、明らかに問題のある認証の実装を指摘したことがある。JWTのsecretをハードコードした状態でのデプロイを求められた場面だ。「本番環境でそれをやると後々大きな問題になる」と説明したら、「クライアントのスケジュール優先で、後から直してもらえれば」と言われた。「後から直す」案件がその後どうなったかは想像の通りだ。

技術判断をエンジニアに委ねてもらえない案件は、作り手としてもリスクが高い。何か起きたときに「言われた通り作っただけ」では済まないことも多いし、そもそも自分の名前がコードに残るのに、納得できない実装をするのはしんどい。

直接取引との差と、自分なりの判断基準

直接クライアントと契約する場合、中間マージンは発生しない。同じ1000万円の案件なら、1000万円がそのまま売上になる。それ以上に大きいのは、要件の解像度と意思決定の速さだと思う。「なぜこの機能が必要か」を直接聞けるし、技術的な判断について相談できる相手がいる。

一方で、直接取引には別のコストが伴う。営業・提案・見積もり・契約書の交渉・入金管理、これを全部自社でやることになる。「案件が降ってくる」という意味での楽さは、代理店経由の仕事にはある。

今の判断基準は2つ:中間マージンの層が何段あるか把握できること技術的な判断を尊重してもらえる可能性があるか。この2点が怪しい案件は、たとえ単価が魅力的でも断るようにした。そうすると受ける案件は減るけど、一件一件の密度と実入りは上がった気がしている。

まとめ

  • 広告代理店経由の受託は2〜3層の中抜き構造になることが多く、エンドクライアントが出した予算の20%以下しか手元に来ないケースもある(そして最上位がいくら積んでいるかは分からない)
  • スケジュールの感覚のズレは構造的なもので、エンジニアリングコストの見積もりを理解できる人間が中間にいないことが原因だと思う
  • 「できない」「リスクがある」という技術的な主張が通らない文化は、作り手としてのリスクにもつながる
  • 直接取引には営業コストが伴うが、中間マージンと技術的な裁量の面では明確に違う

2026/05/13

C# async/awaitの落とし穴と使いこなしコツ

C# async/awaitの落とし穴と使いこなしコツ——「待たない」設計で変わること

async/awaitはC# 5から使えるようになって久しいけど、「とりあえず付ければ非同期になる」という理解のまま書くと、同期コードより遅くなったり、デッドロックで詰んだりする。自分もそういうハマり方を何度かやった。

この記事では、async/awaitで実際に踏みやすい罠と、「待たない」設計のコツを整理する。.NET 10(C# 14)を前提にしているが、考え方自体は.NET 6以降であれば共通だ。

罠その1——awaitを直列に並べると順次実行になる

いちばんよくあるミスはこれだ。複数の非同期処理を「なんとなく」awaitで並べると、前の処理が完全に終わるまで次が始まらない。

// ダメな例: 合計300ms かかる
var user = await GetUserAsync(userId);       // 100ms
var orders = await GetOrdersAsync(userId);   // 100ms
var address = await GetAddressAsync(userId); // 100ms

3つのAPIコールに依存関係がないのに、直列に待つだけで300msかかる。Task.WhenAllを使えば並列実行できて、合計は最大の100msに近づく。

// 正しい例: 約100ms で終わる
var userTask    = GetUserAsync(userId);
var ordersTask  = GetOrdersAsync(userId);
var addressTask = GetAddressAsync(userId);

await Task.WhenAll(userTask, ordersTask, addressTask);

var user    = userTask.Result;    // WhenAll後なので完了済み
var orders  = ordersTask.Result;
var address = addressTask.Result;

Task.WhenAllのあとで.Resultを使っているのは、この時点では既にタスクが完了しているから安全だ(デッドロックの心配はない)。もちろん各タスクをawaitしてもよい。

例外が複数ある場合の注意

Task.WhenAllはいずれかのタスクが例外を投げるとAggregateExceptionとして集約する。awaitするとその中の最初の例外がアンラップされて投げられる。複数タスクすべての例外を取り出したい場合は、task.Exceptionを個別に確認する必要がある。

罠その2——.Result/.Waitで同期的に待つとデッドロックする

ASP.NET Core以前の旧来のASP.NETや、WPF/WinFormsのUIスレッドで.Result.Wait()を呼ぶと、デッドロックが起きることがある。

// UIスレッドや旧ASP.NETコンテキストでは危険
var result = GetDataAsync().Result;   // デッドロックの可能性
var result = GetDataAsync().Wait();   // 同上

仕組みを簡単に言うと、awaitは元のSynchronizationContextに戻ろうとするが、.Resultでそのスレッドがブロックされているため、お互いが待ち合って詰む。

ASP.NET Core(.NET 5以降)はSynchronizationContextがないためこの問題は発生しない。ただし、ライブラリコードとして書く場合や古い環境をサポートする場合は要注意だ。回避策は2つある。

// 方法1: ConfigureAwait(false) でコンテキストへの復帰を抑制
var data = await GetDataAsync().ConfigureAwait(false);

// 方法2: 非同期メソッドチェーンを維持してawaitで待つ(本質的な解決)
public async Task ProcessAsync()
{
    return await GetDataAsync();
}

ライブラリを書く場合はConfigureAwait(false)を付けるのがベストプラクティスとされている。呼び出し元のコンテキストに縛られないため、デッドロックのリスクを下げられる。

罠その3——async voidで例外が消える

async voidはイベントハンドラ以外では使うべきじゃない。メソッドが例外を投げてもキャッチする手段がなく、プロセスがクラッシュするか、例外が無視される。

// ダメな例
async void LoadDataAsync()
{
    var data = await FetchAsync();  // 例外が飛んでも呼び元でキャッチできない
    Process(data);
}

// 正しい例: async Task にする
async Task LoadDataAsync()
{
    var data = await FetchAsync();
    Process(data);
}

// イベントハンドラだけはasync voidが許容される
private async void Button_Click(object sender, EventArgs e)
{
    await LoadDataAsync();  // ここでキャッチできる形にする
}

async voidを書きたくなったら、async Taskに変えて、呼び出し元でawaitするように設計を直すのが先だ。

fire and forget——意図的に「待たない」パターン

ログ送信や通知など、「結果は要らないし失敗してもいい」処理を意図的に待たないことがある。async voidに似ているが、明示的に非同期タスクを手放す書き方を使う。

// awaitしないとコンパイラ警告が出る場合は _ で受ける
_ = SendTelemetryAsync(eventData);

// または Task.Run でスレッドプールに委ねる
_ = Task.Run(() => SendTelemetryAsync(eventData));

fire and forgetは例外が握りつぶされるリスクがある。本番コードで使う場合は、タスク内でtry-catchして例外をログに残す処理を入れておくことが多い。

private static async Task FireAndForgetAsync(Func action)
{
    try
    {
        await action();
    }
    catch (Exception ex)
    {
        // ログに残して握りつぶす
        logger.LogError(ex, "Fire-and-forget task failed");
    }
}

// 使い方
_ = FireAndForgetAsync(() => SendTelemetryAsync(eventData));

CancellationTokenを渡す

非同期メソッドを書くならCancellationTokenを受け取れる設計にしておく。HTTPリクエストのキャンセル・タイムアウト・アプリシャットダウン時のクリーンアップが、これがないと一切効かない。

// キャンセルトークンを受け取るシグネチャ
public async Task GetOrderAsync(Guid id, CancellationToken cancellationToken = default)
{
    return await _repository.FindAsync(id, cancellationToken);
}

// ASP.NET Coreではコントローラから自動で渡される
[HttpGet("{id}")]
public async Task Get(Guid id, CancellationToken cancellationToken)
{
    var order = await _service.GetOrderAsync(id, cancellationToken);
    return Ok(order);
}

引数の末尾にCancellationToken cancellationToken = defaultとデフォルト値付きで追加しておくと、既存の呼び出しコードを壊さずに後から対応できる。

まとめ

  • 依存関係のない複数の非同期処理はTask.WhenAllで並列化する。直列awaitは遅い
  • .Result/.Wait()による同期ブロックはデッドロックの原因になる。ライブラリコードではConfigureAwait(false)を付ける
  • async voidはイベントハンドラ以外では使わない。例外が捕捉できなくなる
  • fire and forgetは_ =で明示し、内部でtry-catchしてログを残す
  • 非同期メソッドにはCancellationTokenを通しておく

C#のプロパティ宣言の最新事情(fieldキーワード・initrequired)についてはこちら。→ C# 2026年のプロパティ宣言まとめ

2026/05/11

C# 2026年のプロパティ宣言——fieldキーワードとget setの書き方まとめ

C# 2026年のプロパティ宣言——fieldキーワードとget/setの書き方まとめ

C#のプロパティ宣言は、バージョンを追うごとにどんどん短く書けるようになっている。古いコードを読んでいると、バッキングフィールドを自分で宣言してgetとsetでそれを返すだけ、という10行近いボイラープレートが普通に出てくる。2026年時点の書き方と比べると、同じことをやっているのに別の言語みたいに見える。

この記事では、C# 14(.NET 10)までの機能を踏まえて、2026年時点でプロパティをどう書くべきかを整理した。特にfieldキーワードは実際に使い始めたら手放せなくなったので、そこを中心に書いている。

古い書き方——バッキングフィールドを自分で管理する

昔からあるパターンはこれだ。バリデーションや副作用が必要なプロパティは、プライベートフィールドを自分で宣言して、getとsetの中で操作する。

public class User
{
    private string _name;

    public string Name
    {
        get => _name;
        set
        {
            if (string.IsNullOrWhiteSpace(value))
                throw new ArgumentException("名前は空にできません");
            _name = value;
        }
    }
}

動くし読めるけど、_nameという変数がNameプロパティのためだけに存在していて、それが当たり前のように積み重なっていく。クラスが大きくなると、どのフィールドがどのプロパティに対応しているかを目で追う必要が出てくる。

C# 14のfieldキーワード——バッキングフィールドが不要になる

.NET 10(C# 14)で追加されたfieldキーワードを使うと、コンパイラが生成するバッキングフィールドにアクセサの中から直接アクセスできる。上のコードは次のように書き直せる。

public class User
{
    public string Name
    {
        get => field;
        set
        {
            if (string.IsNullOrWhiteSpace(value))
                throw new ArgumentException("名前は空にできません");
            field = value;
        }
    }
}

_nameの宣言が消えた。fieldはコンパイラが自動生成するバッキングフィールドへの参照で、外から直接触れない。オートプロパティとカスタムアクセサの中間点みたいな位置づけで、「単純に値を返すだけじゃないけど、専用のフィールドを宣言するほどでもない」ケースにちょうど合う。

片方だけカスタムにすることもできる

setだけロジックを入れて、getはそのまま返す、という使い方が実際には多い。

public string Email
{
    get;
    set
    {
        if (!value.Contains('@'))
            throw new FormatException("メールアドレスの形式が不正です");
        field = value.ToLowerInvariant();
    }
}

getはオートプロパティのまま、setだけカスタムにできる。これがfieldキーワードの使い方としていちばん出番が多いんじゃないかと思っている。

initアクセサ——オブジェクト初期化時だけ書き込みを許可する

C# 9で追加されたinitは、コンストラクタやオブジェクト初期化子から設定できるけど、その後は変更できないプロパティを作れる。setの代わりにinitと書くだけだ。

public class Order
{
    public Guid Id { get; init; } = Guid.NewGuid();
    public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
    public string CustomerId { get; init; }
}

// 初期化子でセット可能
var order = new Order { CustomerId = "C001" };

// これはコンパイルエラー
order.CustomerId = "C002";

レコード型と組み合わせることが多いけど、普通のクラスで「作ったあとは変えない値」を持ちたいときにも使える。

requiredモディファイア——初期化を強制する

C# 11で入ったrequiredは、オブジェクト初期化子でそのプロパティを設定しないとコンパイルエラーにしてくれる。

public class Product
{
    public required string Name { get; init; }
    public required decimal Price { get; init; }
    public string Description { get; init; }  // 任意
}

// Nameを省略するとコンパイルエラー
var p = new Product { Price = 1000 };  // エラー: Nameが設定されていない

// 正しい使い方
var p = new Product { Name = "コーヒー豆", Price = 1000 };

コンストラクタを書かなくてもプロパティの初期化漏れを防げる。DTO・モデルクラスで特に重宝する。

プライマリコンストラクタ——C# 12で普通のクラスにも

C# 12でレコード型だけでなく通常のクラスにもプライマリコンストラクタが使えるようになった。DIでよくある「コンストラクタでサービスを受け取ってフィールドに代入する」パターンが短く書ける。

// 以前
public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly ILogger _logger;

    public OrderService(IOrderRepository repository, ILogger logger)
    {
        _repository = repository;
        _logger = logger;
    }
}

// C# 12以降
public class OrderService(IOrderRepository repository, ILogger logger)
{
    public async Task GetAsync(Guid id)
    {
        logger.LogInformation("Getting order {Id}", id);
        return await repository.GetByIdAsync(id);
    }
}

フィールド宣言が消えてコンストラクタも消えた。パラメータはクラス全体のスコープで使える。ただし、プライマリコンストラクタのパラメータはプロパティではないので、外から参照させたい場合は別途プロパティとして定義する必要がある点は注意だ。

2026年時点での使い分け

整理するとこうなる。

パターン使う場面
{ get; set; }単純なプロパティ。バリデーションなし
{ get; init; }初期化後は変更させたくないプロパティ
required { get; init; }初期化必須 + 変更不可。DTOやモデル
fieldキーワードsetにバリデーション・変換が必要。バッキングフィールドは不要
バッキングフィールド手動宣言フィールドを他のメソッドからも参照する場合

fieldキーワードが追加されたことで、手動バッキングフィールドが必要なケースはかなり限られてきた。大半のケースはfieldで書けるし、その方が宣言が少なくてクラスがスッキリする。

まとめ

  • C# 14のfieldキーワードでバッキングフィールド宣言が不要になった。setのバリデーションにfield = valueと書くだけ
  • init(C# 9)で初期化後に変更不可のプロパティを宣言できる
  • required(C# 11)でプロパティの初期化漏れをコンパイル時に検出できる
  • プライマリコンストラクタ(C# 12)でDIのボイラープレートを大幅に削減できる

2026/05/08

C# WPFのデータバインディングで日本語プロパティ名を使ったら何が起きるか

C# WPFのデータバインディングで日本語プロパティ名を使ったら何が起きるか

「C#って日本語の変数名が使えるらしい」という話は聞いたことがあった。でも実際にWPFのデータバインディングで試したことはなかった。{Binding 名前}みたいに書いて本当に動くのか。動いたとして、実用に耐えるのか。半分好奇心、半分「どうせ落とし穴があるだろう」という気持ちで検証してみた。

そもそもC#は日本語識別子を正式にサポートしている

C#の言語仕様(ECMA-334)では、識別子にUnicode文字を使えると明記されている。つまり変数名・プロパティ名・クラス名に漢字・ひらがな・カタカナをそのまま使えるのは仕様として保証された挙動で、バグでも裏技でもない。

// これは正式に有効なC#コード
public class 利用者
{
    public string 名前 { get; set; }
    public int 年齢 { get; set; }
    public string メールアドレス { get; set; }
}

Visual Studio 2022でも普通にIntelliSenseが効く。補完候補に「名前」「年齢」が出てくる様子はなかなかシュールだけど、ビルドは通る。

WPFのXAMLバインディングでも使えるか

ViewModelに日本語プロパティを持たせてXAMLからバインドしてみた。

// ViewModel
public class 利用者ViewModel : INotifyPropertyChanged
{
    private string _名前;
    public string 名前
    {
        get => _名前;
        set
        {
            _名前 = value;
            OnPropertyChanged(nameof(名前));
        }
    }

    private int _年齢;
    public int 年齢
    {
        get => _年齢;
        set
        {
            _年齢 = value;
            OnPropertyChanged(nameof(年齢));
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged(string name)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
<!-- XAML -->
<StackPanel>
    <TextBox Text="{Binding 名前, UpdateSourceTrigger=PropertyChanged}" />
    <TextBox Text="{Binding 年齢, UpdateSourceTrigger=PropertyChanged}" />
    <TextBlock Text="{Binding 名前}" />
</StackPanel>

結果は——動く。{Binding 名前}でちゃんとViewModelの名前プロパティにバインドされて、TextBoxに入力した値がリアルタイムで反映される。WPFのバインディングは内部でリフレクションを使ってプロパティ名を文字列で解決しているので、Unicode名でも問題ない。

nameof()も日本語プロパティで機能する

INotifyPropertyChangedの実装でnameof(名前)と書けるのが地味に重要で、プロパティ名のタイポをコンパイル時に検出できる。ハードコードで"名前"と文字列を渡す実装より安全だ。

// nameof()は日本語プロパティでも正しく機能する
OnPropertyChanged(nameof(名前)); // → "名前" という文字列になる

実際に困った点

IME切り替えのストレス

コードを書くとき、英字と日本語の間で頻繁にIMEを切り替えることになる。public string まで英字で書いて、プロパティ名を入力するために日本語入力に切り替えて、また { get; set; }に戻す、という繰り返しが思ったより煩わしい。慣れの問題かもしれないけど、英語コードの中に日本語が挟まるリズムが崩れる感覚は否めない。

バックエンドとの連携がつらい

APIのレスポンスをViewModelにマッピングするとき、JSONのキーは英語("name""age")でプロパティは日本語、という状況になる。System.Text.JsonやNewtonsoft.Jsonで属性を付ければ対応できるけど、一段階手間が増える。

using System.Text.Json.Serialization;

public class 利用者
{
    [JsonPropertyName("name")]
    public string 名前 { get; set; }

    [JsonPropertyName("age")]
    public int 年齢 { get; set; }
}

StackOverflowやGitHub Copilotとの相性

英語のプロパティ名なら、同じ構造のコードがStackOverflowにいくらでもある。日本語プロパティ名のコードは検索してもほぼ出てこないし、Copilotの補完も精度が落ちる。サンプルコードをそのまま流用しにくい。

使ってみた正直な感想

「動く」と「実用的」は別の話だと改めて思った。一人で書く小さなツールで、日本語のビジネスドメインを扱う場合は選択肢としてアリかもしれない。「顧客番号」「請求金額」「納期」みたいな用語が英訳すると意味が曖昧になるドメインでは、日本語のまま書いた方がコードと仕様書の対応がはっきりする、という考え方はわかる。

ただチームで使う場合や、外部のAPIやライブラリとの連携が多い場合は苦労の方が大きいと思う。英語識別子のコードベースに日本語が混ざると、どちらのルールで書けばいいか迷う人が出てくる。「愚行」と言い切るほどではないけど、採用するなら明確な理由と運用ルールが必要だ。

まとめ

  • C#は仕様としてUnicode識別子をサポートしており、日本語プロパティ名は正式に有効
  • WPFのデータバインディング({Binding 名前}形式)も日本語プロパティで正常に動作する
  • nameof()INotifyPropertyChanged・XAMLバインディングすべて問題なく機能した
  • 実用上の課題はIME切り替え、JSON属性の追加、英語サンプルコードとの乖離
  • ドメイン用語が日本語で完結する一人プロジェクトなら選択肢になりうる。チーム開発では要検討

GolangよりC#記事が伸びた話——技術ブログのアクセス分析

GolangよりC#記事が伸びた——技術ブログのアクセスを分析してみたら意外な結果が出た

技術ブログを書き始めたとき、Golang関連の記事が一番読まれると思っていた。モダンで注目度が高くて、自分もよく書く言語だったから、ネタとしても自然だと思っていたんだけど、実際にSearch Consoleを見てみたら全然そうじゃなかった。C#の記事の方がずっとアクセスを集めていた。しかもGolang記事との差が思ったより大きかった。

なぜかが気になって調べ始めたのがこの記事のきっかけだ。

Golang記事は伸びなかった——数字で見るとはっきりわかる

Golang関連の記事をいくつか書いた後、Google Search Consoleで表示回数とクリック数を確認した。フォルダ構成の話やGin + GORMを使ったAPIの構成など、自分が実際に使った内容で書いたつもりだったけど、表示回数がそもそも少ない。検索結果に出ていないというより、そもそも検索しているユーザーの絶対数が少ないんだと思う。

一方でC#の記事は、書いてからそれほど時間が経っていないのにSearch Consoleの表示回数がGolangの記事より多かった。クリック率はどちらも似たようなものだったので、単純に検索ボリュームの差が出ている形だ。

なぜC#の方が検索ボリュームが大きいのか

日本市場を考えると、C#の根強さは納得がいく。以前、受託の案件で.NETのシステムを触ったことがあるんだけど、そのあたりの現場ではC#がごく当たり前に使われていた。日本のSIerはJavaが主流とはいえ、.NET/C#も業務系では広く生き残っていて、「C# ○○のやり方」という実務検索がコンスタントに発生し続けている。

Unityも大きい。ゲーム開発でC#を使う人が多くて、「Unity C# スクリプト」系の検索はボリュームがかなりある。ゲーム開発系の読者は技術ブログをよく読む層で、検索行動も活発だ。

Golangはというと、採用している企業はメルカリやCyberAgentなどのWeb系大手が多く、その層はQiitaや英語圏の記事を読む傾向がある。日本語でGolangを検索するユーザーは相対的に少ないかもしれない。公式ドキュメントやGitHub上の情報が英語で十分そろっているので、日本語記事を探すモチベーションが下がるのかもしれない。

Google Trendsで比べると差は明確

Google Trendsで「C#」と「Go 言語」または「Golang」を日本向けで比較すると、C#の方が継続的に検索されているのがわかる。Golangの検索量が急増したタイミングはあるものの、継続的なベースラインで見るとC#の方が安定して多い。

これはGolangが悪い技術だということじゃなくて、単純にユーザー数と歴史の差だと思っている。C#は2002年のリリース以来20年以上の蓄積がある。検索キーワードの種類も多いし、「困った人が検索する」シナリオがそれだけ多い。Golangは普及し始めたのも最近で、コミュニティの規模感がまだ全然違う。

技術ブログで「伸びるネタ」を選ぶ考え方

この経験から、記事ネタを選ぶときの判断軸を少し変えた。「自分が書きたいこと」だけで選ぶんじゃなくて、「その言語・技術を日本語で検索しているユーザーがどれくらいいるか」を先に確認するようになった。

確認する方法は簡単で、Google Trendsで気になる技術名を比較する、あるいはGoogle検索でキーワードを打ってみてサジェストにどんなキーワードが出てくるかを見る。サジェストが豊富なキーワードは検索ボリュームが大きいことが多い。

ただ、「検索ボリュームがあるテーマだけ書く」というのも違う気がしている。自分が実際に使っていない技術を書いても内容が薄くなるし、読んでいてすぐわかる。ボリュームがある技術を扱いつつ、自分の実務経験を乗せることで情報に厚みを出す、というのが今の方針だ。

C#でも書けるネタを探す

自分はGolangをメインに使っているけど、C#を完全に触ったことがないわけじゃない。過去に.NETの案件を触ったことがあるし、Unityも少しさわっている。そういう経験を引っ張り出して、C#記事のネタにしてみたら思ったより素直に書けた。「Go使いが初めてC#を触ったときの話」という切り口で書いたら、Goユーザー向けの比較記事になって意外とハマった。

「自分のメイン技術じゃないから書けない」という思い込みがあったけど、ちょっとさわった経験でも「初心者目線で書く」という切り口なら価値が出る。C#をゴリゴリ使っているベテランが書く記事と、「Golangメインで初めてC#を触ったエンジニアが書く記事」は読者が違う。

Golangを諦めるわけじゃない

C#が伸びたからといって、Golang記事をやめるつもりはない。Golangは自分が一番詳しい技術で、書ける内容の深さが違う。アクセスの絶対数は少なくても、ニッチな検索キーワードで上位を取れる可能性はある。ロングテールで拾う戦略だ。

それにGolang記事はこれから増える可能性も十分ある。国内でのGoの採用は増えていて、日本語で検索する人の数は少しずつ増えているはずだ。今のうちに記事を積み上げておくことで、ボリュームが増えたときに上位表示できる状態を作っておく、という考え方もできる。

結局のところ、自分の経験でいちばん腹落ちしたのは「書いてみないとわからない」という当たり前のことだった。想定と結果が違うことはよくあって、そのギャップから次の方針が見えてくる。Search Consoleは定期的に見る価値があると改めて思っている。

まとめ

  • 日本語での検索ボリュームはC#がGolangを上回る。記事を書く前にGoogle TrendsとGoogleサジェストで確認する価値がある
  • C#はSIer業務システム・Unityの層が厚く、「困って検索する人」が継続的に発生している
  • メイン技術でなくても、実体験があれば「初心者目線の記事」として書ける。ベテランとは読者層が違う
  • Golang記事はロングテール狙いで積み上げ続ける。検索ボリュームは少しずつ増えているはずだ

2026/05/07

GolangでClean ArchitectureなAPIを作るフォルダ構成(Gin + GORM)

GolangでClean ArchitectureなAPIを作るフォルダ構成(Gin + GORM)

Ginで新しいAPIを作り始めるとき、最初に迷うのがフォルダ構成だった。とりあえず動かすだけならmain.goに全部書いてしまえばいいんだけど、それをやると後で必ず後悔する。外部APIとの連携が増えてきたり、ハンドラが10本を超えたあたりで一気に読めなくなる。

今はClean Architectureの考え方をベースにpkg/以下をレイヤーごとに切る構成に落ち着いている。handler → usecase → repository/gatewayという一方向の依存で、内側の層が外側を知らない設計だ。DB操作にはGORM、外部サービスへの接続はgatewayパターンで抽象化している。これはPorts and Adaptersとも呼ばれる考え方で、ローカル環境ではgatewaymockに差し替えることで外部サービスなしに動かせる。

全体のディレクトリ構成

まず全体像から。

myapp/
├── main.go
├── Makefile
├── docker/
│   ├── docker-compose.yml
│   └── docker-compose.test.yml
├── .env.local
├── .env.staging
├── .env.production
├── pkg/
│   ├── handler/
│   │   ├── user_handler.go
│   │   └── item_handler.go
│   ├── gateway/
│   │   ├── db.go
│   │   ├── secrets.go
│   │   ├── payment_gateway.go
│   │   ├── payment_gateway_mock.go
│   │   ├── payment_gateway_test.go
│   │   ├── notification_gateway.go
│   │   ├── notification_gateway_mock.go
│   │   └── notification_gateway_test.go
│   ├── usecase/
│   │   └── user_usecase.go
│   ├── repository/
│   │   └── user_repository.go
│   ├── domain/
│   │   └── user.go
│   └── config/
│       └── config.go
├── migrations/
│   ├── 000001_create_users.up.sql
│   ├── 000001_create_users.down.sql
│   └── 000002_create_items.up.sql
│   └── 000002_create_items.down.sql
├── go.mod
└── go.sum

ルートのmain.goがエントリポイントで、ここでDIをまとめてGinを起動する。pkg/以下に全てのロジックが収まる構成で、DBも「外部リソースへの接続」としてgateway/に置く。Stripeや通知サービスへの接続と同じ扱いで、gateway/db.goがGORMの接続を返す。

pkg/handler — ルーティングとリクエスト処理

handler/はリクエスト・レスポンスの変換を担う。ルーティングはmain.goに書くので、ハンドラは処理だけに集中できる。ビジネスロジックはusecaseに渡すだけにする。

// pkg/handler/user_handler.go
package handler

import (
    "net/http"

    "github.com/gin-gonic/gin"
    "myapp/pkg/usecase"
)

type UserHandler struct {
    userUsecase usecase.UserUsecase
}

func NewUserHandler(uu usecase.UserUsecase) *UserHandler {
    return &UserHandler{userUsecase: uu}
}

func (h *UserHandler) GetUser(c *gin.Context) {
    id := c.Param("id")
    user, err := h.userUsecase.GetByID(c.Request.Context(), id)
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
        return
    }
    c.JSON(http.StatusOK, user)
}

ハンドラごとにファイルを分けているので、user_handler.goが肥大化してきたらitem_handler.goのように追加するだけで済む。ルーティングはmain.goに集めているので、エンドポイントの一覧をそこで把握できる。

pkg/gateway — 外部サービスとの連携

外部APIや外部サービスとのやり取りはgateway/に閉じ込める。決済サービス、通知サービス、外部のマスターデータAPIなど、自分たちが管理していないシステムとの境界がここだ。

gatewayの中身はインターフェースと実装に分けておくと、テスト時にモックに差し替えやすい。

// pkg/gateway/payment_gateway.go
package gateway

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
)

type PaymentGateway interface {
    Charge(ctx context.Context, amount int, token string) error
}

type stripeGateway struct {
    apiKey  string
    baseURL string
    client  *http.Client
}

func NewStripeGateway(apiKey string) PaymentGateway {
    return &stripeGateway{
        apiKey:  apiKey,
        baseURL: "https://api.stripe.com/v1",
        client:  &http.Client{},
    }
}

func (g *stripeGateway) Charge(ctx context.Context, amount int, token string) error {
    // Stripe APIへのリクエスト処理
    _ = json.NewEncoder
    _ = fmt.Sprintf
    return nil
}

ここがgatewayと呼ぶ理由は、外の世界への「玄関口」として責務を明確にするためだ。usecaseはgatewayのインターフェースだけを知っていればよくて、裏でStripeを使おうがPayPayを使おうがusecaseは気にしない。外部サービスの乗り換えや追加がしやすい構造になる。

ローカル用のmock実装(_mockファイル)

ローカル環境では外部サービスを呼ばず、同じインターフェースを実装したmockが固定値を返す。ファイル名はpayment_gateway_mock.goのように_mockサフィックスをつけて本実装と並べておく。

// pkg/gateway/payment_gateway_mock.go
package gateway

import (
    "context"
    "log"
)

type mockPaymentGateway struct{}

func NewMockPaymentGateway() PaymentGateway {
    return &mockPaymentGateway{}
}

func (m *mockPaymentGateway) Charge(ctx context.Context, amount int, token string) error {
    log.Printf("[mock] PaymentGateway.Charge: amount=%d token=%s → success", amount, token)
    return nil
}

本実装と同じパッケージに置くのでインターフェースをそのまま参照できる。main.goでenv値を見て差し替えるだけで、usecaseもhandlerも変更不要になる。

単体テストはGoの標準規則に従って_test.goサフィックスをつける。payment_gateway_test.goのように本実装ファイルと対応した名前にしておくと、どのファイルのテストか迷わない。テスト内でmockを使いたい場合は同パッケージのpayment_gateway_mock.goをそのまま参照できる。

// pkg/gateway/payment_gateway_test.go
package gateway_test

import (
    "context"
    "testing"

    "myapp/pkg/gateway"
)

func TestMockPaymentGateway_Charge(t *testing.T) {
    gw := gateway.NewMockPaymentGateway()
    if err := gw.Charge(context.Background(), 1000, "tok_test"); err != nil {
        t.Errorf("unexpected error: %v", err)
    }
}
// main.go — gatewayの差し替え箇所
var paymentGw gateway.PaymentGateway
if cfg.Env == "local" {
    paymentGw = gateway.NewMockPaymentGateway()
} else {
    paymentGw = gateway.NewStripeGateway(cfg.StripeKey)
}

pkg/domain — GORMモデルの定義

domain/にはGORMのモデル定義を置く。gorm.Modelを埋め込むとIDCreatedAtUpdatedAtDeletedAt(ソフトデリート用)が自動で付いてくるので、基本的にはこれを使っている。

// pkg/domain/user.go
package domain

import (
    "context"

    "gorm.io/gorm"
)

type User struct {
    gorm.Model
    Name  string `gorm:"not null"`
    Email string `gorm:"uniqueIndex;not null"`
}

type UserRepository interface {
    FindByID(ctx context.Context, id uint) (*User, error)
    Create(ctx context.Context, user *User) error
}

リポジトリのインターフェースもここに定義しておく。これによってusecase/domain.UserRepositoryだけを知っていればよく、GORMの実装の詳細と切り離せる。

pkg/repository — GORMによるDB操作の実装

repository/がGORMを直接使う唯一の層で、ここ以外でGORMのコードは書かない。*gorm.DBを受け取って各メソッドを実装する。

// pkg/repository/user_repository.go
package repository

import (
    "context"
    "errors"

    "gorm.io/gorm"
    "myapp/pkg/domain"
)

type userRepository struct {
    db *gorm.DB
}

func NewUserRepository(db *gorm.DB) domain.UserRepository {
    return &userRepository{db: db}
}

func (r *userRepository) FindByID(ctx context.Context, id uint) (*domain.User, error) {
    var user domain.User
    result := r.db.WithContext(ctx).First(&user, id)
    if errors.Is(result.Error, gorm.ErrRecordNotFound) {
        return nil, nil
    }
    return &user, result.Error
}

func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
    return r.db.WithContext(ctx).Create(user).Error
}

db.WithContext(ctx)でコンテキストを必ず渡す。タイムアウトやキャンセルをDB層まで伝播させるためで、省略するとリクエストがキャンセルされてもクエリが走り続ける。gorm.ErrRecordNotFoundはレコードが見つからない場合のエラーなので、errors.Isで判定してnilを返すか上位層に任せるかを決める。

ローカル環境のDB — docker-compose で用意する

ローカルではDBをDockerで立てる。docker/フォルダにdocker-compose.ymlをまとめて、ルートから-fで指定して起動する。

docker compose -f docker/docker-compose.yml up -d
# docker/docker-compose.yml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: myapp
    ports:
      - "5432:5432"

.env.localDATABASE_DSNはこのコンテナに向ける。

# .env.local の DATABASE_DSN
DATABASE_DSN=postgres://user:password@localhost:5432/myapp?sslmode=disable

pkg/gateway/db.go — GORM接続の初期化

DBは「外部リソースへの接続」なので、Stripeや通知サービスと同じくgateway/に収める。db.goがGORMの接続を生成して返すだけのシンプルな役割を持つ。

// pkg/gateway/db.go
package gateway

import (
    "fmt"

    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

func NewDB(dsn string) (*gorm.DB, error) {
    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        return nil, fmt.Errorf("db connection failed: %w", err)
    }
    return db, nil
}

DB接続の生成だけに絞る。テーブル作成はgolang-migrateで管理するのでAutoMigrateは使わない。

migrations — golang-migrateでテーブル管理

テーブルのDDLはバージョン管理されたSQLファイルで管理する。golang-migrateはup/downのペアで変更を管理でき、適用済みのバージョンをDBに記録してくれる。CLIのインストール方法はREADMEに記載する。

ファイル名は連番_説明.up.sql/連番_説明.down.sqlの形式でmigrations/に置く。

-- migrations/000001_create_users.up.sql
CREATE TABLE users (
    id         BIGSERIAL PRIMARY KEY,
    name       TEXT NOT NULL,
    email      TEXT NOT NULL UNIQUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at TIMESTAMPTZ
);
-- migrations/000001_create_users.down.sql
DROP TABLE IF EXISTS users;

マイグレーションはアプリ起動時に自動実行しない。Makefileのターゲットで明示的に実行する。migrate-up-testはテスト用コンテナ(port 5433)向けのターゲットで、テスト実行前に1回だけ手動で叩く。

# Makefile
DSN      ?= $(DATABASE_DSN)
TEST_DSN ?= postgres://user:password@localhost:5433/testdb?sslmode=disable

migrate-up:
	migrate -path migrations -database "$(DSN)" up

migrate-up-test:
	migrate -path migrations -database "$(TEST_DSN)" up

migrate-down:
	migrate -path migrations -database "$(DSN)" down 1

migrate-version:
	migrate -path migrations -database "$(DSN)" version

migrate-create:
	migrate create -ext sql -dir migrations -seq $(name)

テーブル定義を本番・開発・テストで一致させるために、どの環境でも同じSQLファイルをgolang-migrateで適用する。AutoMigrateはGORMのstructから推測してDDLを生成するので、意図しないカラム変更が起きる可能性がある。SQLを自分で書いて管理する方が変更の意図が明確になる。

pkg/config — 環境別の設定読み込み

機密情報(DB接続文字列・APIキーなど)はAWS Secrets Managerに登録して取得する。ポート番号やGinのモードなどの非機密値は環境ごとに1ファイルで管理する。各ファイルはその環境の非機密値を全て持ち、gitにコミットして問題ない内容だけ書く。

# .env.local  ※gitにコミットしない(機密値を含む)
PORT=:8080
GIN_MODE=debug
DATABASE_DSN=postgres://user:password@localhost:5432/myapp?sslmode=disable
STRIPE_KEY=sk_test_xxxxxxxxxxxx
# .env.staging  ※gitにコミットするファイル(非機密値のみ)
PORT=:8080
GIN_MODE=debug
AWS_REGION=ap-northeast-1
SECRET_NAME=myapp/staging/secrets
# .env.production  ※gitにコミットするファイル(非機密値のみ)
PORT=:8080
GIN_MODE=release
AWS_REGION=ap-northeast-1
SECRET_NAME=myapp/production/secrets

AWS Secrets Managerへの接続は他の外部連携と同じくgateway/に置く。シークレットはJSON形式で登録しておき、取得後にパースして使う。

go get github.com/aws/aws-sdk-go-v2/config
go get github.com/aws/aws-sdk-go-v2/service/secretsmanager
// pkg/gateway/secrets.go
package gateway

import (
    "context"
    "encoding/json"
    "fmt"

    "github.com/aws/aws-sdk-go-v2/aws"
    awsconfig "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
)

type Secrets struct {
    DatabaseDSN string `json:"DATABASE_DSN"`
    StripeKey   string `json:"STRIPE_KEY"`
}

func LoadSecrets(ctx context.Context, region, secretName string) (*Secrets, error) {
    cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
    if err != nil {
        return nil, fmt.Errorf("failed to load AWS config: %w", err)
    }

    client := secretsmanager.NewFromConfig(cfg)
    result, err := client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
        SecretId: aws.String(secretName),
    })
    if err != nil {
        return nil, fmt.Errorf("failed to get secret: %w", err)
    }

    var secrets Secrets
    if err := json.Unmarshal([]byte(*result.SecretString), &secrets); err != nil {
        return nil, fmt.Errorf("failed to parse secret: %w", err)
    }

    return &secrets, nil
}

configはこのgatewayを呼び出してシークレットを取得し、Configに詰めて返す。Loadはcontextを受け取る形にして、タイムアウトを外から制御できるようにしておく。

// pkg/config/config.go
package config

import (
    "context"
    "fmt"
    "os"

    "github.com/joho/godotenv"
    "myapp/pkg/gateway"
)

type Config struct {
    Env       string
    DSN       string
    StripeKey string
    Port      string
}

func Load(ctx context.Context) (*Config, error) {
    env := os.Getenv("APP_ENV")
    if env == "" {
        env = "local"
    }

    envFile := fmt.Sprintf(".env.%s", env)
    _ = godotenv.Load(envFile)

    // local は .env.local から直接読む(Docker上のDBを使うため AWS SM 不要)
    if env == "local" {
        return &Config{
            Env:       env,
            DSN:       os.Getenv("DATABASE_DSN"),
            StripeKey: os.Getenv("STRIPE_KEY"),
            Port:      getEnvOrDefault("PORT", ":8080"),
        }, nil
    }

    // staging / production は AWS Secrets Manager から機密値を取得
    secrets, err := gateway.LoadSecrets(
        ctx,
        os.Getenv("AWS_REGION"),
        os.Getenv("SECRET_NAME"),
    )
    if err != nil {
        return nil, fmt.Errorf("failed to load secrets: %w", err)
    }

    return &Config{
        Env:       env,
        DSN:       secrets.DatabaseDSN,
        StripeKey: secrets.StripeKey,
        Port:      getEnvOrDefault("PORT", ":8080"),
    }, nil
}

func getEnvOrDefault(key, defaultVal string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return defaultVal
}

localのときはAWS SMを呼ばずに.env.localから直接読む。DBなどのミドルウェアはDockerで用意するので、DATABASE_DSNにはDockerコンテナのホストを書けばいい。.env.localは機密値を含むので.gitignoreに追加してリポジトリに含めない。staging/productionは非機密値だけを各envファイルに書いてgitで管理し、機密値はAWS SMから取得する。シークレット名を環境ごとに分けておくと(myapp/staging/secretsmyapp/production/secrets)、環境を間違えてもシークレットが混ざらない。

pkg/usecase — ビジネスロジック

usecase/はビジネスロジックの層で、handlerからの入力を受けてrepositoryやgatewayを組み合わせて処理を行う。「ユーザーを作成する」という操作がバリデーション→DB保存→通知送信という手順なら、その流れをusecaseに書く。GORMの*gorm.DBはここには渡さない。repositoryのインターフェースだけに依存させる。

main.go でDIをまとめる

各レイヤーの依存関係はmain.goで一か所に集めて組み立てる。DIコンテナは使わず、素直にコンストラクタを呼んでいく方が小〜中規模のAPIでは読みやすいと感じている。

// main.go
package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/gin-gonic/gin"
    "myapp/pkg/config"
    "myapp/pkg/gateway"
    "myapp/pkg/handler"
    "myapp/pkg/repository"
    "myapp/pkg/usecase"
)

func main() {
    ctx := context.Background()

    cfg, err := config.Load(ctx)
    if err != nil {
        log.Fatalf("failed to load config: %v", err)
    }

    gormDB, err := gateway.NewDB(cfg.DSN)
    if err != nil {
        log.Fatalf("failed to connect db: %v", err)
    }

    sqlDB, err := gormDB.DB()
    if err != nil {
        log.Fatalf("failed to get sql.DB: %v", err)
    }
    defer sqlDB.Close()

    userRepo := repository.NewUserRepository(gormDB)

    var paymentGw gateway.PaymentGateway
    if cfg.Env == "local" {
        paymentGw = gateway.NewMockPaymentGateway()
    } else {
        paymentGw = gateway.NewStripeGateway(cfg.StripeKey)
    }

    userUsecase := usecase.NewUserUsecase(userRepo, paymentGw)
    userHandler := handler.NewUserHandler(userUsecase)

    r := gin.Default()
    v1 := r.Group("/api/v1")
    {
        v1.GET("/users/:id", userHandler.GetUser)
        v1.POST("/users", userHandler.CreateUser)
    }

    srv := &http.Server{
        Addr:    cfg.Port,
        Handler: r,
    }

    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %v", err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    log.Println("shutting down server...")

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Fatalf("server forced to shutdown: %v", err)
    }
}

ルーティングをmain.goに書くことで、エンドポイントの一覧がここに集まる。依存の向きはhandler → usecase → repository/gatewayという一方向を保っている。

r.Run()ではなくhttp.Serverを使ってgoroutineで起動しているのは、graceful shutdownのためだ。SIGINT/SIGTERMを受け取ったらsrv.Shutdown()を呼ぶことで、処理中のリクエストが終わるのを待ってからサーバーを止める。タイムアウトは5秒にしているが、DBトランザクションが長い処理がある場合は調整が必要だ。sqlDB.Close()deferに積んでいるので、Shutdown()でリクエストが全て終わった後にDB接続が閉じられる。

テスト戦略 — DBは本物、外部APIはmock

層によってテストの方針を分けている。

repository層はテスト用の共通コンテナを使う。 DBをmockにすると「コードは通るけどSQLが壊れてる」という状況が発生する。カラム名のミス、トランザクションのバグ、マイグレーションとのズレはmockでは検出できない。テストごとにコンテナを起動するとGoの並列テストで大量のコンテナが立ち上がって負荷になるので、テスト専用コンテナを1つ起動して使い回す方が現実的だ。

# docker/docker-compose.test.yml
services:
  db-test:
    image: postgres:16
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: testdb
    ports:
      - "5433:5432"  # 開発用5432と競合しないようにずらす
# テスト前に一度だけ起動
docker compose -f docker/docker-compose.test.yml up -d

テストコードは TestMain でDB接続を1回だけ確立し、パッケージ内の全テストで使い回す。並列テスト間のデータ干渉はトランザクションのロールバックで防ぐ。

テストコンテナへのマイグレーションはアプリ起動時と同じ方針でAutoMigrateは使わない。テスト実行前にmake migrate-up-testを手動で実行してスキーマを整えておく。テーブル定義が本番と一致した状態でテストできるのが利点だ。

// pkg/repository/testmain_test.go
package repository_test

import (
    "fmt"
    "os"
    "testing"

    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

var testDB *gorm.DB

func TestMain(m *testing.M) {
    dsn := "postgres://user:password@localhost:5433/testdb?sslmode=disable"
    var err error
    testDB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        fmt.Println("failed to connect test db:", err)
        os.Exit(1)
    }
    os.Exit(m.Run())
}
// pkg/repository/user_repository_test.go
package repository_test

import (
    "context"
    "testing"
)

func TestUserRepository_Create(t *testing.T) {
    t.Parallel()

    tx := testDB.Begin()
    defer tx.Rollback() // テスト終了後にロールバックしてデータを残さない

    repo := NewUserRepository(tx)
    err := repo.Create(context.Background(), &domain.User{Name: "test", Email: "test@example.com"})
    if err != nil {
        t.Fatal(err)
    }
}

gateway層(外部API)は_mock.goで固定値を返す。 単体テストで本物のStripeやSlackを叩くと、テストの実行頻度によってはレートリミットに引っかかったりAPIキーを消費したりする。最悪の場合アカウントがブロックされる。外部サービスはこちらがコントロールできないので、テスト時はmockを使って正常系・エラー系のレスポンスを固定値で再現する。

// pkg/gateway/payment_gateway_test.go
package gateway_test

import (
    "context"
    "testing"

    "myapp/pkg/gateway"
)

func TestMockPaymentGateway_Charge(t *testing.T) {
    t.Parallel()
    gw := gateway.NewMockPaymentGateway()
    if err := gw.Charge(context.Background(), 1000, "tok_test"); err != nil {
        t.Errorf("unexpected error: %v", err)
    }
}

この方針を一言で言うと「自分たちが管理しているものは本物でテストし、管理できないものはmockで代替する」になる。

まとめ

  • ローカルDBはdocker/docker-compose.ymlで管理。docker compose -f docker/docker-compose.yml up -dで起動
  • local.env.localから直接読み込み、staging/productionはAWS SMから機密値を取得
  • ルーティングはmain.goに集約。エンドポイント一覧をひとつの場所で把握できる
  • pkg/handler/はリクエスト/レスポンス変換のみ、ビジネスロジックは持たない
  • pkg/domain/にGORMモデルとリポジトリインターフェースを定義、実装はrepositoryに分離する
  • pkg/repository/がGORMを直接使う唯一の層。db.WithContext(ctx)は必ず通す
  • pkg/gateway/は外部サービスとの境界。インターフェースで抽象化するとテストが楽になる
  • repositoryのテストはdocker-compose.test.ymlの共通コンテナを使う。並列テストの負荷を避けつつ本物のDBで検証
  • gateway(外部API)のテストは_mock.goで固定値を返す。レートリミット・APIブロック対策
  • pkg/gateway/db.goでGORM接続のみ管理。AutoMigrateは使わず、DDLはgolang-migrateのSQLファイルで管理する
  • テスト実行前にmake migrate-up-testでテストDBにスキーマを適用する。TestMain内でのマイグレーション実行はしない
  • ルートのmain.goで依存を組み立てるシンプルなDIにする
  • 依存の向きはhandler → usecase → repository/gatewayの一方向を保つ(Clean Architecture / Ports and Adapters)

2026/05/01

C# LINQを日本語変数で書いたらクエリが仕様書みたいになった話

C# の LINQ を日本語変数で書いたら、クエリが仕様書みたいになった話

業務計算ツールで日本語変数名を使い始めてから、LINQ のラムダ式にも日本語を使うようになった。最初は「どうせ慣れてる英語のほうが書きやすい」と思っていたんだけど、やってみたら予想外に読みやすかった。仕様書に書いてあった条件式がそのままコードになる感覚があって、これはなかなかいい発見だった。

英語変数名の LINQ と比べてみる

まず典型的な業務クエリを英語で書いた場合。

// 英語変数名
var result = employees
    .Where(e => e.Department == "営業" && e.IsActive)
    .OrderBy(e => e.HireDate)
    .Select(e => new { e.Name, e.HireDate });

e が何を指しているか、最初の一行を読んで確認しないとわからない。複数の LINQ チェーンが絡むと、どの e がどのコレクションの要素なのかを都度追いかける必要がある。

同じクエリを日本語で書くとこうなる。

// 日本語変数名
var 結果 = 社員一覧
    .Where(社員 => 社員.部署 == "営業" && 社員.在籍中)
    .OrderBy(社員 => 社員.入社日)
    .Select(社員 => new { 社員.氏名, 社員.入社日 });

ラムダ変数が 社員 になっただけで、文章として読める密度が上がる。「社員一覧から、営業部署に在籍中の社員を、入社日順に並べて、氏名と入社日を取り出す」というビジネスルールが、そのままコードになっている感じだ。

クエリ構文(query syntax)だとさらに読みやすい

LINQ には whereselectorderby を使う SQL ライクなクエリ構文もある。日本語変数名と組み合わせると、仕様書に近い表現になる。

var 結果 = from 社員 in 社員一覧
           where 社員.部署 == "営業" && 社員.在籍中
           orderby 社員.入社日
           select new { 社員.氏名, 社員.入社日 };

「社員 in 社員一覧」「社員.部署 == 営業」という部分は、ほぼ日本語の箇条書きと同じ構造になっている。仕様書に「社員一覧の中で営業部に在籍中のものを…」と書いてあれば、それを見ながらそのまま書き写せる感覚があった。

クエリ構文とメソッド構文、どちらで使うか

クエリ構文は可読性が高いけど、AggregateSkipTakeDistinct のような操作はクエリ構文に対応するキーワードがなく、メソッド構文でしか書けない。実際の業務コードでは「シンプルなフィルタリング・ソートはクエリ構文、ページングや重複除去はメソッド構文」という使い分けが多くなった。なお GroupJoin はクエリ構文の join ... into として書けるので、グループ結合もクエリ構文で表現できる。

集計・グループ化でも効果が出る

単純なフィルタリングだけじゃなく、集計処理でも日本語ラムダ変数は読みやすさを上げてくれる。

// 部署ごとの残業時間合計
var 部署別残業集計 = 勤怠一覧
    .GroupBy(勤怠 => 勤怠.部署)
    .Select(グループ => new
    {
        部署名 = グループ.Key,
        残業時間合計 = グループ.Sum(勤怠 => 勤怠.残業時間),
        対象人数 = グループ.Count()
    })
    .OrderByDescending(集計 => 集計.残業時間合計);

グループ集計勤怠 と変数名が変わるたびに「何を操作しているか」が明示される。英語だと gx のような1文字変数になりがちで、複数のラムダが重なると何が何だかわからなくなる。

Join でも意味が通りやすくなる

複数テーブルの結合になると、英語変数名では特に読みにくくなる。

// 英語
var result = orders
    .Join(customers,
          o => o.CustomerId,
          c => c.Id,
          (o, c) => new { o.OrderDate, c.Name, o.Amount });

// 日本語
var 注文明細 = 注文一覧
    .Join(顧客一覧,
          注文 => 注文.顧客ID,
          顧客 => 顧客.ID,
          (注文, 顧客) => new { 注文.注文日, 顧客.氏名, 注文.金額 });

oc が何を表すか覚えておかなくていい。注文顧客 という名前が都度文脈を説明してくれる。

気になった点と対処法

IME の干渉

LINQ の式を書いているとき、=> の直後に日本語入力が始まってしまうことがある。Visual Studio の IME 連携で、ラムダの矢印を打った後に日本語入力に切り替わってしまうケースだ。自分の場合は「=> を打ったら手動で IME をオンにする」という習慣にしたら気にならなくなった。

チェーンが長くなると行が長くなる

英語の1文字変数(ex)は短いので、横に長い式でも収まりやすい。日本語変数名は文字幅があるため、チェーンが長くなると1行が長くなりがちだ。.Select() の前で改行を入れるなど、縦に展開する書き方を意識するようになった。

// 改行を入れて整理する
var 対象社員 = 社員一覧
    .Where(社員 => 社員.在籍中)
    .Where(社員 => 社員.入社日 < 基準日)
    .Select(社員 => 社員.氏名)
    .ToList();

汎用メソッドへの切り出し時は英語に戻す

ドメイン固有の LINQ クエリは日本語変数で書くけど、複数の場所から呼ばれる汎用的なフィルタ関数を切り出すときは英語に戻している。日本語変数名は「この文脈でこの操作をする」が明確な場所にほど効果が出やすい。

まとめ

  • LINQ のラムダ変数を日本語にすると「何のコレクションの要素を操作しているか」がコードから直接読める
  • クエリ構文(from 社員 in 社員一覧 where ...)は仕様書の条件式に近い構造になり、照合コストが下がる
  • Join・GroupBy など複数コレクションが絡む処理で特に可読性向上の恩恵が出やすい
  • IME の干渉と行長には慣れが必要。縦展開する書き方を意識するといい

日本語変数名を業務計算ロジックに使い始めた経緯についてはこちらの記事、コメントと変数名の非対称についての考察はこちらの記事もあわせてどうぞ。