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年のプロパティ宣言まとめ