Welcome to the first post in the “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.

Software Design

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 args 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 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 WebApplication 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 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!