Building modern .NET APIs with FastEndpoints and the Repr pattern
Simplifying API Design with the Repr Pattern and FastEndpoints
• .NET, FastEndpoints, API Design, Repr Pattern, Minimal APIs, ASP.NET Core, Code Night, New Zealand
• 15 min read
Welcome to this comprehensive session from Code Night New Zealand! Tonight we’re diving deep into simplifying API designs with the Repr pattern and FastEndpoints. This session is packed with practical demos showing how to leverage the FastEndpoints library to build clean, performant, and maintainable APIs.
Understanding the Repr Pattern
The Repr pattern defines web API endpoints with three distinct components:
Request - The input data structure
Endpoint - The processing logic
Response - The output data structure
This pattern provides a straightforward way of implementing API endpoints, different from traditional controllers where you often have bloated classes with multiple concerns wrapped into a single layer.
Benefits of the Reaper Pattern
✅ Single Responsibility: Each endpoint handles one specific operation ✅ Fine-Grained Control: Precise, cohesive functionality ✅ Better Testing: More specific unit and integration tests ✅ Reduced Bloat: No more controllers that grow uncontrollably over time ✅ Clear Architecture: Explicit separation of concerns
The Repr pattern aligns perfectly with SOLID principles, particularly the Single Responsibility Principle.
FastEndpoints: Minimal APIs Made Easy
FastEndpoints goes beyond just creating endpoints - it’s a comprehensive library with a wide range of features that make you productive. It’s essentially minimal APIs on wheels with performance very close to minimal APIs but with robust tooling and conventions.
Performance Comparison
FastEndpoints delivers performance very similar to minimal APIs (microseconds of difference) while providing a much more robust development experience than traditional controllers.
Getting Started with FastEndpoints
1. Basic Setup
Let’s start by creating a new web application and adding FastEndpoints:
1
2
3
4
5
# Create new web API projectdotnet new webapi -n FastEndpointsDemo
# Add FastEndpoints packagedotnet add package FastEndpoints
2. Configure Services
1
2
3
4
5
6
7
8
9
10
11
12
// Program.csvarbuilder=WebApplication.CreateBuilder(args);// Add FastEndpointsbuilder.Services.AddFastEndpoints();varapp=builder.Build();// Use FastEndpoints (replace default endpoint mapping)app.UseFastEndpoints();app.Run();
publicclassGetUserEndpoint:Endpoint<UserIdRequest,Results<Ok<UserResponse>,NotFound,ProblemDetails>>{publicoverridevoidConfigure(){Get("/users/{userId}");AllowAnonymous();}publicoverrideasyncTask<Results<Ok<UserResponse>,NotFound,ProblemDetails>>ExecuteAsync(UserIdRequestreq,CancellationTokenct){if(req.UserId<=0){AddError("User ID must be greater than zero");returnnewProblemDetails();}if(req.UserId==0)returnTypedResults.NotFound();varuser=newUserResponse{FullName="John Smith",IsOver18=true};returnTypedResults.Ok(user);}}
Example 3: Swagger Integration
1
2
3
4
5
6
7
8
9
// Add Swagger package// dotnet add package FastEndpoints.Swagger// Program.csbuilder.Services.AddFastEndpoints().AddSwaggerDoc();app.UseFastEndpoints().UseSwaggerGen();// only in development
publicclassGetUserEndpoint:Endpoint<UserIdRequest,UserResponse>{publicoverridevoidConfigure(){Get("/users/{userId}");AllowAnonymous();// Enhanced documentationSummary(s=>{s.Summary="Get user by ID";s.Description="This endpoint retrieves a user by their unique ID";s.Produces<UserResponse>(200,"application/json");s.Produces<ProblemDetails>(404,"application/json");s.Produces<ProblemDetails>(500,"application/json");});// Override tag groupingTags("Users");}}// Custom summary class for better documentationpublicclassGetUserSummary:Summary<GetUserEndpoint>{publicGetUserSummary(){Summary="Get user by ID";Description="Retrieves a user by their unique identifier";Response<UserResponse>(200,"User found successfully");Response<ProblemDetails>(404,"User not found");Response<ProblemDetails>(500,"Internal server error");}}
Example 5: Swagger Client Generation with Kiota
1
2
3
4
5
6
7
8
// Add Kiota client generation package// dotnet add package FastEndpoints.ClientGen.Kiota// Program.cs - only for developmentif(app.Environment.IsDevelopment()){app.MapClientEndpoints();// Enables client download endpoints}
Generated Client Usage:
1
2
3
4
5
6
7
8
9
10
11
12
13
// After downloading and extracting the generated clientvarclient=newUsersClient(newAnonymousAuthenticationProvider());client.BaseUrl="https://localhost:5281";try{varuser=awaitclient.Users[1].GetAsync();Console.WriteLine($"User: {user.FullName}");}catch(Exceptionex){Console.WriteLine($"Error: {ex.Message}");}
Example 6: Fluent Validation Integration
FastEndpoints includes built-in FluentValidation support:
// Request with validatorpublicclassUserRequest{publicstringFirstName{get;set;}=string.Empty;publicstringLastName{get;set;}=string.Empty;publicintAge{get;set;}}publicclassUserRequestValidator:Validator<UserRequest>{publicUserRequestValidator(){RuleFor(x=>x.FirstName).NotEmpty().MinimumLength(2).WithMessage("First name is too short");RuleFor(x=>x.LastName).NotEmpty().MinimumLength(2).WithMessage("Last name is too short");RuleFor(x=>x.Age).GreaterThan(18).WithMessage("You must be over 18 years old to register");}}// Endpoint with validationpublicclassCreateUserEndpoint:Endpoint<UserRequest,UserResponse>{publicoverridevoidConfigure(){Post("/users");AllowAnonymous();}// HandleAsync automatically validates the requestpublicoverrideasyncTaskHandleAsync(UserRequestreq,CancellationTokenct){// Validation happens automatically - no additional code neededvarresponse=newUserResponse{FullName=$"{req.FirstName} {req.LastName}",IsOver18=req.Age>18};awaitSendOkAsync(response,ct);}}
Fine-Grained Validation Control:
1
2
3
4
5
6
7
8
9
10
11
publicoverrideasyncTaskExecuteAsync(UserIdRequestreq,CancellationTokenct){// Custom validation with ExecuteAsyncif(req.UserId<=0){AddError("User ID must be greater than zero");ThrowIfAnyErrors();// Manual error throwing}// Continue with processing...}
// Domain modelpublicclassUser{publicintId{get;set;}publicstringFullName{get;set;}=string.Empty;publicintAge{get;set;}}// Custom mapperpublicclassUserMapper:Mapper<UserRequest,UserResponse,User>{// Request to Entity mappingpublicoverrideUserToEntity(UserRequestr){returnnewUser{FullName=$"{r.FirstName} {r.LastName}",Age=r.Age};}// Entity to Response mappingpublicoverrideUserResponseFromEntity(Usere){returnnewUserResponse{FullName=e.FullName,IsOver18=e.Age>18};}}// Endpoint using mapperpublicclassCreateUserEndpoint:Endpoint<UserRequest,UserResponse,UserMapper>{publicoverridevoidConfigure(){Post("/users");AllowAnonymous();}publicoverrideasyncTaskHandleAsync(UserRequestreq,CancellationTokenct){// Convert request to entityvaruser=Map.ToEntity(req);// Process business logic here...// Convert entity to responsevarresponse=Map.FromEntity(user);awaitSendOkAsync(response,ct);}}
Example 8: Processors (Middleware-like Functionality)
// Pre-processor statepublicclassProcessorState{publicboolIsValidAge{get;set;}publiclongDurationInMs{get;set;}publicStopwatchTimer{get;set;}=new();}// Global pre-processor (runs before all endpoints)publicclassGlobalTenantChecker:IGlobalPreProcessor<ProcessorState>{publicasyncTaskPreProcessAsync(IPreProcessorContext<ProcessorState>ctx,ProcessorStatestate,CancellationTokenct){vartenantId=ctx.HttpContext.Request.Headers["X-Tenant-ID"].FirstOrDefault();if(string.IsNullOrEmpty(tenantId)){ctx.HttpContext.Response.StatusCode=400;awaitctx.HttpContext.Response.WriteAsync("X-Tenant-ID header is required");return;}}}// Endpoint-specific pre-processorpublicclassAgeChecker:IPreProcessor<UserRequest,ProcessorState>{publicasyncTaskPreProcessAsync(IPreProcessorContext<UserRequest>ctx,UserRequestreq,ProcessorStatestate,CancellationTokenct){state.Timer.Start();state.IsValidAge=req.Age>18;Console.WriteLine($"Age validation: {state.IsValidAge}");}}// Post-processorpublicclassDurationLogger:IPostProcessor<UserRequest,UserResponse,ProcessorState>{publicasyncTaskPostProcessAsync(IPostProcessorContext<UserRequest,UserResponse>ctx,UserRequestreq,UserResponseresp,ProcessorStatestate,CancellationTokenct){state.Timer.Stop();state.DurationInMs=state.Timer.ElapsedMilliseconds;Console.WriteLine($"Request processed in: {state.DurationInMs}ms");}}// Endpoint with processorspublicclassCreateUserEndpoint:Endpoint<UserRequest,UserResponse,ProcessorState>{publicoverridevoidConfigure(){Post("/users");AllowAnonymous();PreProcessor<AgeChecker>();PostProcessor<DurationLogger>();}publicoverrideasyncTaskHandleAsync(UserRequestreq,CancellationTokenct){// Access processor stateConsole.WriteLine($"Is valid age from processor: {ProcessorState.IsValidAge}");varresponse=newUserResponse{FullName=$"{req.FirstName} {req.LastName}",IsOver18=ProcessorState.IsValidAge};awaitSendOkAsync(response,ct);}}
// Command definitionpublicclassGetFullNameCommand:ICommand<string>{publicstringFirstName{get;set;}=string.Empty;publicstringLastName{get;set;}=string.Empty;}// Command handlerpublicclassGetFullNameHandler:ICommandHandler<GetFullNameCommand,string>{publicasyncTask<string>ExecuteAsync(GetFullNameCommandcmd,CancellationTokenct){awaitTask.Delay(50,ct);// Simulate async workreturn$"{cmd.FirstName} {cmd.LastName}";}}// Command middleware (Chain of Responsibility)publicclassCommandLogger<TCommand,TResult>:ICommandBehavior<TCommand,TResult>whereTCommand:ICommand<TResult>{publicasyncTask<TResult>HandleAsync(TCommandcmd,CommandHandlerDelegate<TResult>next,CancellationTokenct){Console.WriteLine($"Executing command: {typeof(TCommand).Name}");varresult=awaitnext();Console.WriteLine($"Command executed: {typeof(TCommand).Name}");returnresult;}}publicclassResultLogger<TCommand,TResult>:ICommandBehavior<TCommand,TResult>whereTCommand:ICommand<TResult>{publicasyncTask<TResult>HandleAsync(TCommandcmd,CommandHandlerDelegate<TResult>next,CancellationTokenct){varresult=awaitnext();Console.WriteLine($"Command result: {result}");returnresult;}}// Register middleware in Program.csbuilder.Services.AddFastEndpoints().AddCommandBus(typeof(CommandLogger<,>),typeof(ResultLogger<,>));// Endpoint using commandspublicclassCreateUserEndpoint:Endpoint<UserRequest,UserResponse>{publicoverridevoidConfigure(){Post("/users");AllowAnonymous();}publicoverrideasyncTaskHandleAsync(UserRequestreq,CancellationTokenct){// Execute command through the busvarfullName=awaitExecuteAsync(newGetFullNameCommand{FirstName=req.FirstName,LastName=req.LastName},ct);varresponse=newUserResponse{FullName=fullName,IsOver18=req.Age>18};awaitSendOkAsync(response,ct);}}
FastEndpoints vs MediatR
Common Question: How does FastEndpoints compare to MediatR?
Key Differences:
Overlapping Features:
✅ Both provide middleware/pipeline functionality
✅ Both support request/response patterns
✅ Both enable clean architecture implementations
FastEndpoints Advantages:
🚀 Performance: Close to minimal API performance
🎯 API-First Design: Built specifically for web APIs
📝 Built-in Validation: FluentValidation integration out of the box
📚 Swagger Integration: Comprehensive OpenAPI support
🔧 Client Generation: Built-in Kiota support
MediatR Advantages:
🏗️ Architecture Agnostic: Works beyond just web APIs
📦 Ecosystem Maturity: Larger community and ecosystem
🔄 Flexibility: More generic request/response handling
Recommendation:
Choose FastEndpoints for new API-focused projects where you want performance and productivity
Choose MediatR for complex domain-driven designs or when you need maximum architectural flexibility
Avoid combining both to prevent over-engineering - they serve similar purposes with different focuses
Advanced Configuration and Best Practices
1. Global Configuration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Program.cs - Advanced configurationbuilder.Services.AddFastEndpoints(options=>{options.IncludeAbstractValidators=true;options.SourceGeneratorDiscoveredTypes=[typeof(Program)];}).AddSwaggerDoc(settings=>{settings.Title="My API";settings.Version="v1";settings.DocumentSettings=s=>{s.DocumentName="Initial Release";s.Description="This is the initial version of my API";s.Version="v1.0";};});
2. Security and Authentication
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
publicclassSecureEndpoint:Endpoint<SecureRequest,SecureResponse>{publicoverridevoidConfigure(){Post("/secure-endpoint");// Require authenticationAuthSchemes(AuthSchemes.JwtBearer);// Require specific permissionsPermissions("users:read","users:write");// Require specific rolesRoles("Admin","Manager");// Custom policiesPolicies("MinimumAge");}}
// Integration test example[Test]publicasyncTaskCreateUser_ValidRequest_ReturnsOk(){// Arrangevarrequest=newUserRequest{FirstName="John",LastName="Smith",Age=25};// Actvar(response,result)=awaitApp.Client.POSTAsync<CreateUserEndpoint,UserRequest,UserResponse>(request);// Assertresponse.Should().BeSuccessful();result.FullName.Should().Be("John Smith");result.IsOver18.Should().BeTrue();}// Unit test example[Test]publicasyncTaskCreateUser_UnderageUser_ReturnsValidationError(){// Arrangevarendpoint=Factory.Create<CreateUserEndpoint>();varrequest=newUserRequest{FirstName="Jane",LastName="Doe",Age=16// Under 18};// Act & Assertawaitendpoint.Invoking(e=>e.HandleAsync(request,CancellationToken.None)).Should().ThrowAsync<ValidationFailureException>();}
Performance Considerations
Benchmark Results
FastEndpoints performs very similarly to minimal APIs:
// 1. Use ExecuteAsync for fine-grained controlpublicoverrideasyncTaskExecuteAsync(UserRequestreq,CancellationTokenct){// Skip validation if not neededDontValidate();// Custom processing...}// 2. Configure validation strategicallypublicclassOptimizedValidator:Validator<UserRequest>{publicOptimizedValidator(){// Only validate critical fields in high-performance scenariosRuleFor(x=>x.Age).GreaterThan(0).When(x=>x.Age!=default);// Conditional validation}}// 3. Use processors for cross-cutting concernspublicclassCachingProcessor:IPreProcessor<UserRequest>{publicasyncTaskPreProcessAsync(IPreProcessorContext<UserRequest>ctx,CancellationTokenct){// Check cache before expensive operationsvarcached=awaitGetFromCache(ctx.Request);if(cached!=null){awaitctx.HttpContext.Response.WriteAsJsonAsync(cached,ct);return;// Short-circuit the pipeline}}}
Troubleshooting Common Issues
1. Assembly Loading Problems
1
2
3
4
5
6
7
// If FastEndpoints can't find your endpoints automaticallybuilder.Services.AddFastEndpoints(options=>{options.Assemblies=[typeof(Program).Assembly];});// Avoid naming your project with "FastEndpoints" - it's in the excluded keywords list
2. Validation Not Working
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Ensure validator is properly associatedpublicclassUserRequestValidator:Validator<UserRequest>{publicUserRequestValidator(){// Validator rules here}}// For ExecuteAsync, manually validate if neededpublicoverrideasyncTaskExecuteAsync(UserRequestreq,CancellationTokenct){awaitValidateAsync(req,ct);ThrowIfAnyErrors();}
3. Swagger Documentation Issues
1
2
3
4
5
6
7
8
9
10
11
// Ensure proper summary configurationSummary(s=>{s.Summary="Create User";s.Description="Creates a new user in the system";s.ExampleRequest=newUserRequest{FirstName="John",LastName="Doe",Age=25};});
When to Use FastEndpoints
✅ Ideal Scenarios:
Building new .NET web APIs
Performance-critical applications
Teams wanting structured endpoint organization
Projects requiring comprehensive API documentation
Applications needing client code generation
Microservices architectures
❌ Consider Alternatives When:
Working with legacy systems heavily invested in controllers
Team has limited time for learning new patterns
Need maximum architectural flexibility (consider MediatR)
Building non-API applications (MVC views, Blazor, etc.)
Conclusion
FastEndpoints represents a significant evolution in .NET API development, offering:
🚀 Performance: Near minimal API speeds with better organization 🏗️ Structure: Clean implementation of the Reaper pattern 📚 Productivity: Built-in validation, documentation, and client generation 🔧 Flexibility: Extensive middleware and processing capabilities 🎯 Focus: Purpose-built for modern API development
FastEndpoints is a comprehensive library that extends minimal APIs with enterprise-grade features while maintaining their performance characteristics. It’s an excellent choice for teams looking to modernize their API development approach without sacrificing productivity or performance.
Key Takeaways:
Start Simple: Begin with basic endpoints and gradually add features
Leverage Built-ins: Use integrated validation, mapping, and documentation
Think Repr: Embrace the request-endpoint-response pattern
Test Thoroughly: Take advantage of FastEndpoints’ testing utilities
Document Everything: Use the comprehensive Swagger integration
Whether you’re building microservices, public APIs, or internal services, FastEndpoints provides a compelling alternative to traditional approaches while maintaining the performance benefits of minimal APIs.
Join the conversation! Share your thoughts and connect with other readers.