Making OpenAPI the source of truth for ASP.NET Core Minimal APIs
Most ASP.NET Core APIs treat OpenAPI as an output generated from code. I wanted to explore the opposite model for Minimal APIs: keep the OpenAPI document in the project, generate the HTTP surface from it at build time, and hand-write only the application-specific behavior.
This contract-first approach matters most when the contract is more than generated documentation. If the API boundary needs to be reviewed explicitly, versioned intentionally, or shared across team boundaries before the implementation is finished, it helps to treat the OpenAPI document as a real part of the project rather than something the application emits later.
The usual ASP.NET Core flow
In a typical ASP.NET Core application, you write the HTTP surface in C# first, then generate the OpenAPI document from the application using Swashbuckle or similar tooling.
The code-first flow works well when OpenAPI is mainly documentation or an integration aid generated from an already finished application. But it also means contract changes are usually detected after the fact. If a route changes, a request shape changes, or a response model evolves, the contract change only becomes visible once a new schema is generated and compared with a previous one, often somewhere in a CI pipeline rather than at the point where the change is introduced.
Authoring the contract directly
My interest in this approach started after attending the panel discussion OpenAPI & .NET: You’re Doing It Wrong at NDC Oslo 2023. I did not come away thinking one model always wins, but I did start wondering what a contract-first workflow could look like in ASP.NET Core without feeling forced.
Contract-first tooling in .NET already exists. NSwag, for example, has long supported generating server-side code from an OpenAPI document through NSwagStudio. What I wanted to explore here was a version that fits naturally into the Minimal API edit-build-run loop rather than an external generation step built around controllers.
Source generators make contract-first workflows practical inside a normal project and build loop, without pushing code generation into a separate preprocessing step.
Eventually, I spent some time working on MinimalOpenAPI, a small library that aims to bring contract-first ergonomics similar to technologies such as WCF and gRPC.
To get started, add the package to the project:
dotnet add package MinimalOpenAPI --prerelease
The library is still pre-release, which is why the --prerelease flag is required.
Once the package is installed and the OpenAPI document is referenced from the project file, it becomes part of the build.
<ItemGroup>
<OpenApi Include="openapi.yml" />
</ItemGroup>
The runtime setup stays intentionally small.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMinimalOpenApi();
var app = builder.Build();
app.MapMinimalOpenApiEndpoints();
app.Run();
At this point the build can generate the C# surface from the OpenAPI document: DTOs, endpoint base classes, and route registration code. From there, you can focus on implementing endpoint behavior.
A small example
To make this more concrete, I put together a small bookstore sample built with MinimalOpenApiSample.
The domain itself is intentionally simple. It is just large enough to show the interesting parts of the workflow without burying them under application logic. The point of the sample is not the bookstore. It is the shape of the contract and the code generated from it.
The sample includes a mix of reusable schemas such as Book and Category, operation-specific inline request and response shapes, grouped query parameters for search endpoints, and validation constraints expressed directly in the OpenAPI document.
This mix makes the sample a good fit for showing what the build generates, how inline schemas can stay local to an operation, and how much of the repetitive endpoint plumbing can be produced from the contract itself.
What the build generates
Once the OpenAPI file is part of the project, the next question is what the build actually generates.
Schemas defined under components/schemas become DTO records that can be used directly in the generated endpoint surface and in the handwritten implementation. That is the most straightforward part of the pipeline, but it is also what makes the contract visible in C# as a real typed surface instead of leaving it as just YAML or JSON.
components:
schemas:
Book:
type: object
required:
# ...
properties:
# ...
Category:
type: string
enum: [fiction, non-fiction, sci-fi, fantasy]
[ExcludeFromCodeCoverage]
[GeneratedCode("MinimalOpenAPI.Generator", "1.0.0")]
public sealed record Book
{
// ...
}
[GeneratedCode("MinimalOpenAPI.Generator", "1.0.0")]
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum Category
{
Fiction,
NonFiction,
SciFi,
Fantasy
}
For each operation in the OpenAPI document, the generator also produces a typed base class that represents the HTTP contract in C#. That generated shape reflects the operation parameters, request body if present, and possible responses. Instead of hand-writing a Minimal API lambda and deciding the shape yourself, you inherit from the generated base class and implement the behavior inside the structure defined by the contract.
paths:
/books/{id}:
get:
operationId: getBookById
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/Book'
'404':
description: Book not found
[ExcludeFromCodeCoverage]
[GeneratedCode("MinimalOpenAPI.Generator", "1.0.0")]
public class GetBookByIdEndpointBase
{
public virtual Task<Results<Ok<Book>, NotFound>> HandleAsync(
Guid id,
CancellationToken cancellationToken)
=> throw new NotImplementedException(
$"{GetType().Name} has not implemented HandleAsync for operation 'getBookById'.");
}
The build also generates the registration and route-mapping code needed to expose those endpoints through ASP.NET Core, so the application does not need to manually wire each operation one by one. The generated layer is intentionally boring, so the only interesting code left to write is endpoint behavior.
What remains handwritten is the part that should remain handwritten: the OpenAPI document itself and the business logic behind each operation. You still own the contract, and you still own the behavior. The generator takes care of the repetitive code that sits in between.
public sealed class GetBookByIdEndpoint(IBookRepository books) : GetBookByIdEndpointBase
{
public override async Task<Results<Ok<Book>, NotFound>> HandleAsync(
Guid id,
CancellationToken cancellationToken)
{
var book = await books.GetByIdAsync(id, cancellationToken);
return book is null ? TypedResults.NotFound() : TypedResults.Ok(book);
}
}
This shape also keeps dependency injection flexible. The generated base class fixes the HTTP-facing contract, while the handwritten implementation remains free to inject whatever collaborators it needs through its constructor. That is the main reason the generated handlers are class-based rather than static delegate-like endpoints.
Inline schemas as a first-class case
Many code generation tools make inline schemas awkward.
That is understandable. Referenced schemas under components/schemas are reusable, easy to name, and easy to map into generated code. If everything important lives there, the generation story is usually straightforward.
The problem is that real OpenAPI documents are not always written that way. Some shapes are genuinely reusable and deserve a named schema. Others are specific to a single operation and read more naturally inline. Extracting everything into components/schemas just to keep the generator happy often makes the contract noisier than it needs to be.
The bookstore sample mixes both styles on purpose. Book and Category are reusable and live under components/schemas, but the searchBooks operation also defines operation-specific shapes inline. Its response envelope is specific to that endpoint, and its query parameters are easier to reason about as one search request than as several unrelated scalars.
paths:
/books:
get:
operationId: searchBooks
parameters:
- name: query
in: query
schema:
type: string
minLength: 2
maxLength: 100
- name: author
in: query
schema:
type: string
minLength: 1
maxLength: 100
- name: category
in: query
schema:
$ref: '#/components/schemas/Category'
responses:
'200':
content:
application/json:
schema:
type: object
required: [totalCount, items]
properties:
totalCount:
type: integer
minimum: 0
items:
type: array
items:
$ref: '#/components/schemas/Book'
The generator keeps those operation-specific shapes local to the operation by nesting them inside the generated endpoint base class.
public class SearchBooksEndpointBase
{
public sealed record OkResponse
{
[JsonPropertyName("totalCount")]
[Range(0, int.MaxValue)]
public required int TotalCount { get; init; }
[JsonPropertyName("items")]
public required Book[] Items { get; init; }
}
public sealed record Parameters
{
[FromQuery(Name = "query")]
[StringLength(100, MinimumLength = 2)]
public string? Query { get; init; }
[FromQuery(Name = "author")]
[StringLength(100, MinimumLength = 1)]
public string? Author { get; init; }
[FromQuery(Name = "category")]
public Category? Category { get; init; }
}
public virtual Task<Ok<OkResponse>> HandleAsync(
Parameters parameters,
CancellationToken cancellationToken)
=> throw new NotImplementedException();
}
I like this shape because it reflects the contract more honestly. OkResponse is not pretending to be a reusable application-wide model, and Parameters is not pretending to be a domain type. They are both scoped to searchBooks, which is exactly where they belong.
It also gives the generated endpoint surface a more stable shape over time. If the operation gains a new optional query parameter later, the generated Parameters record changes, but the handwritten handler signature can stay the same. That is a much more stable boundary between generated code and handwritten code than a long list of individual scalar parameters.
Reusable types can stay reusable, operation-specific shapes can stay local, and the code does not force the OpenAPI document into a style chosen mainly for the convenience of the generator.
Trade-offs and current limitations
This approach works best when the contract fits within the shapes the generator can represent well. The generator is intentionally scoped around shapes that map cleanly to idiomatic C# and Minimal APIs; beyond that, explicit gaps are better than clever but fragile generation.
Some OpenAPI constructs are still out of scope. In particular, schema composition features such as allOf, oneOf, and anyOf are not supported yet. That means the library works better with contracts that can be expressed as fairly direct object, enum, array, and scalar shapes than with heavily composed schema models.
Validation is another place where it is important to be precise about what is and is not happening. The generator can translate constraints such as minLength, maxLength, minimum, maximum, and pattern into validation metadata on the generated types. Since the library targets .NET 10, those constraints can participate in the new Minimal API validation support Microsoft added based on source generation. Even so, generating validation metadata from the OpenAPI document and enforcing validation at runtime are still separate concerns. The generator emits the constraints, but enforcement depends on how validation is wired into the application. This keeps constraints visible in generated types without overstating runtime guarantees.
There is also a runtime trade-off in the generated handler model. Because the implementation is shaped as a class with constructor injection rather than a static handler method, each request ends up going through an endpoint instance. That gives the implementation a clean dependency injection surface, but it is less allocation-friendly than a purely static Minimal API shape. That trade-off is intentional: constructor injection ergonomics matter more here than chasing the most allocation-minimal handler shape.
Code-first is still the simpler fit in many cases. If the OpenAPI document is mainly generated documentation, if the contract is not reviewed or versioned independently, or if the API evolves directly from the implementation, then the usual ASP.NET Core workflow is often more natural. This approach makes more sense when the contract is something you actually want to author and own.
Finally, the library is still pre-release, and some areas are still evolving. The overall model is already useful, but parts of the surface are still in flux, especially around schema publishing and serving.
Serving the original schema file
One detail I cared about from the start was that the schema served by the application should be the same schema that was authored in the project. If the OpenAPI document is meant to be the starting point, it should not disappear after generation or be replaced at runtime by a reconstructed approximation.
This process starts in the project file. The OpenAPI document can be marked for publishing so it is copied to the build output together with the application.
<ItemGroup>
<OpenApi Include="openapi.yml" Publish="true" />
</ItemGroup>
At runtime, the application can then expose that same file directly.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMinimalOpenApi();
var app = builder.Build();
app.MapMinimalOpenApiEndpoints();
app.MapOpenApiSchemas();
app.Run();
The document served to clients is the authored contract itself, not a schema regenerated from controller metadata, route definitions, or C# types discovered at runtime.
This part of the library is still evolving, so the exact APIs and behavior around schema publishing may still change before a stable release.
Conclusion
Contract-first is not the right default for every API. In many projects, the usual ASP.NET Core flow of writing the application first and generating OpenAPI from it is simpler and entirely good enough.
But when the OpenAPI document is something you want to review explicitly, version independently, or share across team boundaries before the implementation is finished, it helps to have a workflow built around that.
MinimalOpenAPI is my attempt to make contract-first feel like everyday ASP.NET Core work: write the contract, let the build generate the plumbing, implement the logic.
The specification stays authored, not derived.
Support this blog
If you liked this article, consider supporting this blog by buying me a pizza!