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:
Features/Expenses/
├── Domain/
│ ├── Entities/
│ │ └── Expense.cs
│ └── Repositories/
│ └── IExpenseRepository.cs
├── Application/
│ └── UseCases/
│ └── SubmitExpenseUseCase.cs
└── Infrastructure/
├── Adapters/
│ └── InMemoryExpenseAdapter.cs
├── Api/
│ └── Endpoints/
│ └── SubmitExpenseEndpoint.cs
└── ExpenseModule.csThe 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:
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:
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:
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:
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:
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:
// 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:
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:
var app = builder.Build();
app.Run();
public partial class Program { } // ✨ Accessible for testsAnd 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:
[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:
public Guid Id { get; private init; } // Can only be set in construction2. Powerful Pattern Matching
Switch expressions are very expressive:
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.