Sitemap
Johni Douglas Marangon

Johni Douglas Marangon

Senior Engineer with 18+ years of experience building complex software from scratch. Backend, cloud, and AI. Always building, learning, and exploring new ideas.

The Simplest Way to Build a REST API in .NET — Minimal API

7 min readNov 2, 2026

--

Press enter or click to view image in full size

Java and .NET are well-known platforms for building complete REST API projects, offering a rich set of built-in features (authorization, database connectivity, design patterns, and more) compared to tools like Python or Node.js. Building even a minimal REST API in .NET traditionally required creating multiple files, classes, and supporting artifacts.

Introduced in .NET 6, Minimal APIs let you spin up a small REST API with just a Program.cs and no other files.

.NET 7 and .NET 8 then expanded Minimal APIs significantly, adding things like route groups (MapGroup), typed results (TypedResults), better filters, and improved OpenAPI support. So if you're aiming for "production-ready" minimal APIs, it's worth knowing that the feature matured a lot after its initial .NET 6 release.

This post builds a tiny .NET 10 API for a to-do list. We’ll wire up the four HTTP methods that cover basic CRUD — GET, POST, PUT, DELETE — plus the new QUERY method for searches, all against an in-memory list, with no database and no boilerplate.

By the end, you’ll have a working API you can hit from the browser or curl, and a clear picture of what each line is doing.

What we’re going to build

A single resource: a Todo, with an Id, a Title, and an IsDone flag. We’ll expose six endpoints over it:

  • GET /todos — list every to-do.
  • GET /todos/{id} — fetch one by id.
  • POST /todos — create a new one.
  • PUT /todos/{id} — update an existing one.
  • DELETE /todos/{id} — remove one.
  • QUERY /todos — search with filters in the request body.

That’s a complete CRUD surface, plus a search. We’ll keep the data in a plain List<Todo> so the focus stays on the API itself, not on persistence.

A bonus verb: QUERY for safe searches

Standardized in June 2026 as RFC 10008, QUERY fills the gap between GET and POST. It supports complex search requests with a body while remaining safe and idempotent, making caching and automatic retries possible. It’s a cleaner way to handle read-only operations when filters become too complex to fit in a URL.

Prerequisites

The .NET 10 SDK. Version 10.0 is the current Long-Term Support release, so it’s a good baseline for new work. Confirm your install with:

dotnet --version

You should see 10.0.x.

This post needs .NET 10, because the QUERY endpoint uses HttpMethods.Query. On .NET 9 you can follow along if you drop that endpoint; Guid.CreateVersion7() needs .NET 9 or later.

Create the project

dotnet new web -o MinimalTodoApi --framework net10.0
cd MinimalTodoApi

The web template is the empty ASP.NET Core starter — no MVC, no controllers, just the minimal host. It gives you a Program.cs with a couple of lines in it and nothing else. That file is the entire app.

The whole API

Open Program.cs and replace its contents with this:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var todos = new List<Todo>
{
new(Guid.Parse("019a3a1c-5e00-7000-8000-000000000001"),
"Write the tutorial", false),
new(Guid.Parse("019a3a1c-5e00-7000-8000-000000000002"),
"Publish the draft", false),
new(Guid.Parse("019a3a1c-5e00-7000-8000-000000000003"),
"Respond to the comments", true)
};

// List all
app.MapMethods("/todos", [HttpMethods.Get], () => Results.Ok(todos));

// Get one
app.MapMethods("/todos/{id}", [HttpMethods.Get], (Guid id) =>
todos.FirstOrDefault(t => t.Id == id) is { } todo
? Results.Ok(todo)
: Results.NotFound());

// Create
app.MapMethods("/todos", [HttpMethods.Post], (TodoInput input) =>
{
var todo = new Todo(Guid.CreateVersion7(), input.Title, input.IsDone);
todos.Add(todo);
return Results.Created($"/todos/{todo.Id}", todo);
});

// Update
app.MapMethods("/todos/{id}", [HttpMethods.Put], (Guid id, TodoInput input) =>
{
var index = todos.FindIndex(t => t.Id == id);
if (index == -1) return Results.NotFound();
todos[index] = new Todo(id, input.Title, input.IsDone);
return Results.NoContent();
});

// Delete
app.MapMethods("/todos/{id}", [HttpMethods.Delete], (Guid id) =>
todos.RemoveAll(t => t.Id == id) > 0
? Results.NoContent()
: Results.NotFound());

// Search
app.MapMethods("/todos", [HttpMethods.Query], (TodoFilter filter) =>
{
var matches = todos.Where(t =>
(filter.TitleContains is null ||
t.Title.Contains(filter.TitleContains, StringComparison.OrdinalIgnoreCase))
&& (filter.IsDone is null || t.IsDone == filter.IsDone));

return Results.Ok(matches);
});

app.Run();

record Todo(Guid Id, string Title, bool IsDone);
record TodoInput(string Title, bool IsDone);
record TodoFilter(string? TitleContains, bool? IsDone);

That’s the complete application. Run it:

dotnet run --urls http://localhost:5000

The --urls flag pins the app to port 5000 so the examples below work as written. Without it, the template picks a random port from Properties/launchSettings.json, and you’d need to use the URL the console prints instead. Once you see Now listening on: http://localhost:5000, the API is live.

What each piece is doing

Two lines of setup, and the rest is endpoints.

  • WebApplication.CreateBuilder(args) configures the host; builder.Build() produces the app you map routes onto. In a minimal API these are the only two lines of plumbing you need.
  • MapMethods connects a route and one or more HTTP methods to a handler. The handler is just a lambda, and whatever it returns is sent back as the response.

You’ll usually see MapGet, MapPost, MapPut, and MapDelete in Minimal API code, and they work exactly the same here. There’s no MapQuery shortcut, though, so QUERY has to go through MapMethods. I used MapMethods for every endpoint to keep them all in one consistent style.

The handler parameters carry all the meaning, and ASP.NET Core fills them in for you by name:

  • (Guid id) matches the {id} in the route. A request to /todos/019a3a1c-5e00-7000-8000-000000000001 arrives with id already parsed into a Guid. If the value isn't a valid GUID, the framework returns 400 Bad Request before your handler runs.
  • (TodoInput input) has no matching segment in the route, so the framework reads it from the JSON request body. You don't need a [FromBody] attribute or any manual deserialization.
  • (TodoFilter filter) works the same way on the QUERY endpoint.

Return values are just as automatic. Hand back a Todo and it’s serialized to JSON with a 200 OK. Hand back a Results.* value when you want to control the status code:

  • Results.Ok(todo) → 200 with a body.
  • Results.Created(uri, todo) → 201, the correct response for a successful POST, with a Location header pointing at the new resource.
  • Results.NoContent() → 204, the usual answer to a successful PUT or DELETE where there’s nothing to send back.
  • Results.NotFound() → 404, for an id that isn’t there.

Using the right status code is most of what makes an API “RESTful” — the verb says what you’re doing, the status says how it went.

Get Johni Douglas Marangon’s stories in your inbox

Join Medium for free to get updates from this writer.

About Guid.CreateVersion7() in the Create handler: a UUIDv7 is a UUID that begins with a timestamp. RFC 9562 standardized it in 2024. Its first 48 bits hold the number of milliseconds since the Unix epoch, and almost all of the remaining bits are random. Starting with .NET 9, you can generate one with Guid.CreateVersion7().

The *is { } todo* in the *GET /todos/{id}* handler is a C# pattern-matching shortcut: it’s true when *FirstOrDefault* finds a match (i.e. the result isn’t null), and binds that match to *todo* in one step. It saves a null check and a separate variable.

Try it out

With the app running, list the seeded to-dos in your browser by visiting http://localhost:5000/todos, or from the terminal.

List all todos:

curl -i http://localhost:5000/todos

Get one todo:

curl -i http://localhost:5000/todos/019a3a1c-5e00-7000-8000-000000000001

Create one todo:

curl -i -X POST http://localhost:5000/todos \
-H "Content-Type: application/json" \
-d '{"title":"Learn minimal APIs","isDone":false}'

Update a todo:

curl -i -X PUT http://localhost:5000/todos/019a3a1c-5e00-7000-8000-000000000001 \
-H "Content-Type: application/json" \
-d '{"title": "Write the tutorial", "isDone": true}'

Search a todo using JSON body:

curl -i -X QUERY http://localhost:5000/todos \
-H "Content-Type: application/json" \
-d '{"titleContains": "tutorial", "isDone": true}'

Remove a todo:

curl -i -X DELETE http://localhost:5000/todos/019a3a1c-5e00-7000-8000-000000000001

Each call hits one of the six endpoints, returns the matching status code, and leaves the in-memory list in its new state.

Wrapping up

With Minimal APIs, you can build a functional REST API with just a few lines of C# and a single Program.cs file. ASP.NET Core handles much of the plumbing for you, from route mapping and parameter binding to JSON serialization and HTTP responses, letting you focus on the application logic.

We covered the four core CRUD operations (GET, POST, PUT, and DELETE), plus the newer QUERY method for more complex, safe search operations. We also used UUIDv7 to generate time-ordered identifiers without adding external dependencies.

Of course, an in-memory list is only a starting point. For a production application, you’ll likely want to introduce persistent storage, dependency injection, validation, authentication, logging, and proper error handling. (A plain List<T> also isn’t thread-safe, so it’s fine for a demo but not for real traffic.) Minimal APIs support all of these features without forcing you into a controller-based architecture.

The whole thing is about sixty lines. Run dotnet new web, paste the handlers in, and you have a working CRUD API in the time it takes to read this.

Minimal doesn’t mean limited. You can start with a single file, keep your API straightforward, and gradually introduce the structure and features your application needs as it grows.

About me

I am a senior backend engineer with 18+ years of experience building software across different industries. I specialize in building complex software from scratch, backend architecture, and cloud-based systems, with a growing focus on AI-powered solutions.

He enjoys turning complex technical challenges into pragmatic, scalable solutions. Always building, learning, and exploring new ideas. 🚀

Connect with me: LinkedIn 🚀

Learn more about the: Oracle ACE Program 💡⭐️

Oracle ACE Associate: Johni Douglas 🏅💻

--

--