Skip to main content

Honest and Robust Endpoints in .NET 10 with Minimal APIs

2026-01-2512 min
Clean Code.NETDDDMinimal APIsArquitectura

Over these past weeks I've been exploring .NET 10, with the goal of applying everything I've learned about clean architecture and DDD in a different ecosystem. I've been admiring for some time how the .NET community has evolved toward more expressive and minimalist architectures, especially with the advent of Minimal APIs.

To put it into practice, I decided to build a corporate expense reimbursement API, initially focused on the "Submit Expense" vertical slice. I wanted to implement a clean architecture following DDD principles, and discover how .NET 10 facilitates these kinds of implementations.

My Structure: Vertical Slice Architecture

I decided to organize the project following Vertical Slice Architecture, where each feature is self-contained:

plain
Features/Expenses/
├── Domain/
│   ├── Entities/
│   │   └── Expense.cs
│   └── Repositories/
│       └── IExpenseRepository.cs
├── Application/
│   └── UseCases/
│       └── SubmitExpenseUseCase.cs
└── Infrastructure/
    ├── Adapters/
    │   └── InMemoryExpenseAdapter.cs
    ├── Api/
    │   └── Endpoints/
    │       └── SubmitExpenseEndpoint.cs
    └── ExpenseModule.cs

The interesting thing about this structure is that each feature encapsulates all its functionality: from domain to infrastructure. This makes the code more cohesive and easier to maintain.

Modularity with Extension Methods

One of the features I love most about .NET is how it facilitates modularity. The ExpenseModule.cs file encapsulates all the dependency injection configuration very concisely:

c#
public static class ExpenseModule
{
    public static void AddExpensesModule(this IServiceCollection services)
    {
        services.AddTransient<SubmitExpenseUseCase>();
        services.AddTransient<IExpenseRepository, InMemoryExpenseAdapter>();
    }
}

And in Program.cs, registration is as simple as:

c#
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddExpensesModule(); // ✨ One single method

var app = builder.Build();

var expensesGroup = app.MapGroup("/api/expenses").WithTags("Expenses");
expensesGroup.MapSubmitExpenseEndpoint();

app.Run();

This ability to create extension methods makes configuration declarative and easy to read. Each module handles its own configuration, keeping Program.cs clean and focused.

A Pure Domain: The Star of the Show

One of the principles I value most in DDD is keeping the domain free from infrastructure details. In .NET this is achieved very naturally:

c#
public class Expense
{
    public Guid Id { get; private init; }
    private ExpenseState state { get; init; }
    private Guid receiptId { get; init; }

    public ErrorOr<Expense> Submit()
    {
        if(state is not ExpenseState.Draft || receiptId == Guid.Empty)
        {
            return Error.Validation("Expense.InvalidState", 
                "The expense is not in a valid state to be submitted.");
        }
        return ChangeTo(ExpenseState.PendingApproval);
    }
    
    public static Expense Draft(Guid seedExpenseId, Guid receiptId)
    {
        return new Expense
        {
            Id = seedExpenseId,
            state = ExpenseState.Draft,
            receiptId = receiptId
        };
    }
    
    private Expense ChangeTo(ExpenseState newState)
    {
        return new Expense
        {
            Id = Id,
            state = newState,
            receiptId = receiptId
        };
    }
}

Zero references to Entity Framework. The entity knows nothing about persistence, and that's perfect.

The entity:

  • ✅ Validates its own invariants (receipt required to submit)

  • ✅ Controls its state transitions

  • ✅ Encapsulates domain behavior

  • ✅ Is completely testable without mocks

How does it map to the database? That's infrastructure's responsibility, using Fluent API from EF Core in separate configuration files. The domain remains pure.

Minimal APIs: Expressive and Direct Endpoints

This is where Minimal APIs really shines. Endpoint definition is declarative and functional:

c#
public static void MapSubmitExpenseEndpoint(this IEndpointRouteBuilder app)
{
    app.MapPost("{id:guid}/submit", HandleAsync)
        .WithName("SubmitExpense")
        .WithSummary("Submits an expense for approval")
        .Produces(StatusCodes.Status200OK)
        .Produces(StatusCodes.Status404NotFound)
        .Produces(StatusCodes.Status400BadRequest);
}

private static async Task<IResult> HandleAsync(
    Guid id, 
    [FromServices] SubmitExpenseUseCase useCase)
{
    var result = await useCase.Execute(id);

    return result.Match(
        _ => Results.Ok(),
        errors => Results.Problem(
            detail: string.Join(", ", from error in errors select error.Description),
            statusCode: errors.First().Type switch
            {
                ErrorType.NotFound => StatusCodes.Status404NotFound,
                ErrorType.Validation => StatusCodes.Status400BadRequest,
                _ => StatusCodes.Status500InternalServerError
            }
        )
    );
}

I especially like:

  • Extension methods: Each endpoint extends IEndpointRouteBuilder, making it composable

  • Direct injection: Dependencies are injected into the method with [FromServices], no constructors needed

  • Fluent configuration: .WithName(), .WithSummary(), .Produces() make it self-documenting

  • No controller classes: Just pure functions mapping requests to responses

Functional Error Handling with ErrorOr

One of the libraries I've liked most is ErrorOr, which brings functional programming concepts to error handling:

c#
public async Task<ErrorOr<Success>> Execute(Guid expenseId)
{
    var expense = await expenseRepository.GetById(expenseId);
    
    if (expense.IsEmpty()) 
        return Error.NotFound("Expense.NotFound", "The expense does not exist.");

    var submitResult = expense.Submit();

    if (submitResult.IsError)
        return submitResult.Errors;

    await expenseRepository.Save(submitResult.Value);
    return Result.Success;
}

No try-catch for control flow. Everything is explicit and typed:

  • The return type ErrorOr<Success> clearly communicates that this operation can fail

  • Errors propagate functionally without throwing exceptions

  • The .Match() method in the endpoint maps errors to HTTP codes declaratively

This makes code:

  • More predictable: No hidden exceptions

  • More testable: No need to mock exceptions

  • More expressive: The method signature documents possible failures

Repository Pattern Without Ceremony

The Repository pattern implementation is straightforward and without boilerplate:

c#
// Interface in the domain
public interface IExpenseRepository
{
    Task<Expense> GetById(Guid expenseId);
    Task Save(Expense expense);
}

// Implementation in infrastructure
public class InMemoryExpenseAdapter : IExpenseRepository
{
    private static readonly Guid SeedExpenseId = 
        Guid.Parse("a52397ad-eb8c-4090-99b1-55cf768530f8");
    
    private readonly List<Expense> expenses = 
    [
        Expense.Draft(SeedExpenseId, Guid.NewGuid()),
    ];
    
    public Task<Expense> GetById(Guid expenseId)
    {
        return Task.FromResult(
            expenses.FirstOrDefault(e => e.Id == expenseId) ?? Expense.NotFound()
        );
    }

    public Task Save(Expense expense)
    {
        var index = expenses.FindIndex(e => e.Id == expense.Id);
        if (index >= 0)
            expenses[index] = expense;
        else
            expenses.Add(expense);
    
        return Task.CompletedTask;
    }
}

The Repository pattern works as a port between domain and infrastructure:

  • The domain defines what it needs (IExpenseRepository)

  • Infrastructure implements how to do it (InMemoryExpenseAdapter)

  • Dependency injection connects them

Testing: First-Class Citizen

.NET has exceptional support for testing. Integration tests are especially elegant:

c#
public class SubmitExpenseEndpointShould : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient client;

    public SubmitExpenseEndpointShould(WebApplicationFactory<Program> factory)
    {
        client = factory.CreateClient();
    }

    [Fact]
    public async Task return_ok_when_submitting_valid_expense()
    {
        var response = await client.PostAsync(
            $"/api/expenses/{expenseId}/submit", 
            null
        );
        
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }
}

Just expose Program as partial:

c#
var app = builder.Build();
app.Run();

public partial class Program { } // ✨ Accessible for tests

And you automatically have access to all the HTTP infrastructure for end-to-end tests.

For domain unit tests, the purity of the entities makes it trivial:

c#
[Fact]
public void return_error_when_submitting_draft_without_receipt()
{
    var expense = Expense.Draft(Guid.NewGuid(), Guid.Empty);

    var result = expense.Submit();

    Assert.True(result.IsError);
    Assert.Equal("Expense.InvalidState", result.FirstError.Code);
}

No infrastructure, no mocks, no configuration. Just instantiate your entity and verify behavior.

What Has Surprised Me

After implementing this vertical slice, a few things caught my attention:

1. Immutability First-Class

Records and init accessors make working with immutability feel natural:

c#
public Guid Id { get; private init; } // Can only be set in construction

2. Powerful Pattern Matching

Switch expressions are very expressive:

c#
statusCode: errors.First().Type switch
{
    ErrorType.NotFound => StatusCodes.Status404NotFound,
    ErrorType.Validation => StatusCodes.Status400BadRequest,
    _ => StatusCodes.Status500InternalServerError
}

3. Consistent Async/Await

The entire stack is naturally asynchronous, from endpoints to repositories.

4. Less Boilerplate

Compared to other frameworks, .NET allows expressing complex architectures with surprisingly concise code.

What's Coming

This is just the first vertical slice of the application. In upcoming articles I'll explore:

  • ✨ Real persistence with EF Core and Fluent API

  • ✨ Value Object implementation (Money, Receipt)

  • ✨ Domain Events for auditing

  • ✨ Command validation with FluentValidation

  • ✨ More verticals: Approve/Reject, Attach Receipt

Conclusion

Minimal APIs in .NET 10 combines power with simplicity. You get:

  • ✅ Clean architecture without ceremony

  • ✅ Pure domain without contamination

  • ✅ Minimalist but robust endpoints

  • ✅ Explicit dependency injection

  • ✅ First-class testing

  • ✅ Functional error handling

The complete code is in my repository if you want to explore it in depth.

If you're interested in seeing how to implement a similar architecture in Spring Boot, I recommend my article on hexagonal architecture in Spring, where I explain the process step by step with Java and Spring.


Sources