ASP.NET Core(.NET 9)で「コントローラにもアクションにも [Route] を付けたら、一方だけ 404 になる」という相談はとても多い問題です。原因は難解な実装ではなく、属性ルーティングの“連結”ルールを誤解していることが大半。この記事では、再現→原因→正しい書き方→デバッグ手順→落とし穴回避までを一気に整理します。貼り付けてすぐ試せるコードと表で、現場のつまずきを解消しましょう。
症状の再現:/api/content は通るのに /api/content/GetData が 404
まずはよくあるコード例から。
using Microsoft.AspNetCore.Mvc;
namespace Demo.Controllers;
[Route("api/[controller]")]
[ApiController]
public class ContentController : ControllerBase
{
[HttpGet] // → /api/content は 200 OK
public string Get() => "OK";
[HttpGet]
[Route("api/Content/GetData/")] // → /api/content/GetData は 404
public string GetData() => "OK";
}
上のコードでは、コントローラに [Route("api/[controller]")] があり、アクションにも [Route("api/Content/GetData/")] を付けています。結果として、
GET /api/content… 成功(200)GET /api/content/GetData… 失敗(404)
なぜこの差が生まれるのでしょうか?
根本原因:属性ルーティングは「ベース+相対」の連結で解決される
ASP.NET Core MVC/Web API の属性ルーティングは以下のルールで解釈されます。
- コントローラの
[Route]がベースパスになる(例:api/[controller])。 - アクションの
[Route]は通常「相対パス」としてベースに連結される。 - アクション側を絶対パス(= 連結しない)にしたい場合は、テンプレートを 「
/」または「~/」で始める。
つまり、先の NG 例は実際にはこう展開されています。
// Controller: api/[controller] → api/content
// Action: api/Content/GetData/(相対)
// 結果: api/content + api/content/getdata → /api/content/api/content/getdata
開発者は「/api/content/getdata にしたい」つもりでも、フレームワークは「ベース+相対」で /api/content/api/content/getdata を作ってしまう──ここで URL がズレるため 404 になります。
補足として、ルーティングは既定で ASCII の大小を区別しないため、/api/Content と /api/content の表記ゆれ自体は直接の原因ではありません(リンク生成や統一運用の観点で小文字に揃えるのは推奨)。
正しい書き方は 3 通り(目的別)
以下のいずれかで解決します。最もシンプルなのは相対パスのみを書く方法です。
| 解決策 | 実装例 | 期待される URL | ポイント |
|---|---|---|---|
| ① 相対パスのみを書く(推奨) | [Route("api/[controller]")] public class ContentController : ControllerBase { [HttpGet("GetData")] public string GetData() => "OK"; } | /api/content/getdata | アクションのテンプレートはベースに連結される。 同じセグメント( api/content)を重ね書きしない。 |
| ② 絶対パスで書く | [Route("api/[controller]")] public class ContentController : ControllerBase { [HttpGet] [Route("/api/content/getdata")] // or: [Route("~/api/content/getdata")] public string GetData() => "OK"; } | /api/content/getdata | / または ~/ で始めると連結が無効化され、その文字列がそのままマッチングに使われる。 |
③ コントローラに [action] トークンを使う | [Route("api/[controller]/[action]")] public class ContentController : ControllerBase { [HttpGet] // メソッド名 GetData が URL になる public string GetData() => "OK"; [HttpGet] public string Get() => "OK"; } | /api/content/getdata、/api/content/get | メソッド名を URL に露出したいときに便利。命名変更の影響に注意。 |
最小サンプル(.NET 9):すぐ動かして確かめる
プロジェクトを新規作成し、以下の 2 ファイルを配置して実行してください。
Program.cs
var builder = WebApplication.CreateBuilder(args);
// コントローラを有効化
builder.Services.AddControllers();
// 生成されるリンクの規約(任意・推奨)
builder.Services.Configure(o =>
{
o.LowercaseUrls = true; // 生成 URL を小文字化
o.AppendTrailingSlash = false; // 末尾スラッシュは付けない
});
var app = builder.Build();
app.MapControllers();
// デバッグ用に登録された全エンドポイントをダンプする簡易ページ(任意)
app.MapGet("/_debug/routes", (IEnumerable dataSources) =>
{
var endpoints = dataSources.SelectMany(ds => ds.Endpoints).OfType();
return Results.Text(string.Join(Environment.NewLine,
endpoints.Select(e => $"{e.DisplayName} ⇒ {e.RoutePattern}")));
});
app.Run();
Controllers/ContentController.cs(解決策①の例)
using Microsoft.AspNetCore.Mvc;
namespace Demo.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ContentController : ControllerBase
{
[HttpGet]
public string Get() => "OK";
[HttpGet("GetData")]
public string GetData() => "OK";
}
起動後、以下で確認します。
# どちらも 200 OK が返る
curl http://localhost:5000/api/content
curl http://localhost:5000/api/content/getdata
# 登録済みルートの一覧も見える(任意)
curl http://localhost:5000/_debug/routes
「なぜ 404?」をもう一歩深掘り:連結の具体的なルール
- ベース+相対の合成:コントローラのテンプレート(例:
api/[controller])と、アクションのテンプレート(例:GetData)がパスセパレータで連結され、最終的なルートが決まる。 - 絶対パスの宣言:アクションテンプレートを
/または~/で始めると合成をスキップし、文字列どおりのルートになる。 - トークン展開:
[controller]はクラス名からControllerを除いた部分(例:Content)を小文字化して展開される(リンク生成は小文字化を有効化していれば小文字で出ます)。[action]はメソッド名、[area]はエリア名。 - 大文字小文字:既定ではマッチングは ASCII で大文字小文字を区別しません。とはいえ、URL の見た目・SEO・可読性のために小文字で統一し、リンク生成も小文字化しておくのがベストです。
- 末尾スラッシュ:テンプレートに末尾スラッシュを付けないのが無難。エンドポイントの定義と呼び出し側を一致させる運用で 404 の取り逃しを防ぎます。
現場で役立つデバッグ・確認テクニック
1) まずは「実際に登録されたエンドポイント」を見る
前述の /_debug/routes のように EndpointDataSource をダンプすると、アプリが認識している最終的なルートパターンを一覧できます。期待と結果がズレていないか、一目で分かります。
2) ルーティングの衝突チェック
属性ルートは順序よりもパターンが重要です。同じパスに複数のアクションがマッチすると曖昧さが発生します。HTTP メソッド([HttpGet]/[HttpPost] など)やルート制約(例:{id:int})で意図を明確化しましょう。
[Route("api/[controller]")]
public class ContentController : ControllerBase
{
[HttpGet("{id:int}")]
public IActionResult GetById(int id) => Ok(id);
[HttpGet("{slug}")]
public IActionResult GetBySlug(string slug) => Ok(slug);
}
このように制約を使うと、/api/content/123 は前者、/api/content/abc は後者に振り分けられます。
3) 統合テストで「404 にならないこと」を固定化
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
public class RoutingTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public RoutingTests(WebApplicationFactory<Program> factory)
=> _client = factory.CreateClient();
[Theory]
[InlineData("/api/content")]
[InlineData("/api/content/getdata")]
public async Task Endpoints_Should_Be_Accessible(string path)
{
var res = await _client.GetAsync(path);
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
}
}
CI で回しておけばルート変更による事故を早期検知できます。
設計ベストプラクティス:運用で 404 を減らすために
- ベースはコントローラ、アクションは相対に徹する(解決策①)。URL 構造の一貫性が高まり、リファクタも簡単。
- 絶対パスが必要な場合のみ、明示的に
/または~/で始める(解決策②)。 - 小文字 & 末尾スラッシュなしを規約にする(リンク生成を小文字化)。
- 冗長なセグメントの重複を避ける(
apiを二重にしない等)。 - バージョニングを導入する場合は、ベース側にまとめると整理しやすい:
[Route("api/v{version:apiVersion}/[controller]")] [ApiVersion("1.0")] public class ContentController : ControllerBase { ... } - ルート制約・名前付きルートを活用し、曖昧さを排除:
[HttpGet("getdata", Name = "ContentGetData")]
よくある落とし穴(実例付き)
同じセグメントを二度書いてしまう
NG:
[Route("api/[controller]")]
public class ContentController : ControllerBase
{
[HttpGet]
[Route("api/content/getdata")] // ← ここが重複
public string GetData() => "OK";
}
OK:
[Route("api/[controller]")]
public class ContentController : ControllerBase
{
[HttpGet("getdata")]
public string GetData() => "OK";
}
末尾スラッシュの取り扱い
テンプレートとリクエストの表記を合わせるのが確実です。リンク生成は AppendTrailingSlash = false にしておくと統一しやすく、ブックマークやドキュメントでも揺れが減ります。
Minimal API との共存
同一アプリで Minimal API(app.MapGet(...))とコントローラを併用する場合、パスの衝突に注意してください。app.MapControllers() を呼ぶ順序は大抵問題になりませんが、同じパターンを定義すると競合します。API 設計段階で領域を明確に分けるのが安全です。
環境差(IIS / Kestrel / リバースプロキシ)
リバースプロキシのプレフィックス(例:/api を前段で付与)との二重化で 404 を踏むケースもあります。アプリ側では PathBase を意識し、前段の書き換えとアプリ定義が二重にならないようにしましょう。
運用の型:コントローラ単位で統一する
チームでの実装ルールが混在すると、URL 設計の一貫性が崩れて保守コストが跳ね上がります。以下の「型」をチーム規約として導入するのがお勧めです。
型 A:最小・シンプル(推奨)
[ApiController]
[Route("api/[controller]")]
public class ContentController : ControllerBase
{
[HttpGet] // GET /api/content
public IActionResult Get() => Ok();
[HttpGet("getdata")] // GET /api/content/getdata
public IActionResult GetData() => Ok();
}
型 B:アクション名を露出
[ApiController]
[Route("api/[controller]/[action]")]
public class ContentController : ControllerBase
{
[HttpGet] // GET /api/content/get
public IActionResult Get() => Ok();
[HttpGet] // GET /api/content/getdata
public IActionResult GetData() => Ok();
}
型 C:どうしても絶対パスが必要なときだけ
[ApiController]
[Route("api/[controller]")]
public class ContentController : ControllerBase
{
[HttpGet]
[Route("/api/content/getdata")] // or "~/api/content/getdata"
public IActionResult GetData() => Ok();
}
チェックリスト:実装前・レビュー時に見るポイント
- コントローラに
[Route("api/[controller]")]などのベースがあるか。 - アクションのテンプレートは相対になっているか(
GetDataや"getdata")。 - 絶対パスを使うなら
/または~/で始めているか。 - 同じセグメント(
api/contentなど)をアクションで重複させていないか。 - リンク生成の規約(小文字化・末尾スラッシュなし)が設定されているか。
- 制約や HTTP メソッドで曖昧さを排除しているか。
- Minimal API/前段プロキシとの衝突がないか。
トラブルシューティング:それでも 404 のとき
- エンドポイント一覧(
/_debug/routes)で、狙いのパターンが登録されているか確認。無ければテンプレートの書き方を見直す。 - HTTP メソッド(GET/POST/PUT/DELETE)の取り違いがないか(
curl -X GETで叩いているのに[HttpPost]になっていないか)。 - ルート制約に引っかかっていないか(例:
{id:int}に文字列でアクセスしていないか)。 - 前段(IIS/Nginx/Apache/ALB 等)のリライト・ベースパス付与と二重化していないか。
- 環境変数・ベースパス(
ASPNETCORE_URLSやUsePathBase)で予期せぬパスが混ざっていないか。
まとめ:404 の正体は「重ね書き」
今回の 404 の正体は、コントローラのベース api/[controller] に、アクション側でさらに api/content/getdata を重ね書きし、結果として /api/content/api/content/getdata ができてしまったことでした。解決策はシンプルです。
- 基本は相対パスだけを書く(
[HttpGet("getdata")])。 - どうしても必要なときのみ絶対パス(
[Route("/api/content/getdata")]または[Route("~/api/content/getdata")])。 - URL の小文字統一・末尾スラッシュなしでブレを無くす。
この 3 点をチームの実装規約に落とし込めば、ASP.NET Core での 404 トラブルは劇的に減ります。ルートは設計です。小さな規約の積み重ねが、のちのちの保守性と品質に直結します。
付録:よく使うコードスニペット集
1) ルートの小文字化と末尾スラッシュ無効化
builder.Services.Configure<RouteOptions>(o =>
{
o.LowercaseUrls = true;
o.AppendTrailingSlash = false;
});
2) ルート制約の例
[HttpGet("{year:int:min(2000):max(2100)}/{month:int:range(1,12)}")]
public IActionResult GetMonthly(int year, int month) => Ok(new { year, month });
3) 名前付きルートと URL 生成
[HttpGet("getdata", Name = "ContentGetData")]
public IActionResult GetData() => Ok();
// どこか別の場所で URL を生成
public string BuildUrl([FromServices] LinkGenerator linker)
=> linker.GetPathByName(HttpContext, "ContentGetData", values: null) ?? "";
4) バージョニング(ルートにまとめる)
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class ContentController : ControllerBase
{
[HttpGet("getdata")]
public IActionResult GetDataV() => Ok();
}
5) 既存コードの「重複セグメント」を機械的に直すコツ
// Before(NG)
[Route("api/[controller]")]
public class ContentController : ControllerBase
{
[HttpGet]
[Route("api/content/getdata")]
public IActionResult GetData() => Ok();
}
// After(OK)
[Route("api/[controller]")]
public class ContentController : ControllerBase
{
[HttpGet("getdata")]
public IActionResult GetData() => Ok();
}
ケーススタディ:既存プロジェクトでの移行手順
- 現状棚卸し:
/_debug/routesで全エンドポイントを出力し、/api/api/...のような重複や、末尾スラッシュの揺れを洗い出す。 - 規約決定:「ベース=コントローラ、アクションは相対、絶対パス禁止(例外は要レビュー)」 を明文化。
- 一括修正:正規表現置換(例:
\[Route\("api\/[a-zA-Z].*"\)\]をアクションから削除)で重複を除去。 - リンク生成の統一:
LowercaseUrlsとAppendTrailingSlashを設定。 - 自動テスト:主要エンドポイントの 200 応答テストを追加し、回帰を防止。
最後に:ミスを防ぐための「ルール一行」
「アクションの [Route] には 相対 だけを書く」──この一行を守るだけで、今回のような 404 はほぼ消えます。絶対パスが必要な特殊ケースでは / か ~/ のみを接頭に付ける。これが ASP.NET Core ルーティングを安定運用する最短ルートです。
Q&A:手早く解決したい人向けまとめ
| やりたいこと | 書き方 | アクセス URL | 備考 |
|---|---|---|---|
ベースに api/[controller] を使い、GetData を公開 | [HttpGet("getdata")] | /api/content/getdata | 最小・推奨 |
ベース無視で /api/content/getdata を直書き | [Route("/api/content/getdata")] または [Route("~/api/content/getdata")] | /api/content/getdata | 絶対パス。乱用注意 |
| メソッド名を URL に反映したい | [Route("api/[controller]/[action]")] | /api/content/getdata, /api/content/get | 命名の破壊的変更に注意 |

コメント