Problem Context

OpenAPI is the JSON / YAML contract that says "here's every endpoint, every parameter, every response shape, every error type." In 2026, OpenAPI 3.1 is mainstream โ€” it's aligned with JSON Schema 2020-12, which means your validation rules are reusable across REST, AsyncAPI, and config files. .NET 9 shipsMicrosoft.AspNetCore.OpenApi built in (replacing Swashbuckle in default project templates), and the modern UI layer is Scalar or Redocly โ€” both significantly nicer than the classic Swagger UI.

The real win of a good OpenAPI doc isn't the rendered UI. It's code generation: typed clients with NSwag orMicrosoft Kiota, mock servers with Prism, contract tests with Schemathesis, and AI agents that can call your API because they read the spec.

๐Ÿค” Sound familiar?
  • Your "OpenAPI doc" is a stale Notion page
  • Three teams have hand-written three different SDKs for the same API
  • Your spec lies โ€” the API actually returns a different shape
  • You're still on Swashbuckle and it doesn't support 3.1

Generate the spec from code on .NET 9, render with Scalar, ship typed SDKs with Kiota โ€” the spec stops drifting.

Concept Explanation

  • Code-first โ€” write C#, generate the spec. Fast iteration, spec follows code.
  • Spec-first โ€” write the spec, generate stubs. Better for cross-team contracts.
  • Hybrid โ€” generate from code, then lint/diff in CI to catch breaking changes.

flowchart LR
    Code[".NET 9 endpoints<br/>+ XML docs"] --> Gen["AddOpenApi()<br/>MapOpenApi()"]
    Gen --> Spec["openapi.json<br/>(OpenAPI 3.1)"]
    Spec --> UI["Scalar UI<br/>or Redoc"]
    Spec --> Sdk["Kiota / NSwag<br/>typed SDKs"]
    Spec --> Lint["Spectral lint<br/>+ openapi-diff in CI"]
    Spec --> AI["AI agents<br/>(tool calling)"]

    style Spec fill:#85EA2D,color:#000,stroke:#3aa31d

Implementation

Step 1: Enable .NET 9 built-in OpenAPI

// .csproj
// <PropertyGroup>
//   <GenerateDocumentationFile>true</GenerateDocumentationFile>
//   <NoWarn>$(NoWarn);1591</NoWarn>
// </PropertyGroup>

builder.Services.AddOpenApi("v1", o =>
{
    o.AddDocumentTransformer((doc, ctx, ct) =>
    {
        doc.Info.Title       = "Orders API";
        doc.Info.Version     = "v1";
        doc.Info.Description = "Order management endpoints.";
        return Task.CompletedTask;
    });
});

var app = builder.Build();
app.MapOpenApi("/openapi/{documentName}.json");   // serves the spec

Step 2: Document endpoints (Minimal API)

app.MapGet("/orders/{id:long}", async (long id, IOrderRepo repo) =>
{
    var o = await repo.FindAsync(id);
    return o is null ? Results.NotFound() : Results.Ok(o);
})
.WithName("GetOrder")
.WithSummary("Get an order by id")
.WithDescription("Returns a single order. 404 if not found.")
.Produces<OrderDto>(200)
.ProducesProblem(404)
.WithTags("Orders");

/// <summary>Place a new order.</summary>
/// <remarks>Total must be &gt; 0. Idempotency-Key header is required.</remarks>
app.MapPost("/orders", async (CreateOrderCmd cmd, IOrderRepo repo) =>
        Results.Created($"/orders/{await repo.CreateAsync(cmd)}", null))
   .Accepts<CreateOrderCmd>("application/json")
   .Produces(201)
   .ProducesValidationProblem();

Step 3: Render with Scalar (modern Swagger UI replacement)

// dotnet add package Scalar.AspNetCore

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference("/docs", o =>
    {
        o.WithTitle("Orders API")
         .WithTheme(ScalarTheme.Purple)
         .WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient);
    });
}
// Browse to /docs โ€” fast, dark mode, sample requests in 20 languages.

Step 4: Schema transformers for cross-cutting concerns

builder.Services.AddOpenApi(o =>
{
    // Add Bearer auth scheme to every operation
    o.AddDocumentTransformer<BearerSecuritySchemeTransformer>();

    // Treat all DateTimeOffset as ISO-8601 strings with examples
    o.AddSchemaTransformer((schema, ctx, ct) =>
    {
        if (ctx.JsonTypeInfo.Type == typeof(DateTimeOffset))
            schema.Example = OpenApiAnyFactory.CreateFromJson("\"2026-04-01T12:00:00Z\"");
        return Task.CompletedTask;
    });
});

Step 5: Generate typed SDKs with Kiota

// Microsoft Kiota โ€” official Microsoft generator, 8 languages
// kiota generate -l csharp -d https://api.example.com/openapi/v1.json \
//                -c OrdersClient -n Example.Orders.Client -o ./Generated

// Usage:
var http = new HttpClient { BaseAddress = new Uri("https://api.example.com") };
var auth = new BaseBearerTokenAuthenticationProvider(new TokenProvider());
var adapter = new HttpClientRequestAdapter(auth, httpClient: http);
var client  = new OrdersClient(adapter);
var order   = await client.Orders[42].GetAsync();   // strongly typed

Step 6: Lint + diff in CI (catch breaking changes)

# .github/workflows/api.yml
# - run: dotnet run --project Api -- --export-openapi openapi.json
# - uses: stoplightio/spectral-action@latest      # style + correctness lint
# - uses: oasdiff/oasdiff-action@main             # breaking-change detection
#   with:
#     base: ./baseline/openapi.json
#     revision: ./openapi.json
#     fail-on: breaking
# Result: PR fails if a contract-breaking change ships without a version bump.

Common Pitfalls

  1. Hand-written specs that drift.If the spec isn't generated from (or validated against) the running code, it will lie within a sprint. Either generate or contract-test.
  2. No operationId. Generated SDK method names become GetUsers_2_post_v3(). Set.WithName("CreateOrder") on every endpoint.
  3. Missing error schemas. A spec that only documents the happy path produces SDKs that throwApiException for everything. Document application/problem+json for 4xx/5xx.
  4. Inline schemas everywhere. Same shape repeated across 12 endpoints = 12 generated types. Use$ref / shared DTOs.
  5. Ignoring breaking-change detection. Renaming a field is a breaking change even if your tests still pass. Runoasdiff in CI.
  6. Still on OpenAPI 2 / Swashbuckle 5. 3.1 has nullable, oneOf/anyOf, JSON Schema 2020-12 alignment. Modern tooling (Kiota, AI agents) targets 3.1.

Practical Takeaways

  • .NET 9 has built-in OpenAPI โ€” drop Swashbuckle from new projects.
  • Render with Scalar or Redoc; both beat the classic Swagger UI.
  • Set WithName, Produces<T>, ProducesProblem on every endpoint.
  • Generate clients with Kiota or NSwag โ€” never hand-write SDKs.
  • Document errors with application/problem+json (RFC 9457).
  • Lint specs with Spectral; diff against baseline with oasdiff in CI.
  • OpenAPI 3.1 + JSON Schema 2020-12 is the format AI agents read for tool-calling โ€” keep it accurate.