ASP.NET Core の Web API を Swagger(Swashbuckle/NSwag)で公開したとき、コントローラの戻り値を dynamic にすると UI のレスポンス・スキーマが空になる――この現象は「仕様」を正しく理解すれば避けられます。本記事では原因から実務的な回避策、サンプル実装、運用のコツまでをまとめて解説します。
問題の背景と現象
まず、なぜ dynamic を返すと Swagger UI にスキーマが表示されないのかを整理します。
- Swagger(OpenAPI)に必要なのはコンパイル時の型情報… スキーマは「プロパティ名・型・必須かどうか」を静的に表現します。
dynamicは実行時バインディング… C# のコンパイラは型情報を保持しないため、リフレクションだけではメンバー構造を確定できません。- Swashbuckle/NSwag は型メタデータからスキーマを生成… 返却型が
dynamic(またはobject)だと、プロパティを列挙できず「不明」=空欄に見える状態になります。
結果として、Swagger UI では 200 応答の「Schema」が表示されず、クライアント生成や検証に役立つ情報が失われます。API 消費者の体験を損ねるだけでなく、仕様変更の検出も困難になります。
最短の答え:DTO + ActionResult<T>(推奨)
結論から言えば「返却用の DTO を定義して ActionResult<T> を返す」が最も簡潔かつ堅牢です。Swagger は静的型 T から完全なスキーマを起こせるため、プロパティ定義・必須項目・列挙値・フォーマット(email、date-time 等)を正確に UI に反映できます。さらに OpenAPI からのクライアント自動生成(NSwagStudio / OpenAPI Generator など)と組み合わせると、型安全な SDK を配布できます。
コントローラ(属性ルーティング)での実装例
public record MyResponseDto(string Name, int Age);
[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(MyResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult Get(int id)
{
// 実アプリでは DB 等から取得
if (id == 404) return NotFound();
```
var dto = new MyResponseDto("Alice", 25);
return Ok(dto);
}
```
}
Minimal APIs での実装例(.NET 7+)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.MapGet("/api/users/{id:int}",
Results, NotFound> (int id) =>
{
if (id == 404) return TypedResults.NotFound();
return TypedResults.Ok(new MyResponseDto("Alice", 25));
})
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
app.Run();
public record MyResponseDto(string Name, int Age);
Minimal APIs の Results<...> を使うと、成功/失敗の複数ステータスを静的に表明できます。Swagger はそれぞれの status code に対してスキーマと説明を表示します。
他の現実解:妥協の度合いと用途で選ぶ
返却形が実行時まで確定できないケースでは、次のような代替も現実的です。空欄を避け、利用者に最低限のヒントを提供できます。
| 解決策 | 内容 | メリット | デメリット |
|---|---|---|---|
① DTO + ActionResult<T>(推奨) | 返却用クラス (DTO) を定義し、ActionResult<MyDto> を返す。例:[ProducesResponseType(typeof(MyDto), 200)] | Swagger が完全な型情報を取得し、詳細なスキーマを自動生成できる | DTO を都度作成する手間 |
② JsonObject / JObject を返す | 例:return Ok(JsonNode.Parse("{"foo":"bar"}")); | UI では「汎用 object」と表示され、空欄にならない | プロパティ構造は具体的に示されない |
③ oneOf/anyOf で複数 DTO を列挙 | 可能性のある返却形を列挙して公開する | バリエーションを正規にドキュメント化 | 宣言が煩雑/運用コスト増 |
| ④ Example でサンプル JSON を提示 | スキーマではなく例だけ示す(Example Filter など) | DTO が作れない場合でも UI でイメージ共有 | スキーマ検証はできない |
⑤ object を返す | [ProducesResponseType(typeof(object), 200)] | 最低限「object」は表示 | 情報量はほぼ無い |
② JsonObject / JObject を返して「空欄」を回避
dynamic をそのまま返すのではなく、System.Text.Json.Nodes.JsonObject(または Newtonsoft.Json.Linq.JObject)に包むと、Swagger は「object(任意のプロパティ)」として扱ってくれます。完全なスキーマではないものの、UI が空白になるよりは遥かに親切です。
// System.Text.Json 版
using System.Text.Json;
using System.Text.Json.Nodes;
[HttpGet("raw")]
public IActionResult GetRaw()
{
var json = new JsonObject
{
["foo"] = "bar",
["count"] = 3
};
return Ok(json); // Swagger 上は type: object と表示
}
Newtonsoft.Json を使う場合は AddControllers().AddNewtonsoftJson() を有効化し、JObject を返します。
③ 返却形が複数あるなら oneOf/anyOf
「成功時に UserDto か AdminDto のどちらか」など、複数形があり得る場合は oneOf を使います。Swashbuckle では素の属性だけで 200 応答の oneOf を作るのが難しいため、OperationFilter でスキーマを書き換えるのが実務的です。
public class OneOfResponseOperationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
if (!operation.Responses.TryGetValue("200", out var resp)) return;
if (!resp.Content.TryGetValue("application/json", out var media)) return;
```
var s1 = context.SchemaGenerator.GenerateSchema(typeof(UserDto), context.SchemaRepository);
var s2 = context.SchemaGenerator.GenerateSchema(typeof(AdminDto), context.SchemaRepository);
media.Schema.OneOf = new List<OpenApiSchema> { s1, s2 };
media.Schema.Nullable = false;
}
```
}
Startup/Program 側で services.AddSwaggerGen(c => c.OperationFilter<OneOfResponseOperationFilter>()); を登録してください。NSwag でも同様に Operation Processor を用意できます。
④ Example(サンプル JSON)を提示して合意を形成
仕様が固まりきっていないプロトタイピング期は、例 を見せて関係者の合意形成を進めるのが効果的です。Swashbuckle の Examples 機能(Example Filter)や OperationFilter でレスポンス例を設定すれば、スキーマ空欄の心理的障壁を下げられます。
// Swashbuckle.AspNetCore.Filters を利用する例
public class MyResponseExample : IExamplesProvider<MyResponseDto>
{
public MyResponseDto GetExamples() => new("Alice", 25);
}
// Startup
services.AddSwaggerGen(c => c.ExampleFilters());
services.AddSwaggerExamplesFromAssemblyOf();
スキーマ検証はできませんが、UI 上でデータ形状の「当たり」を共有できます。
⑤ 最低限の逃げ道:object を返す
object 型は OpenAPI 上で「任意のオブジェクト」を意味します。情報量は少ないものの、dynamic よりは「何かしらの JSON オブジェクト」が返ることを表明できます。
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
public IActionResult GetLoose() => Ok(new { message = "anything" });
dynamic をどうしても使いたい時のテクニック
要件上どうしても dynamic(または object)にせざるを得ない場合、Swagger 上の見え方を改善するテクニックを紹介します。
SchemaFilter で「自由形式オブジェクト」を明示
ISchemaFilter を使い、object/JsonNode/JsonElement に遭遇したら additionalProperties を許可するスキーマへ置き換えます。これにより UI は「任意のキーを持つオブジェクト」と認識します。
public class DynamicSchemaFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
var t = context.Type;
if (t == typeof(object) || t == typeof(System.Text.Json.Nodes.JsonNode)
|| t == typeof(System.Text.Json.JsonElement))
{
schema.Type = "object";
schema.AdditionalPropertiesAllowed = true;
schema.AdditionalProperties = new OpenApiSchema(); // any
schema.Properties?.Clear();
}
}
}
// Startup
services.AddSwaggerGen(c => c.SchemaFilter());
Envelope パターンで外形を固定する
中身は自由でも、外側のフォーマット(メタ情報)は固定すると、ドキュメント品質が大幅に上がります。
public record EnvelopeDto(string Kind, JsonObject Data, DateTimeOffset Timestamp);
[HttpGet("search")]
[ProducesResponseType(typeof(EnvelopeDto), StatusCodes.Status200OK)]
public ActionResult Search(string q)
{
var payload = new JsonObject { ["q"] = q, ["hits"] = 123 };
return Ok(new EnvelopeDto("searchResult", payload, DateTimeOffset.UtcNow));
}
この形なら Kind や Timestamp は強く型付けでき、Data は自由形式として公開できます。
NSwag を使う場合のポイント
- アノテーション…
[SwaggerResponse(200, typeof(MyDto), Description = "OK")]で戻り値を明示できます。 - ポリモーフィズム… NJsonSchema の discriminator(継承階層)を使って
oneOfを表現できます。基底 DTO を定義しサブタイプに派生させると、UI は型のバリエーションを把握します。 - 例の提示… Operation Processor で
OpenApiExampleを付与可能です。
よくある落とし穴と対策
- JSON シリアライザの不一致… プロジェクトが
System.Text.JsonなのにJObjectを返すと期待通りに動かない場合があります。Newtonsoft を使うならAddNewtonsoftJson()を有効に。 ProducesResponseTypeの未設定…ActionResultを返しているのに属性で型を指定していないと、Swagger からは判別しにくいです。成功・失敗ともに明示しましょう。- 配列・辞書の型引数未指定…
IEnumerableやIDictionaryを返す場合は、要素型・値型を必ず具体化(IEnumerable<ItemDto>/Dictionary<string, string>)。 - Minimal APIs のメタデータ不足… ラムダの戻り値が曖昧だと推論できません。
Results<...>や.Produces<T>()を付けてください。 - バージョン管理なし… 返却形の変更を伴う場合は API Versioning を導入して互換性を担保します。
実装テンプレート集(コピペ可)
1) 標準的な DTO 返却(Controller)
public record ErrorDto(string Code, string Message);
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(MyResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorDto), StatusCodes.Status404NotFound)]
public ActionResult GetById(int id)
{
var entity = Find(id);
if (entity is null)
return NotFound(new ErrorDto("NotFound", $"User {id} was not found"));
```
return Ok(new MyResponseDto(entity.Name, entity.Age));
```
}
2) oneOf(ユーザー種別で返却形が変わる)
public record UserBase(string Id, string Kind);
public record MemberUser(string Id, string Kind, DateTime JoinedAt) : UserBase(Id, Kind);
public record AdminUser(string Id, string Kind, string Role) : UserBase(Id, Kind);
// OperationFilter で 200 応答の oneOf として MemberUser / AdminUser を設定
3) Example のみ提示(サンプル JSON)
public class SearchExample : IExamplesProvider<JsonObject>
{
public JsonObject GetExamples() => new()
{
["query"] = "alice",
["total"] = 2,
["items"] = new JsonArray("A", "B")
};
}
4) どうしても柔軟にしたい時の辞書返却
[ProducesResponseType(typeof(Dictionary<string, object>), StatusCodes.Status200OK)]
public ActionResult<Dictionary<string, object>> GetMap()
{
return Ok(new Dictionary<string, object>
{
["foo"] = "bar",
["answer"] = 42
});
}
検証の手順(手元で再現)
- 新規の ASP.NET Core Web API を作成し、
AddEndpointsApiExplorer()とAddSwaggerGen()を有効化。 dynamicを返すエンドポイントと、DTO を返すエンドポイントを一つずつ用意。- Swagger UI を開き、両者の「Schema」欄の違いを比較。
- SchemaFilter/OperationFilter を順に導入し、UI の変化を確認。
- OpenAPI JSON(
/swagger/v1/swagger.json)のschemasに生成された型が登録されているかを確認。
設計判断の指針(チェックリスト)
- 返却形は実行前に決められるか? → はい:DTO 化する(推奨)。
- 返却形の候補が有限か? → はい:
oneOfで列挙する。 - 形は流動的だが「外形」は固定できるか? → はい:Envelope(
metadata + data)パターン。 - 形も外形も流動的か? → はい:
JsonObject返却 + Example 提示 + SchemaFilter。
パフォーマンス・保守性への影響
- DTO は最適化しやすい…
JsonSerializerContext(Source Generator)を併用するとシリアライズの割当が減ります。 - 自由形式はテストが増える… スキーマ検証が効かないため、E2E テストや契約テストで補完しましょう。
- 自動生成クライアントの恩恵… DTO 化すれば型安全な SDK を配布でき、呼び出し側の Null 安全・変更検知が効きます。
運用のベストプラクティス
- 共通エラー DTO…
ProblemDetailsまたは独自ErrorDtoを全 API で統一し、[ProducesResponseType(typeof(ProblemDetails), 400)]のように明記。 - ステータス毎にドキュメント化… 200/400/404/409/500 など、実際に返し得るものは漏れなく列挙。
- バージョニング… 返却形の破壊的変更は新しい API バージョンで。
- Example の鮮度… 仕様変更時は Example も更新。UI の印象は重要です。
トラブルシューティング早見表
| 症状 | 原因の目安 | 対処 |
|---|---|---|
| Schema が空欄 | 返却型が dynamic/object | DTO 化 or JsonObject 返却 or SchemaFilter |
| 200 に複数形がある | 仕様が分岐 | oneOf を OperationFilter で設定 |
| Minimal API でスキーマ推論されない | 戻り値が曖昧 | Results<...> と .Produces<T>() を追加 |
| Newtonsoft と混在 | Serializer 設定不一致 | AddNewtonsoftJson() か System.Text.Json に統一 |
まとめ
Swagger に戻り値を「きちんと」示したいなら、DTO + ActionResult<T> が最も確実で、ドキュメント品質・自動生成・保守性のすべてでメリットが大きい選択です。要件上 dynamic/object を避けられない場面でも、JsonObject 返却・oneOf の明示・Example の提示・SchemaFilter の導入といった工夫で、Swagger UI の空白をなくし、API 利用者の理解コストを最小化できます。設計初期から「コンパイル時に説明できる型」を意識しておくことが、開発速度と品質の両立に直結します。
付録:完全サンプル(Swashbuckle 構成)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers()
.AddJsonOptions(o => o.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Demo API", Version = "v1" });
c.SchemaFilter<DynamicSchemaFilter>(); // 任意
c.OperationFilter<OneOfResponseOperationFilter>(); // 任意
// c.ExampleFilters(); // Swashbuckle.AspNetCore.Filters を使う場合
});
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.MapControllers();
app.Run();
// --- DTOs ---
public record MyResponseDto(string Name, int Age);
public record ErrorDto(string Code, string Message);
// --- Filters ---
public class DynamicSchemaFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
var t = context.Type;
if (t == typeof(object) || t == typeof(System.Text.Json.Nodes.JsonNode)
|| t == typeof(System.Text.Json.JsonElement))
{
schema.Type = "object";
schema.AdditionalPropertiesAllowed = true;
schema.AdditionalProperties = new OpenApiSchema();
schema.Properties?.Clear();
}
}
}
public class OneOfResponseOperationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
if (!operation.Responses.TryGetValue("200", out var resp)) return;
if (!resp.Content.TryGetValue("application/json", out var media)) return;
```
// 例として MyResponseDto と ErrorDto の二択を oneOf にする
var s1 = context.SchemaGenerator.GenerateSchema(typeof(MyResponseDto), context.SchemaRepository);
var s2 = context.SchemaGenerator.GenerateSchema(typeof(ErrorDto), context.SchemaRepository);
media.Schema.OneOf = new List<OpenApiSchema> { s1, s2 };
}
```
}
補足メモ
- record 型で DTO を簡潔に… C# 9+ の
recordはイミュータブル DTO に最適。initアクセサで柔軟性も確保。 - 共通エラーの統一…
ProblemDetailsを使えば RFC7807 形式で機械可読なエラーを整備できます。 - バリデーション属性…
[Required]や[Range]を DTO に付けると Swagger スキーマにも反映され、UI の信頼性が上がります。 - クライアント生成との相性… DTO 定義があるほど生成クライアントの型安全性・保守性は高く、フロント側の開発効率も向上します。
結論:API 利用者へレスポンス構造を正確に伝えたいなら DTO + ActionResult<T> が最も確実。
dynamic/object で妥協する場合でも、JsonObject やサンプル JSON、oneOf、SchemaFilter などで「空欄」を残さない工夫を行い、仕様の意思疎通と将来の保守性を担保しましょう。

コメント