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. The- argsparameter 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 of- builder.Services.AddEndpointsApiExplorer();and- builder.Services.AddSwaggerGen();. It adds Swagger support for generating API documentation. This is useful for testing and exploring your API.
- var app = builder.Build();builds the- WebApplicationobject, 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 https
- app.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)