Building a production-ready Web API with ASP.NET Core 9 in Visual Studio Code takes about 35 minutes from a fresh machine: install the .NET 9 SDK and the C# DevKit extension, create the project with dotnet new webapi -minimal, scaffold a controller, wire up Entity Framework Core 9, and test through the built-in OpenAPI explorer. This walkthrough covers the full setup including dependency injection patterns, JWT authentication, validation, and the scoped-vs-singleton service-lifetime pitfall that trips up most newcomers.
What changed between ASP.NET Core 2.1 and ASP.NET Core 9?
If you wrote ASP.NET Core APIs in the 2.x era and stepped away for a few years, the framework looks different in 2026. The most visible change is the Minimal APIs model introduced in .NET 6 and matured through .NET 8 and .NET 9: you can now build a complete CRUD endpoint in roughly 12 lines of Program.cs without controllers, attributes, or services scaffolding. The traditional MVC controller approach still works fine and remains the right choice for larger applications, but the Minimal API style won the hearts of teams shipping smaller services.
The dependency injection container received native support for keyed services in .NET 8, which lets you register multiple implementations of the same interface and resolve them by string key rather than by interface type. Native AOT compilation reached production maturity in .NET 9, which means you can now publish a Web API as a single self-contained native binary without a JIT compiler at runtime. This matters for cold-start performance in serverless deployments where startup time directly affects cost.
OpenAPI integration replaced Swashbuckle as the default in .NET 9 through the new Microsoft.AspNetCore.OpenApi package, which generates the OpenAPI document at build time rather than runtime. The generated document hooks into Scalar or Swagger UI through small adapter packages, but the generation itself moved into the framework. For teams who maintain their own OpenAPI-driven tooling, this change improved both performance and contract reliability.
How do I set up the development environment?
Start with a fresh VS Code installation and install the C# Dev Kit extension from the marketplace. The Dev Kit bundles the C# extension, the .NET Runtime Install Tool, and project-management features that previously required Visual Studio. Install the .NET 9 SDK separately from the official Microsoft .NET download page: pick the SDK installer for your operating system, the runtime-only package will not let you build projects.
Verify the installation through your terminal:
dotnet --version
# Expected output: 9.0.x
dotnet --list-sdks
# Expected to show 9.0.x at minimum
If the version reads as 8.x or older, your PATH still points at an older installation. On Windows the installer normally handles this, on macOS and Linux you may need to update ~/.bashrc or ~/.zshrc to point at the new SDK location.
How do I create the Web API project?
From an empty folder where you want the project to live, run the following commands:
dotnet new webapi --use-controllers --name MyApi
cd MyApi
dotnet run
The --use-controllers flag creates the traditional MVC-style project with a sample WeatherForecastController. Without that flag you get the Minimal API template. For a tutorial like this the controller-based template offers more visible structure for learning, but for actual project work the Minimal API approach is often the better starting point.
The dotnet run command launches the API on a random localhost port, which the terminal output will show. Open that URL plus /scalar/v1 or /swagger depending on which UI got bundled, and you’ll see the auto-generated documentation for the WeatherForecast endpoint.
How do I add a database with Entity Framework Core 9?
Add the EF Core packages through the CLI:
dotnet add package Microsoft.EntityFrameworkCore --version 9.0.0
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 9.0.0
dotnet add package Microsoft.EntityFrameworkCore.Design --version 9.0.0
dotnet tool install --global dotnet-ef --version 9.0.0
The Sqlite provider works for tutorials and small applications. For production you’d swap in Microsoft.EntityFrameworkCore.SqlServer, Npgsql.EntityFrameworkCore.PostgreSQL, or Pomelo.EntityFrameworkCore.MySql depending on your database choice. The interface stays identical across providers, so switching later means changing one line in your DI registration.
Create a Models folder and add a simple entity:
namespace MyApi.Models;
public class TodoItem
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public bool IsComplete { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
Create a Data folder for the DbContext:
using Microsoft.EntityFrameworkCore;
using MyApi.Models;
namespace MyApi.Data;
public class ApiDbContext(DbContextOptions<ApiDbContext> options) : DbContext(options)
{
public DbSet<TodoItem> TodoItems => Set<TodoItem>();
}
This is a primary-constructor pattern that .NET 8 introduced and .NET 9 expanded. It cuts the boilerplate of a separate constructor body for simple DbContext classes that only forward options to the base class.
Register the DbContext in Program.cs:
builder.Services.AddDbContext<ApiDbContext>(options =>
options.UseSqlite("Data Source=todos.db"));
Generate and apply the initial migration:
dotnet ef migrations add InitialCreate
dotnet ef database update
How do I write a controller that uses dependency injection correctly?
This is the section where most beginners hit the scoped-vs-singleton wall, which is also the most-asked question on Stack Overflow about ASP.NET Core DI. The short version: DbContext is scoped by default, controllers are scoped, and singletons cannot consume scoped services without explicit handling.
Create a controller in Controllers/TodosController.cs:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MyApi.Data;
using MyApi.Models;
namespace MyApi.Controllers;
[ApiController]
[Route("api/[controller]")]
public class TodosController(ApiDbContext db) : ControllerBase
{
[HttpGet]
public async Task<IEnumerable<TodoItem>> GetAll()
=> await db.TodoItems.ToListAsync();
[HttpGet("{id:int}")]
public async Task<ActionResult<TodoItem>> GetById(int id)
{
var item = await db.TodoItems.FindAsync(id);
return item is null ? NotFound() : Ok(item);
}
[HttpPost]
public async Task<ActionResult<TodoItem>> Create(TodoItem item)
{
db.TodoItems.Add(item);
await db.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = item.Id }, item);
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var item = await db.TodoItems.FindAsync(id);
if (item is null) return NotFound();
db.TodoItems.Remove(item);
await db.SaveChangesAsync();
return NoContent();
}
}
The controller uses primary constructor injection for the DbContext, which is cleaner than the older field-and-constructor pattern. The {id:int} route constraint tells ASP.NET to only match the route when the id segment parses as an integer, which gives you 404s for malformed URLs rather than 500s.
What’s the scoped-vs-singleton service problem?
This is the single most common DI mistake in ASP.NET Core, and the one that drove a Stack Overflow answer linking back to this tutorial in the first place. The pattern looks like this:
// In Program.cs
builder.Services.AddSingleton<IBackgroundProcessor, BackgroundProcessor>();
// In BackgroundProcessor.cs
public class BackgroundProcessor(ApiDbContext db) : IBackgroundProcessor
{
public async Task ProcessAsync()
{
// This will throw at startup or first request:
// "Cannot consume scoped service 'ApiDbContext' from singleton"
await db.TodoItems.ToListAsync();
}
}
The runtime throws because singleton services live for the entire application lifetime, while scoped services like DbContext live for a single request. Letting a singleton hold a reference to a scoped service would create a memory leak (the singleton would keep the DbContext alive past its scope) and a thread-safety nightmare (DbContext is not thread-safe).
The correct pattern uses IServiceScopeFactory to create a fresh scope inside the singleton when database access is needed:
public class BackgroundProcessor(IServiceScopeFactory scopeFactory) : IBackgroundProcessor
{
public async Task ProcessAsync()
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApiDbContext>();
await db.TodoItems.ToListAsync();
// db gets disposed when scope disposes
}
}
This pattern is essential for hosted services, background workers, and any singleton that needs database access. The fresh scope per operation isolates the DbContext lifetime correctly and avoids both the memory leak and the thread-safety problems.
How do I add JWT authentication?
Install the JWT package:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 9.0.0
Configure JWT validation in Program.cs:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var jwtKey = builder.Configuration["Jwt:Key"]
?? throw new InvalidOperationException("JWT key missing in config");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtKey))
};
});
builder.Services.AddAuthorization();
Add the middleware in the request pipeline:
app.UseAuthentication();
app.UseAuthorization();
Order matters here: authentication must come before authorization, and both must come before app.MapControllers(). Reversing the order silently breaks token validation while still showing endpoints as accessible in the OpenAPI explorer.
Protect your controller actions with the [Authorize] attribute. To require authentication for the whole controller, decorate the class. To require it only for specific actions, decorate just those methods.
How do I validate input properly?
ASP.NET Core 9 ships with two validation approaches: data annotations on your model classes, and FluentValidation through the open-source library. Data annotations work for simple cases but become unwieldy for cross-property rules.
For data-annotation validation:
using System.ComponentModel.DataAnnotations;
public class TodoItem
{
public int Id { get; set; }
[Required, StringLength(200, MinimumLength = 1)]
public string Title { get; set; } = string.Empty;
public bool IsComplete { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
The [ApiController] attribute on your controller automatically returns 400 Bad Request with detailed validation errors when the model fails validation, so you don’t need explicit ModelState.IsValid checks in each action.
For FluentValidation, install the package:
dotnet add package FluentValidation.AspNetCore --version 11.3.0
And register your validators in Program.cs:
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddFluentValidationAutoValidation();
FluentValidation gives you a much richer rule API with conditional validation, async rules, and complex object graphs. For anything beyond simple required fields and length checks, the migration pays off in code clarity.
How do I deploy the API to production?
For a simple deployment to a Linux server, publish the project as a self-contained executable:
dotnet publish -c Release -r linux-x64 --self-contained \
-p:PublishSingleFile=true -p:PublishTrimmed=true
The output in bin/Release/net9.0/linux-x64/publish contains a single executable plus a few support files. Copy the folder to your server, set up a systemd service to run it, and put nginx in front as a reverse proxy with TLS termination. The official Microsoft documentation on Linux + nginx deployment covers the systemd service file and nginx configuration in detail.
For container deployment, use the official Microsoft .NET 9 base images:
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["MyApi/MyApi.csproj", "MyApi/"]
RUN dotnet restore "MyApi/MyApi.csproj"
COPY . .
WORKDIR "/src/MyApi"
RUN dotnet publish "MyApi.csproj" -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApi.dll"]
This multi-stage build produces a final image around 220 MB. For smaller images, use the chiseled base images (mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled) which strip unnecessary OS components and bring the size to around 110 MB.
Frequently Asked Questions
Should I use Minimal APIs or Controllers?
For projects with fewer than 20 endpoints or microservices with a single responsibility, Minimal APIs are usually the better choice because the structure stays close to the route definitions. For larger applications with nested resources, multiple authentication policies, and complex routing, traditional controllers offer better organisation and easier testing through the existing MVC test fixtures.
What’s the difference between AddScoped, AddSingleton, and AddTransient?
Scoped services are created once per HTTP request and disposed at request end, which suits database contexts and unit-of-work patterns. Singleton services are created once at application startup and live forever, which suits configuration providers, HTTP clients with HttpClientFactory, and stateless utility services. Transient services are created fresh every time they’re requested, which suits lightweight stateless operations.
Do I need both Swagger and OpenAPI?
No. As of .NET 9 the framework generates the OpenAPI document natively through Microsoft.AspNetCore.OpenApi, and Swagger UI or Scalar simply render that document as a UI. You can use either UI library or both. The Swashbuckle package is no longer the default and exists mainly for backwards compatibility with .NET 8 and earlier projects.
How do I handle CORS for a frontend on a different domain?
Add the CORS service registration and middleware to Program.cs: builder.Services.AddCors(), then app.UseCors(policy => policy.AllowAnyHeader().AllowAnyMethod().WithOrigins("https://yourfrontend.com")). Place UseCors before UseAuthentication and after UseRouting. Never use AllowAnyOrigin in production with credentialed requests.
What’s the easiest way to log exceptions in production?
The built-in ASP.NET Core logging works through ILogger dependency injection, with sinks to console, files through Serilog or NLog, or external services like Application Insights, Seq, or Sentry. The exception-handling middleware app.UseExceptionHandler("/error") catches unhandled exceptions and routes them to a controller action that can log and return a clean error response without exposing stack traces to clients.
How do I test a Web API without a frontend?
The built-in OpenAPI explorer lets you fire requests against any endpoint directly from the browser. For repeated testing, the .http file format that VS Code supports through the REST Client extension lets you write request templates that get checked into source control. For automated testing, the WebApplicationFactory<TEntryPoint> from Microsoft.AspNetCore.Mvc.Testing spins up an in-memory test server that runs your full DI configuration without needing a real server.
What you’ve built and where to go next
You now have a working Web API with database persistence, validation, JWT authentication, and deployment configuration. The same pattern scales to dozens of controllers, multiple databases, and microservices architectures, the only structural changes are folder organisation and registration code.
For the next steps in your .NET 9 journey, the topics worth exploring include Clean Architecture for separating domain logic from infrastructure concerns, MediatR for decoupling controllers from business logic through a request-handler pattern, and SignalR for real-time communication between server and clients. Each of these adds complexity that pays off only when the application reaches a certain scale, so introduce them when the pain of doing without becomes obvious rather than as a default starting point.
For deeper coverage of related topics, my colleague’s Git workflow guide for beginners covers the source-control side of API development, and the Python virtual environments tutorial offers a useful comparison for developers working across language ecosystems.
About the author: Marcus Weber is a .NET Architect and Backend Engineer with over 12 years in the Microsoft stack. His focus areas include ASP.NET Core, Clean Architecture, Microservices, and Enterprise API design. He contributes to .NET open-source projects and writes practical tutorials for developers transitioning from older .NET versions to current releases.
Tested on: .NET 9.0.0 SDK on Windows 11 24H2, macOS 14.5, and Ubuntu 24.04 LTS, all in May 2026. Code samples verified against the official ASP.NET Core 9 documentation and the dotnet-runtime GitHub repository tag v9.0.0. For the official .NET 9 release notes see the Microsoft .NET 9 overview.