May 26 2025
🚀 Coming Soon: Enforcing Code Style
A brand-new course is launching soon inside The CodeMan Community!
Join now to lock in early access when it drops - plus get everything else already inside the group.
Founding Member Offer: • First 100 members get in for just $4/month - 80 spots already taken! • Or subscribe for 3 months ($12) or annually ($40) to unlock full access when the course goes live.
Get ahead of the game - and make clean, consistent code your superpower. Join here
Let’s be honest: writing APIs with controllers in .NET can feel... heavy.
Sure, Minimal APIs were a breath of fresh air when they landed. But once your app grows?
Your Program.cs turns into a giant spaghetti of MapGet, MapPost, and “where the heck is that route?” chaos.
That’s where Carter steps in.
Carter gives you a super clean, modular way to write Minimal APIs - while keeping everything fast, testable, and organized.
Let me walk you through how to build a complete Carter-based API with proper validation, services, and even a touch of Swagger.
Carter is basically Minimal APIs on steroids. It lets you organize routes by feature using small modular classes. Instead of one giant Program.cs, each feature lives in its own file - and Carter wires it all up for you. What you get: • Minimal APIs with clean structure • Built-in support for FluentValidation • Native dependency injection • Works perfectly with Swagger and unit tests
Here’s the setup we’ll use:
Each feature gets its own module, model, validator, and service. It’s simple and scales well.
dotnet add package Carterdotnet add package FluentValidationdotnet add package FluentValidation.DependencyInjectionExtensions
var builder = WebApplication.CreateBuilder(args); builder.Services.AddCarter();builder.Services.AddScoped<IUserService, UserService>();builder.Services.AddValidatorsFromAssemblyContaining<CreateUserValidator>(); var app = builder.Build(); app.MapCarter(); app.Run();
Create the User Model and the DTO for incoming data
public class User{ public int Id { get; set; } public string Name { get; set; } = string.Empty; public string Email { get; set; } = string.Empty;} public record CreateUserRequest(string Name, string Email);
Add Validation with FluentValidation
// Validators/CreateUserValidator.csusing FluentValidation; public class CreateUserValidator : AbstractValidator<CreateUserRequest>{ public CreateUserValidator() { RuleFor(x => x.Name).NotEmpty().WithMessage("Name is required"); RuleFor(x => x.Email).EmailAddress().WithMessage("Invalid email format"); }}
This keeps your logic out of the route definitions. Interface:
public interface IUserService{ IEnumerable<User> GetAllUsers(); User? GetUserById(int id); User CreateUser(CreateUserRequest request);}
Implementation:
public class UserService : IUserService{ private readonly List<User> _users = [ new User { Id = 1, Name = "Alice", Email = "alice@example.com" }, new User { Id = 2, Name = "Bob", Email = "bob@example.com" } ]; public List<User> GetAllUsers() { return _users; } public User? GetUserById(int id) { return _users.FirstOrDefault(u => u.Id == id); } public User CreateUser(CreateUserRequest request) { var user = new User { Id = _users.Max(u => u.Id) + 1, Name = request.Name, Email = request.Email }; _users.Add(user); return user; }}
Here’s where the routing magic happens.
public class UserModule : ICarterModule{ public void AddRoutes(IEndpointRouteBuilder app) { app.MapGet("/users", (IUserService userService) => { var users = userService.GetAllUsers(); return Results.Ok(users); }); app.MapGet("/users/{id:int}", (int id, IUserService userService) => { var user = userService.GetUserById(id); return user is null ? Results.NotFound("User not found") : Results.Ok(user); }); app.MapPost("/users", async ( HttpRequest req, IUserService userService, IValidator<CreateUserRequest> validator) => { var userRequest = await req.ReadFromJsonAsync<CreateUserRequest>(); if (userRequest is null) return Results.BadRequest("Invalid request payload"); ValidationResult validationResult = await validator.ValidateAsync(userRequest); if (!validationResult.IsValid) return Results.BadRequest(validationResult.Errors); var newUser = userService.CreateUser(userRequest); return Results.Created($"/users/{newUser.Id}", newUser); }); }}
In Carter, instead of writing all your route handlers in Program.cs, you define them inside classes that implement ICarterModule.
These classes represent a group of related routes - typically per feature (like “Users”).
The Carter framework will automatically discover and register all modules at startup (when you call app.MapCarter() in Program.cs).
MapGroup("/users") creates a route group — a way to group multiple endpoints under a common URL prefix (in this case /users). This makes your code: • Cleaner — You don’t repeat the /users prefix for every route. • Organized — Groups related endpoints logically. • More powerful — You can apply filters (auth, validation, etc.) to the whole group later.
var group = app.MapGroup("/users"); group.MapGet("/", (IUserService service) => Results.Ok(service.GetAllUsers())); group.MapGet("/{id:int}", (int id, IUserService service) =>{ var user = service.GetUserById(id); return user is null ? Results.NotFound() : Results.Ok(user);});
Once you've set up your Carter-based API and it's working like a charm, you might wonder:
"How do I make my routes more discoverable in Swagger? How do I group them? How can I prefix everything under /api without repeating myself?"
Carter has you covered with its advanced configuration capabilities - especially when using the CarterModule base class.
public UserModule() : base("/api") // All endpoints will be prefixed with /api{ WithTags("Users"); // Swagger/OpenAPI tag for this module IncludeInOpenApi(); // Automatically include all endpoints in Swagger}
Also check out Building Clean Minimal APIs with Carter.
As you've seen, Carter doesn't just make routing modular - it also makes it smartly configurable.
By inheriting from CarterModule, you can add route prefixes, tag your endpoints for better Swagger organization, and automatically include everything in your API docs without extra clutter.
It's a simple upgrade that brings a ton of polish to your API. Whether you're building a small internal tool or a large public-facing API, these small touches go a long way in keeping your code clean, your docs clear, and your life easier.
So the next time you’re setting up a new Carter module, remember: • Use base("/api") to avoid repeating yourself • Group routes with WithTags() • Let Swagger handle the docs with IncludeInOpenApi()
It's minimal effort for maximum structure.
Download code here (give it a start :) ).
That's all from me today.
P.S. Follow me on YouTube.
Stop arguing about code style. In this course you get a production-proven setup with analyzers, CI quality gates, and architecture tests — the exact system I use in real projects. Join here.
Not sure yet? Grab the free Starter Kit — a drop-in setup with the essentials from Module 01.
Design Patterns that Deliver — Solve real problems with 5 battle-tested patterns (Builder, Decorator, Strategy, Adapter, Mediator) using practical, real-world examples. Trusted by 650+ developers.
Just getting started? Design Patterns Simplified covers 10 essential patterns in a beginner-friendly, 30-page guide for just $9.95.
Every Monday morning, I share 1 actionable tip on C#, .NET & Architecture that you can use right away. Join here.
Join 20,000+ subscribers who mass-improve their .NET skills with actionable tips on C#, Architecture & Best Practices.
Subscribe to the TheCodeMan.net and be among the 20,000+ subscribers gaining practical tips and resources to enhance your .NET expertise.