ASP.NET Core Web API Tutorial (.NET 10): CRUD and 7 Errors

Last updated: October 26, 2026 · Tested with .NET 10.0.12 (SDK 10.0.401), Microsoft.EntityFrameworkCore.Sqlite 10.0.14, Microsoft.AspNetCore.OpenApi 10.0.14, Swashbuckle.AspNetCore.SwaggerUI 10.3.1, Windows 11

Short answer: to build a REST API in ASP.NET Core on .NET 10, create the project with dotnet new webapi --use-controllers, register an EF Core DbContext, and write an [ApiController] class with one action per HTTP method: GET returns 200 or 404, POST returns 201 with a Location header, PUT and DELETE return 204. Below is the complete code I ran, the real responses it returns, and the seven errors I hit on the way, each with the exact message and the fix.

You see this Cause Fix
InvalidOperationException: No route matches the supplied values. on POST CreatedAtAction(nameof(GetByIdAsync), ...): MVC drops the Async suffix Name the action GetById
Record type '...' has validation metadata defined on property '...' that will be ignored [property: Range] on a record parameter Put the attribute on the parameter
Unable to resolve service for type 'ShopDb' while attempting to activate 'ProductsController' AddDbContext missing Register the context in Program.cs
JsonException: A possible object cycle was detected Returning EF entities with navigation properties Return DTOs
415 Unsupported Media Type Body sent without Content-Type: application/json Set the header in the client
400 with "The request field is required." and $.price A JSON value of the wrong type Fix the client; read the $. error
/swagger returns 404 The .NET 9 and 10 templates no longer include Swagger UI Add a UI for /openapi/v1.json

How I Tested It

One project contains the working API. Each mistake from the table sits behind a compile symbol, so dotnet build -p:Case=ASYNCNAME builds the API with only that one mistake. A Python script starts each build and sends real HTTP requests. The response blocks below are copied from its output: > is the request, < the status code and the headers that matter, then the body.

Step 1: Create the Project

dotnet new webapi --use-controllers -o Api

With SDK 10.0.401 the generated Program.cs is short, and it is the reason for error 7 below:

builder.Services.AddControllers();
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

Add EF Core with SQLite (any provider works the same way) and, if you want a browser UI, Swagger UI:

<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.14" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.3.1" />

Step 2: Entities, DbContext and DTOs

The entities are what EF Core stores. The API never returns them directly (error 4 shows why); it accepts a request record and returns a DTO:

public class Category
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public List<Product> Products { get; set; } = [];
}

public class Product
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public decimal Price { get; set; }
    public int CategoryId { get; set; }
    public Category? Category { get; set; }
}

public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public DbSet<Product> Products => Set<Product>();
    public DbSet<Category> Categories => Set<Category>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>().Property(p => p.Price).HasConversion<double>();
        modelBuilder.Entity<Category>().HasData(new Category { Id = 1, Name = "Keyboards" });
        modelBuilder.Entity<Product>().HasData(new Product { Id = 1, Name = "Mechanical keyboard", Price = 49.90m, CategoryId = 1 });
    }
}

public record ProductDto(int Id, string Name, decimal Price, int CategoryId);

public record CreateProductRequest(
    [Required, StringLength(100)] string Name,
    [Range(0.01, 100_000)] decimal Price,
    [Range(1, int.MaxValue)] int CategoryId);

The HasConversion<double>() is only there because SQLite has no decimal type and can’t sort or compare decimal columns; on SQL Server use HasPrecision(10, 2) instead.

Step 3: Register the DbContext

builder.Services.AddControllers();
builder.Services.AddOpenApi();
builder.Services.AddDbContext<ShopDb>(o => o.UseSqlite(builder.Configuration.GetConnectionString("Shop")));

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.UseSwaggerUI(o => o.SwaggerEndpoint("/openapi/v1.json", "Shop API v1"));
}

app.MapControllers();
app.Run();

The connection string lives in appsettings.json as "Shop": "Data Source=shop.db". My test app also calls EnsureCreated() at startup so every run starts from the seed data; a real app uses migrations.

Step 4: The CRUD Controller

[ApiController]
[Route("api/products")]
public class ProductsController(ShopDb db) : ControllerBase
{
    [HttpGet]
    public async Task<List<ProductDto>> GetAll(CancellationToken ct) =>
        await db.Products
            .OrderBy(p => p.Id)
            .Select(p => new ProductDto(p.Id, p.Name, p.Price, p.CategoryId))
            .ToListAsync(ct);

    [HttpGet("{id:int}")]
    public async Task<ActionResult<ProductDto>> GetById(int id, CancellationToken ct)
    {
        var p = await db.Products.FindAsync([id], ct);
        return p is null ? NotFound() : new ProductDto(p.Id, p.Name, p.Price, p.CategoryId);
    }

    [HttpPost]
    public async Task<ActionResult<ProductDto>> Create(CreateProductRequest request, CancellationToken ct)
    {
        if (!await db.Categories.AnyAsync(c => c.Id == request.CategoryId, ct))
        {
            ModelState.AddModelError(nameof(request.CategoryId), $"Category {request.CategoryId} does not exist.");
            return ValidationProblem(ModelState);
        }

        var product = new Product { Name = request.Name, Price = request.Price, CategoryId = request.CategoryId };
        db.Products.Add(product);
        await db.SaveChangesAsync(ct);

        var dto = new ProductDto(product.Id, product.Name, product.Price, product.CategoryId);
        return CreatedAtAction(nameof(GetById), new { id = product.Id }, dto);
    }

    [HttpPut("{id:int}")]
    public async Task<IActionResult> Update(int id, CreateProductRequest request, CancellationToken ct)
    {
        var product = await db.Products.FindAsync([id], ct);
        if (product is null) return NotFound();

        product.Name = request.Name;
        product.Price = request.Price;
        product.CategoryId = request.CategoryId;
        await db.SaveChangesAsync(ct);
        return NoContent();
    }

    [HttpDelete("{id:int}")]
    public async Task<IActionResult> Delete(int id, CancellationToken ct)
    {
        var deleted = await db.Products.Where(p => p.Id == id).ExecuteDeleteAsync(ct);
        return deleted == 0 ? NotFound() : NoContent();
    }
}

Three details that matter: [ApiController] turns invalid input into a 400 before your action runs; {id:int} means /api/products/abc doesn’t match at all (404, not a binding error); and ExecuteDeleteAsync deletes with one SQL statement instead of loading the row first.

Step 5: What the API Returns

> GET /api/products/1
< 200 {"Content-Type": "application/json; charset=utf-8"}
{"id":1,"name":"Mechanical keyboard","price":49.9,"categoryId":1}

> GET /api/products/999
< 404 {"Content-Type": "application/problem+json; charset=utf-8"}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"00-bcf202375a017ec104e062e7b6682307-ca2442b4644c8c2f-00"}

> POST /api/products (application/json) {"name": "USB-C cable", "price": 9.99, "categoryId": 1}
< 201 {"Content-Type": "application/json; charset=utf-8", "Location": "http://localhost:5500/api/products/2"}
{"id":2,"name":"USB-C cable","price":9.99,"categoryId":1}

> PUT /api/products/1 (application/json) {"name": "Mechanical keyboard v2", "price": 59.9, "categoryId": 1}
< 204 {}

> DELETE /api/products/2
< 204 {}

> DELETE /api/products/2
< 404 {"Content-Type": "application/problem+json; charset=utf-8"}

Validation needs no code in the action. Empty name and zero price:

> POST /api/products (application/json) {"name": "", "price": 0, "categoryId": 1}
< 400 {"Content-Type": "application/problem+json; charset=utf-8"}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Name":["The Name field is required."],"Price":["The field Price must be between 0.01 and 100000."]},"traceId":"00-d1122cac36456bb7905401153a759ab6-47f0ec61eb292b40-00"}

The category check in Create uses ValidationProblem(ModelState), so a rule that needs the database returns the same shape:

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"CategoryId":["Category 9 does not exist."]},"traceId":"00-f766471c2389939ead78888fddc56c01-56b696aa974adc1c-00"}

An HTTP method without an action gets 405 with an Allow header listing the ones that exist:

> PATCH /api/products/1 (application/json) {"price": 1}
< 405 {"Allow": "DELETE, GET, PUT"}

The Errors I Hit

1. No route matches the supplied values (CreatedAtAction)

Name the GET action GetByIdAsync, as many async-naming guidelines suggest, and point CreatedAtAction at it:

return CreatedAtAction(nameof(GetByIdAsync), new { id = product.Id }, dto);

The product is saved, and then the response fails:

< 500 {"Content-Type": "text/plain; charset=utf-8"}
System.InvalidOperationException: No route matches the supplied values.

MVC removes the Async suffix from action names by default, so the action is registered as GetById and nameof(GetByIdAsync) matches nothing. The row is already in the database when the 500 comes back, so a client that retries creates a duplicate. The next GET in my run:

> GET /api/products
< 200 {"Content-Type": "application/json; charset=utf-8"}
[{"id":1,"name":"Mechanical keyboard","price":49.9,"categoryId":1},{"id":2,"name":"USB-C cable","price":9.99,"categoryId":1}]

Either drop the suffix from the method name (what I did above) or turn the trimming off:

builder.Services.AddControllers(o => o.SuppressAsyncSuffixInActionNames = false);

With that option the same code returns 201 with the Location header.

2. Record type has validation metadata defined on property

Records with attributes are a natural fit for request bodies, but the attribute target matters. With [property: ...]:

public record CreateProductRequest(
    [property: Required, StringLength(100)] string Name,
    [property: Range(0.01, 100_000)] decimal Price,
    [property: Range(1, int.MaxValue)] int CategoryId);

every POST and PUT fails before the action runs:

System.InvalidOperationException: Record type 'CreateProductRequest' has validation metadata defined on property 'CategoryId' that will be ignored. 'CategoryId' is a parameter in the record primary constructor and validation metadata must be associated with the constructor parameter.

MVC validates records through their constructor parameters, so remove property: and put the attributes on the parameters, as in Step 2.

3. Unable to resolve service for type ‘ShopDb’

Forget AddDbContext (or register it in a different project’s startup) and every request to the controller fails:

System.InvalidOperationException: Unable to resolve service for type 'ShopDb' while attempting to activate 'ProductsController'.

The type in the message is the constructor parameter the container couldn’t build. Register it in Program.cs before builder.Build(). The same message with an interface name (IProductRepository) means that service is missing its AddScoped.

4. A possible object cycle was detected

Return the EF entities with their navigation properties loaded:

[HttpGet]
public async Task<List<Category>> GetAll(CancellationToken ct) =>
    await db.Categories.Include(c => c.Products).ToListAsync(ct);

Category.Products[0].Category points back to the category, and System.Text.Json gives up:

System.Text.Json.JsonException: A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 32. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. Path: $.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Id.

The quick fix is a serializer option:

builder.Services.AddControllers()
    .AddJsonOptions(o => o.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles);
[{"id":1,"name":"Keyboards","products":[{"id":1,"name":"Mechanical keyboard","price":49.9,"categoryId":1,"category":null}]}]

It works, but look at the output: every product now carries a "category":null that means nothing to the client, and every column you add to the table appears in the API. Projecting to DTOs fixes both:

Ok(await db.Categories
    .Select(c => new { c.Id, c.Name, Products = c.Products.Select(p => new ProductDto(p.Id, p.Name, p.Price, p.CategoryId)) })
    .ToListAsync(ct));
[{"id":1,"name":"Keyboards","products":[{"id":1,"name":"Mechanical keyboard v2","price":59.9,"categoryId":1}]}]

5. 415 Unsupported Media Type

A [FromBody] parameter (implicit for complex types under [ApiController]) only accepts the content types of the input formatters, which is JSON by default. Sent as text/plain:

> POST /api/products (text/plain) name=Mouse
< 415 {"Content-Type": "application/problem+json; charset=utf-8"}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.16","title":"Unsupported Media Type","status":415,"traceId":"00-69a553a9a92782db9809bdd4741c99b3-e325864cb67d4f21-00"}

The fix is on the client: send Content-Type: application/json (HttpClient.PostAsJsonAsync does it for you; with fetch or Postman you set it yourself). HTML forms post application/x-www-form-urlencoded, which needs [FromForm] on the parameter.

6. 400 “The request field is required.” with a $. error

Send a string where the API expects a number:

> POST /api/products (application/json) {"name": "Mouse", "price": "cheap", "categoryId": 1}
< 400 {"Content-Type": "application/problem+json; charset=utf-8"}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"request":["The request field is required."],"$.price":["The JSON value could not be converted to CreateProductRequest. Path: $.price | LineNumber: 0 | BytePositionInLine: 34."]},"traceId":"00-755f9da9bd6a1dbf674a02d968e776c4-535ec9d23ce81e87-00"}

The first error is misleading: the body was sent, but deserialization failed, so the parameter ended up null and [ApiController] reports it as missing (request is the parameter name). The useful line is the one whose key starts with $.: it is a JSON path to the bad value.

7. /swagger returns 404 on .NET 9 and .NET 10

The template calls MapOpenApi(), which serves the JSON document only. On the unchanged template:

> GET /swagger
< 404 {}

> GET /openapi/v1.json
< 200 {"Content-Type": "application/json;charset=utf-8"}

Swagger UI was removed from the template in .NET 9, so there is no UI until you add one. With Swashbuckle.AspNetCore.SwaggerUI and the UseSwaggerUI line from Step 3, pointed at the built-in document:

> GET /swagger
< 301 {"Location": "swagger/index.html"}

> GET /swagger/index.html
< 200 {"Content-Type": "text/html;charset=utf-8"}

You only need the UI package, not the full Swashbuckle generator, because ASP.NET Core writes the document itself. If that document’s format matters to your tooling, note that ASP.NET Core 11 changes it to OpenAPI 3.2: see OpenAPI 3.2 in ASP.NET Core 11.

Checklist

  • [ApiController] on every controller, route templates with constraints ({id:int}).
  • Accept request records, return DTOs, never the EF entities.
  • Validation attributes on record parameters, without property:.
  • CreatedAtAction(nameof(GetById), ...) with an action name that has no Async suffix.
  • 404 for a missing id, 204 for PUT and DELETE, 201 with Location for POST.
  • Rules that need the database go through ModelState + ValidationProblem so every 400 has the same shape.
  • Pass the CancellationToken to EF Core.
  • Add a UI for /openapi/v1.json if you want one; the template doesn’t.

Microsoft’s version of this tutorial: Create a controller-based web API with ASP.NET Core. To turn the unhandled 500s above into proper ProblemDetails responses, see IExceptionHandler in ASP.NET Core.

Related: Minimal APIs · API versioning in ASP.NET Core · JWT authentication · EF Core 11 breaking changes · all ASP.NET Core tutorials

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *