Minimal APIs arrived with .NET 6 and now, in Version 10, are already part of the daily routine for many developers. In this post, we'll explore best practices for organizing your Minimal APIs and see how Carter can help make them even cleaner, more modular and more elegant.
In contrast to traditional Controller classes, minimal APIs offer a more compact way to create web APIs. The downside of this approach is that the Program class can quickly become bloated. To overcome this problem, we can use some approaches and libraries that help organize the mess and bring order to things.
In this post, we’ll create a Minimal API with a disorganized Program class and then refactor it into a clean and elegant version. We’ll also explore a very versatile approach using the Carter library.
One Class to Rule Them All
.NET 6 introduced a new format for creating web APIs, eliminating the need for the Startup class and even the Controller classes, responsible for HTTP input and output methods.
In the Minimal APIs model, the Program class, previously responsible only for bootstrapping the application, now also concentrates everything that was previously in Startup and Controller: service configuration, middleware definition and endpoint mapping.
This unification considerably reduced the number of classes in the project, but also opened the door to a common problem: the tendency to transform Program.cs into an inflated, confusing and difficult-to-maintain file.
Without a structured approach to organizing these responsibilities, what should be minimalist can quickly become something far from it. Therefore, adopting strategies and tools that help modularize routes, configurations and business rules is not only a good practice but also essential for maintaining the scalability and clarity of the project.
Organization Is the Key ️
When working with Minimal APIs, the tendency is to put everything in the Program class, but the truth is that this class wasn’t designed to be a repository, but rather to orchestrate the application. Keeping this class clean, small and focused on composition is the secret to preserving readability and facilitating code evolution.
And this is only possible when we use consistent organization: separating modules, extracting configurations, delegating routes and preventing implementation details from leaking to the highest level of the application.
In this section, we’ll first create a messy example and then transform Program.cs into a truly minimalist entry point, taking in only what it should take in, while moving the rest to the most appropriate places.
Creating the Project
The complete application code is available in this GitHub repository: Customer Admin source code.
To create the base application, you can use the command below:
dotnet new web -n CustomerAdmin
Then, open the application and add the following dependencies to the .csproj file:
<ItemGroup>
<PackageReference Include="Microsoft. EntityFrameworkCore" Version="10.0.1" />
<PackageReference Include="Microsoft. EntityFrameworkCore .SqlServer" Version="10.0.1" />
<PackageReference Include="Microsoft. EntityFrameworkCore .Design" Version="10.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
Now let’s move on to the grand finale, the monstrous Program.cs class. Replace the code in the Program class with the code below:
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddLogging(logging =>
{
logging.ClearProviders();
logging.AddConsole();
});
builder.Services.AddHttpClient();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddCors(options =>
{
options.AddPolicy("Default", policy =>
{
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod();
});
});
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
var app = builder.Build();
app.UseCors("Default");
app.Use(async (context, next) =>
{
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("RequestLogger");
logger.LogInformation("Request: {method} {url}", context.Request.Method, context.Request.Path);
await next();
});
app.MapPost("/customers", async (CustomerDto dto, AppDbContext db, IEmailSender email) =>
{
if (string.IsNullOrWhiteSpace(dto.Name))
return Results.BadRequest("Name is required");
if (!new EmailAddressAttribute().IsValid(dto.Email))
return Results.BadRequest("Invalid email");
var entity = new Customer
{
Name = dto.Name,
Email = dto.Email,
CreatedAt = DateTime.UtcNow
};
db.Customers.Add(entity);
await db.SaveChangesAsync();
await email.SendAsync(dto.Email, "Welcome!", "Thanks for registering!");
return Results.Created($"/customers/{entity.Id}", entity);
});
app.MapGet("/customers", async (AppDbContext db) =>
{
var items = await db.Customers.ToListAsync();
return Results.Ok(items);
});
app.MapGet("/customers/{id:int}", async (int id, AppDbContext db) =>
{
var customer = await db.Customers.FindAsync(id);
return customer is null ? Results.NotFound() : Results.Ok(customer);
});
app.MapPost("/customers/{id:int}/activate", async (int id, AppDbContext db) =>
{
var customer = await db.Customers.FindAsync(id);
if (customer is null)
return Results.NotFound();
if (customer.IsActive)
return Results.BadRequest("Customer already active");
customer.IsActive = true;
await db.SaveChangesAsync();
return Results.Ok(customer);
});
app.Run();
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = default!;
public string Email { get; set; } = default!;
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
}
public class CustomerDto
{
public string Name { get; set; } = default!;
public string Email { get; set; } = default!;
}
public interface IEmailSender
{
Task SendAsync(string to, string subject, string body);
}
public class SmtpEmailSender : IEmailSender
{
public Task SendAsync(string to, string subject, string body)
{
Console.WriteLine($"Sending email to {to}: {subject}");
return Task.CompletedTask;
}
}
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Customer> Customers => Set<Customer>();
}
The code above demonstrates the type of architecture that starts simple but quickly becomes a serious problem as the application grows. The Program class should only be the compose root of the application, where services are registered, middlewares are configured and high-level endpoints are mapped. Instead, the code above:
- Validates data
- Accesses the database
- Implements business rules
- Sends emails
- Defines DTOs, entities, services and DbContext
- Registers all services manually
- Writes middleware logic directly in it
- Maps detailed and complex endpoints
The result is a single file that performs the work of about 10 different files, resulting in something that grows uncontrollably and prevents the healthy evolution of the application. Each new feature exacerbates the problem, leaving the class disorganized and prone to errors. Furthermore, it hinders teamwork, generates merge conflicts and makes the integration of new developers much slower.
Putting Things in Order
Now that we’ve seen a bad example of a bloated and messy Program class, let’s put things in order, separating each part into its proper place.
The image below shows what the complete application structure will look like at the end of the post:

So, let’s start with the entities, for which we can create separate classes. In this case, create a new folder called “Model” and inside it create the classes below:
namespace CustomerAdmin.Models;
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = default!;
public string Email { get; set; } = default!;
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
}
namespace CustomerAdmin.Models;
public class CustomerDto
{
public string Name { get; set; } = default!;
public string Email { get; set; } = default!;
}
Now let’s create all the other things related to the Infrastructure layer, which refers to everything that communicates with external services such as databases and web APIs. Create a new folder called “Infrastructure” and inside it create a new folder called “Data” and add the following class to it:
using CustomerAdmin.Models;
using Microsoft.EntityFrameworkCore;
namespace CustomerAdmin.Infrastructure.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
public DbSet<Customer> Customers => Set<Customer>();
}
All subsequent folders should be created inside the Infrastructure folder. So, add another folder called “Email” and, inside it, add the following interface and class:
namespace CustomerAdmin.Infrastructure.Email;
public interface IEmailSender
{
Task SendAsync(string to, string subject, string body);
}
namespace CustomerAdmin.Infrastructure.Email;
public class SmtpEmailSender : IEmailSender
{
public Task SendAsync(string to, string subject, string body)
{
Console.WriteLine($"Sending email to {to}: {subject}");
return Task.CompletedTask;
}
}
Here we create an interface and a class for sending emails, which is used only for demonstration purposes and is therefore very simple, but in real applications it can become large, so separating it from the Program class is essential.
The next step is to create the extension methods for the controllers, endpoints and policies. Create a new folder called “Extensions” and, inside it, create the classes below:
using System.ComponentModel.DataAnnotations;
using CustomerAdmin.Infrastructure.Data;
using CustomerAdmin.Infrastructure.Email;
using CustomerAdmin.Models;
using Microsoft.EntityFrameworkCore;
namespace CustomerAdmin.Infrastructure.Extensions;
public static class CustomerEndpoints
{
public static async Task<IResult> CreateCustomer(
CustomerDto dto,
AppDbContext db,
IEmailSender email
)
{
if (string.IsNullOrWhiteSpace(dto.Name))
return Results.BadRequest("Name is required");
if (!new EmailAddressAttribute().IsValid(dto.Email))
return Results.BadRequest("Invalid email");
var entity = new Customer
{
Name = dto.Name,
Email = dto.Email,
CreatedAt = DateTime.UtcNow,
};
db.Customers.Add(entity);
await db.SaveChangesAsync();
await email.SendAsync(dto.Email, "Welcome!", "Thanks for registering!");
return Results.Created($"/customers/{entity.Id}", entity);
}
public static async Task<IResult> GetAll(AppDbContext db)
{
return Results.Ok(await db.Customers.ToListAsync());
}
public static async Task<IResult> GetById(int id, AppDbContext db)
{
var customer = await db.Customers.FindAsync(id);
return customer is null ? Results.NotFound() : Results.Ok(customer);
}
public static async Task<IResult> Activate(int id, AppDbContext db)
{
var customer = await db.Customers.FindAsync(id);
if (customer is null)
return Results.NotFound();
if (customer.IsActive)
return Results.BadRequest("Customer already active");
customer.IsActive = true;
await db.SaveChangesAsync();
return Results.Ok(customer);
}
}
Here we add all the endpoints for the new customer registration API. Note that the methods are static so they can be used by the endpoint mapper that we will create next.
So now create a new class called CustomersModule and add the following code to it:
namespace CustomerAdmin.Infrastructure.Extensions;
public static class CustomersModule
{
public static IEndpointRouteBuilder MapCustomerEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/customers");
group.MapPost("/", CustomerEndpoints.CreateCustomer);
group.MapGet("/", CustomerEndpoints.GetAll);
group.MapGet("/{id:int}", CustomerEndpoints.GetById);
group.MapPost("/{id:int}/activate", CustomerEndpoints.Activate);
return app;
}
}
In the code above, we define an endpoint module where we use an extension method to encapsulate the mapping of routes related to customers. All routes are grouped with the prefix /customers, which allows for scalable application growth and facilitates API organization.
Now let’s create another extension class to define the registration of application services in the ASP.NET Core dependency injection container. This class will centralize the configuration of application dependencies such as infrastructure, HTTP communication, API documentation and CORS policies. So, still in the “Extensions” folder, add the class below:
using CustomerAdmin.Infrastructure.Data;
using CustomerAdmin.Infrastructure.Email;
using Microsoft.EntityFrameworkCore;
namespace CustomerAdmin.Infrastructure.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
{
services.AddScoped<IEmailSender, SmtpEmailSender>();
return services;
}
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration config
)
{
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(config.GetConnectionString("DefaultConnection"))
);
services.AddHttpClient();
return services;
}
public static IServiceCollection AddApiDocumentation(this IServiceCollection services)
{
services.AddEndpointsApiExplorer();
return services;
}
public static IServiceCollection AddCorsPolicies(this IServiceCollection services)
{
services.AddCors(options =>
{
options.AddPolicy(
"Default",
policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()
);
});
return services;
}
}
Note that here we have several configurations grouped into a separate file, leaving the Program class free for what it is actually responsible for.
Now let’s create the class responsible for implementing the configurations that belong to the WebApplication class. So, create a new class called WebApplicationExtensions and add the code below to it:
using CustomerAdmin.Infrastructure.Middlewares;
namespace CustomerAdmin.Infrastructure.Extensions;
public static class WebApplicationExtensions
{
public static IApplicationBuilder UseApiDocumentation(this WebApplication app)
{
return app;
}
public static IApplicationBuilder UseCorsPolicies(this WebApplication app)
{
app.UseCors("Default");
return app;
}
public static WebApplication UseRequestLogging(this WebApplication app)
{
app.UseMiddleware<RequestLoggingMiddleware>();
return app;
}
}
The last configuration class will be used to implement logging middleware. So, create a new class called RequestLoggingMiddleware and add the code below to it:
namespace CustomerAdmin.Infrastructure.Middlewares;
public class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestLoggingMiddleware> _logger;
public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
_logger.LogInformation("Request: {method} {url}", context.Request.Method, context.Request.Path);
await _next(context);
}
}
With all the implementations organized, we can finally implement the method calls in the Program class. So, replace the existing code there with the code below:
using CustomerAdmin.Infrastructure.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder
.Services.AddApplicationServices()
.AddInfrastructure(builder.Configuration)
.AddApiDocumentation()
.AddCorsPolicies();
var app = builder.Build();
app.UseRequestLogging();
app.UseApiDocumentation();
app.UseCorsPolicies();
app.MapCustomerEndpoints();
app.Run();
See how the Program class looks now. Instead of spreading configurations across multiple files, it centralizes the entire application initialization process in a fluid way.
Starting with the creation of the WebApplicationBuilder, responsible for preparing the environment, the configuration of services is done in a chained manner, which has greatly improved readability compared to the first version.
Each extension method represents a well-defined block of responsibility, such as registering application services, configuring the infrastructure (database, external integrations, etc.), API documentation and CORS policies.
Finally, the HTTP request pipeline, middleware and endpoint mapping are initiated. Thus, this refactored approach makes the Program class leaner and more expressive, serving as a high-level view of how the application is composed and initialized.
Taking a Shortcut with Carter
Carter is an open-source NuGet package for defining HTTP routes and request handlers using a simple and declarative syntax.
Below, we’ll use Carter in the refactored version and see how it can be a great option for organizing minimal APIs.
To download Carter to the project, simply run the command below:
dotnet add package Carter --version 10.0.0
Next, create a new folder in the project called “Modules,” inside it another folder called “Customers” and, inside that folder, create the class below:
namespace CustomerAdmin.Modules.Customers;
using System.ComponentModel.DataAnnotations;
public class CustomersModule : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup("/customers");
group.MapPost("/", Create);
group.MapGet("/", GetAll);
group.MapGet("/{id:int}", GetById);
group.MapPost("/{id:int}/activate", Activate);
}
private static async Task<IResult> Create(CustomerDto dto, AppDbContext db, IEmailSender email)
{
if (string.IsNullOrWhiteSpace(dto.Name))
return Results.BadRequest("Name is required");
if (!new EmailAddressAttribute().IsValid(dto.Email))
return Results.BadRequest("Invalid email");
var customer = new Customer
{
Name = dto.Name,
Email = dto.Email,
CreatedAt = DateTime.UtcNow,
};
db.Customers.Add(customer);
await db.SaveChangesAsync();
await email.SendAsync(dto.Email, "Welcome!", "Thanks for registering!");
return Results.Created($"/customers/{customer.Id}", customer);
}
private static async Task<IResult> GetAll(AppDbContext db)
{
return Results.Ok(await db.Customers.ToListAsync());
}
private static async Task<IResult> GetById(int id, AppDbContext db)
{
var customer = await db.Customers.FindAsync(id);
return customer is null ? Results.NotFound() : Results.Ok(customer);
}
private static async Task<IResult> Activate(int id, AppDbContext db)
{
var customer = await db.Customers.FindAsync(id);
if (customer is null)
return Results.NotFound();
if (customer.IsActive)
return Results.BadRequest("Customer already active");
customer.IsActive = true;
await db.SaveChangesAsync();
return Results.Ok(customer);
}
}
Note that the class we created inherits from the Carter interface: ICarterModule, which allows grouping related endpoints without coupling them to Program.cs. This isolates route definitions, validations and integrations into separate modules, making the code scalable and aligned with the true purpose of Minimal APIs: simplicity with organization.
To configure the Program class with Carter, replace the existing code with the code below:
using Carter;
using CustomerAdmin.Infrastructure.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder
.Services.AddApplicationServices()
.AddInfrastructure(builder.Configuration)
.AddApiDocumentation()
.AddCorsPolicies();
builder.Services.AddCarter();
var app = builder.Build();
app.UseRequestLogging();
app.UseApiDocumentation();
app.UseCorsPolicies();
app.MapCarter();
app.Run();
Note that the Program class remains simple and clean using Carter. Another important point to highlight is that we have no coupling to the Program class. Instead, we pass all responsibility for the modules to Carter through dependency injection (DI), implemented by the app.MapCarter(); method.
Conclusion
Minimal APIs streamline development by offering a simple approach to creating compact endpoints. However, as the application grows, it’s common for the Program class to become extensive and disorganized if project structure isn’t carefully considered.
To minimize the chances of having an inflated Program class, we can adopt some organizational strategies. In this post, we saw an approach that clearly separates the responsibilities of each class and, to shorten the process, we used the Carter library, leaving the Program class responsible only for the configurations that truly belong to it.
I hope this post helps you create more organized APIs that are ready to evolve!