Welcome to the first post in the “.NET Core Web API – Step-by-Step Best Practices” series. In this series, we’ll walk through building a modern, clean, and maintainable Web API using .NET 9. Each post will focus on a specific aspect—from project setup and architecture to testing, migrations, and deployment.
In This Post
We’ll cover:
- Creating a new .NET 9 Web API project
- Understanding the default project structure
- Setting up FluentMigrator
- Writing and running your first database migration
Step 1: Create the API Project
Start by creating a new Web API project using the .NET CLI:
dotnet new webapi -n Sample.Api --use-controllers
Understanding the Project Structure
After creating the project, you’ll see something like this:
Sample.Api/ ├── Controllers/ │ └── WeatherForecastController.cs ├── Program.cs ├── appsettings.json └── Sample.Api.csproj . . .
Program.cs
This is the entry point of the app. It sets up services and configures the request pipeline. Here’s the full code of Program.cs
:
var builder = WebApplication.CreateBuilder(args); // Add services to the container. 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(); } app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run();
Let’s break it down:
var builder = WebApplication.CreateBuilder(args);
initializes the app’s builder, where you configure services and settings. Theargs
parameter allows command-line arguments to be passed in.builder.Services.AddControllers();
adds support for controllers, enabling your Web API to handle incoming HTTP requests.builder.Services.AddOpenApi();
, this is simplified in dotnet 9, and used instead ofbuilder.Services.AddEndpointsApiExplorer();
andbuilder.Services.AddSwaggerGen();
. It adds Swagger support for generating API documentation. This is useful for testing and exploring your API.var app = builder.Build();
builds theWebApplication
object, finalizing the app configuration and preparing it for running.if (app.Environment.IsDevelopment()) { app.MapOpenApi(); // same as app.UseSwagger(); app.UseSwaggerUI(); }
enables Swagger UI for development environments, making it easy to explore and test the API.app.UseHttpsRedirection();
redirects all http requests to httpsapp.UseAuthorization();
adds middleware for handling authorization, which is necessary if your API needs to control access to certain routes.app.MapControllers();
maps the controller routes to the HTTP request pipeline, allowing the API to handle requests.app.Run();
starts the app and begins listening for incoming requests.
appsettings.json
This file is used for configuration—stuff like connection strings, logging, and environment-specific settings:
{ "ConnectionStrings": { "DefaultConnection": "Server=localhost;Database=SampleDb;Trusted_Connection=True;TrustServerCertificate=True" }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*" }
Controllers/
This folder holds your API endpoints. It’s where we’ll define controllers.
Step 2: Create a Separate Migrations Project
We want to keep our code modular and focused , so we’ll separate database migration logic from our main Web API project and create a dedicated class library just for migrations.
Why put migrations in a separate class library?
Here are a couple of reasons:
- Separation of concerns : Your Web API project should focus on handling HTTP requests and responses—not managing database schema. Keeping migrations in their own project ensures the API stays lean and focused.
- Cleaner dependencies : The migration project will only reference what’s needed for database changes.
- Better organization : As your project grows, having migrations away from API, in a dedicated project, keeps things easier to manage and navigate.
Create the migration library
Let’s create the class library:
dotnet new classlib -n Sample.Migrations
Then install the FluentMigrator packages:
cd Sample.Migrations dotnet add package FluentMigrator.Runner dotnet add package FluentMigrator.Extensions.SqlServer
Now go back to your Web API project and link it to the migration library:
cd ../Sample.Api dotnet add reference ../Sample.Migrations/Sample.Migrations.csproj
At this point, your solution structure looks like this:
Sample.Api/ ├── Program.cs ├── appsettings.json └── ... Sample.Migrations/ └── Sample.Migrations.csproj
Step 3: Create Your First Migration
Inside the Sample.Migrations
project, create folder Migrations/
, and add this migration class:
using FluentMigrator; namespace Sample.Migrations.Migrations; [Migration(20250421001)] public class InitialMigration : Migration { public override void Up() { Create.Table("Users") .WithColumn("Id").AsInt32().PrimaryKey().Identity() .WithColumn("Username").AsString(100).NotNullable() .WithColumn("Email").AsString(200).NotNullable(); } public override void Down() { Delete.Table("Users"); } }
About the [Migration]
attribute
The number you pass into the [Migration(...)]
attribute is a unique ID for the migration. A common and helpful naming convention is to use a timestamp format, followed by a sequence number. Example:
YYYYMMDD + sequence
So in our case:
20250421001
That means this is the first migration created on April 21, 2025. This format is nice because:
- Migrations are naturally sorted in order
- You can tell when each migration was created at a glance
- It avoids name conflicts even if multiple migrations are added in the same day
Step 4: Configure FluentMigrator in the Web API
Back in Program.cs
of Sample.Api
, register FluentMigrator and point it to the Sample.Migrations
assembly:
using FluentMigrator.Runner; using Sample.Migrations.Migrations; var builder = WebApplication.CreateBuilder(args); // Add services builder.Services.AddControllers(); builder.Services.AddOpenApi(); // Configure FluentMigrator using the separate migrations project builder.Services.AddFluentMigratorCore() .ConfigureRunner(rb => rb .AddSqlServer() .WithGlobalConnectionString( builder.Configuration.GetConnectionString("DefaultConnection")!) .ScanIn(typeof(InitialMigration).Assembly).For.Migrations()) .AddLogging(lb => lb.AddFluentMigratorConsole()); var app = builder.Build(); // Apply pending migrations on startup using (var scope = app.Services.CreateScope()) { var runner = scope.ServiceProvider.GetRequiredService<IMigrationRunner>(); runner.MigrateUp(); } if (app.Environment.IsDevelopment()) { app.MapOpenApi(); } app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run();
Coming Up Next…
In Part 2, we’ll dive into global error handling and logging. We’ll cover:
- Setting up custom error handling middleware
- Implementing logging for your API using NLog
- How to effectively capture and log error details
Thanks for reading! See you in Part 2!
Top comments (0)