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.
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 project
dotnet new webapi -n FastEndpointsDemo
# Add FastEndpoints package
dotnet add package FastEndpoints
|
1
2
3
4
5
6
7
8
9
10
11
12
| // Program.cs
var builder = WebApplication.CreateBuilder(args);
// Add FastEndpoints
builder.Services.AddFastEndpoints();
var app = builder.Build();
// Use FastEndpoints (replace default endpoint mapping)
app.UseFastEndpoints();
app.Run();
|
3. Create Your First Endpoint
Request Model:
1
2
3
4
5
6
7
| // Models/MyRequest.cs
public class MyRequest
{
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public int Age { get; set; }
}
|
Response Model:
1
2
3
4
5
6
| // Models/MyResponse.cs
public class MyResponse
{
public string FullName { get; set; } = string.Empty;
public bool IsOver18 { get; set; }
}
|
Endpoint Implementation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // MyEndpoint.cs
public class MyEndpoint : Endpoint<MyRequest, MyResponse>
{
public override void Configure()
{
Post("/my-endpoint");
AllowAnonymous();
}
public override async Task HandleAsync(MyRequest req, CancellationToken ct)
{
var response = new MyResponse
{
FullName = $"{req.FirstName} {req.LastName}",
IsOver18 = req.Age > 18
};
await SendOkAsync(response, ct);
}
}
|
10 Practical FastEndpoints Examples
Example 1: Basic Implementation
The foundation example showing request, response, and endpoint structure with simple business logic.
Example 2: Typed Results
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| public class GetUserEndpoint : Endpoint<UserIdRequest, Results<Ok<UserResponse>, NotFound, ProblemDetails>>
{
public override void Configure()
{
Get("/users/{userId}");
AllowAnonymous();
}
public override async Task<Results<Ok<UserResponse>, NotFound, ProblemDetails>> ExecuteAsync(
UserIdRequest req, CancellationToken ct)
{
if (req.UserId <= 0)
{
AddError("User ID must be greater than zero");
return new ProblemDetails();
}
if (req.UserId == 0)
return TypedResults.NotFound();
var user = new UserResponse
{
FullName = "John Smith",
IsOver18 = true
};
return TypedResults.Ok(user);
}
}
|
Example 3: Swagger Integration
1
2
3
4
5
6
7
8
9
| // Add Swagger package
// dotnet add package FastEndpoints.Swagger
// Program.cs
builder.Services.AddFastEndpoints()
.AddSwaggerDoc();
app.UseFastEndpoints()
.UseSwaggerGen(); // only in development
|
Example 4: Enhanced Swagger Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| public class GetUserEndpoint : Endpoint<UserIdRequest, UserResponse>
{
public override void Configure()
{
Get("/users/{userId}");
AllowAnonymous();
// Enhanced documentation
Summary(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 grouping
Tags("Users");
}
}
// Custom summary class for better documentation
public class GetUserSummary : Summary<GetUserEndpoint>
{
public GetUserSummary()
{
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 development
if (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 client
var client = new UsersClient(new AnonymousAuthenticationProvider());
client.BaseUrl = "https://localhost:5281";
try
{
var user = await client.Users[1].GetAsync();
Console.WriteLine($"User: {user.FullName}");
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
|
Example 6: Fluent Validation Integration
FastEndpoints includes built-in FluentValidation support:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
| // Request with validator
public class UserRequest
{
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public int Age { get; set; }
}
public class UserRequestValidator : Validator<UserRequest>
{
public UserRequestValidator()
{
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 validation
public class CreateUserEndpoint : Endpoint<UserRequest, UserResponse>
{
public override void Configure()
{
Post("/users");
AllowAnonymous();
}
// HandleAsync automatically validates the request
public override async Task HandleAsync(UserRequest req, CancellationToken ct)
{
// Validation happens automatically - no additional code needed
var response = new UserResponse
{
FullName = $"{req.FirstName} {req.LastName}",
IsOver18 = req.Age > 18
};
await SendOkAsync(response, ct);
}
}
|
Fine-Grained Validation Control:
1
2
3
4
5
6
7
8
9
10
11
| public override async Task ExecuteAsync(UserIdRequest req, CancellationToken ct)
{
// Custom validation with ExecuteAsync
if (req.UserId <= 0)
{
AddError("User ID must be greater than zero");
ThrowIfAnyErrors(); // Manual error throwing
}
// Continue with processing...
}
|
Example 7: Object Mapping
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
| // Domain model
public class User
{
public int Id { get; set; }
public string FullName { get; set; } = string.Empty;
public int Age { get; set; }
}
// Custom mapper
public class UserMapper : Mapper<UserRequest, UserResponse, User>
{
// Request to Entity mapping
public override User ToEntity(UserRequest r)
{
return new User
{
FullName = $"{r.FirstName} {r.LastName}",
Age = r.Age
};
}
// Entity to Response mapping
public override UserResponse FromEntity(User e)
{
return new UserResponse
{
FullName = e.FullName,
IsOver18 = e.Age > 18
};
}
}
// Endpoint using mapper
public class CreateUserEndpoint : Endpoint<UserRequest, UserResponse, UserMapper>
{
public override void Configure()
{
Post("/users");
AllowAnonymous();
}
public override async Task HandleAsync(UserRequest req, CancellationToken ct)
{
// Convert request to entity
var user = Map.ToEntity(req);
// Process business logic here...
// Convert entity to response
var response = Map.FromEntity(user);
await SendOkAsync(response, ct);
}
}
|
Example 8: Processors (Middleware-like Functionality)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
| // Pre-processor state
public class ProcessorState
{
public bool IsValidAge { get; set; }
public long DurationInMs { get; set; }
public Stopwatch Timer { get; set; } = new();
}
// Global pre-processor (runs before all endpoints)
public class GlobalTenantChecker : IGlobalPreProcessor<ProcessorState>
{
public async Task PreProcessAsync(IPreProcessorContext<ProcessorState> ctx,
ProcessorState state, CancellationToken ct)
{
var tenantId = ctx.HttpContext.Request.Headers["X-Tenant-ID"].FirstOrDefault();
if (string.IsNullOrEmpty(tenantId))
{
ctx.HttpContext.Response.StatusCode = 400;
await ctx.HttpContext.Response.WriteAsync("X-Tenant-ID header is required");
return;
}
}
}
// Endpoint-specific pre-processor
public class AgeChecker : IPreProcessor<UserRequest, ProcessorState>
{
public async Task PreProcessAsync(IPreProcessorContext<UserRequest> ctx,
UserRequest req, ProcessorState state, CancellationToken ct)
{
state.Timer.Start();
state.IsValidAge = req.Age > 18;
Console.WriteLine($"Age validation: {state.IsValidAge}");
}
}
// Post-processor
public class DurationLogger : IPostProcessor<UserRequest, UserResponse, ProcessorState>
{
public async Task PostProcessAsync(IPostProcessorContext<UserRequest, UserResponse> ctx,
UserRequest req, UserResponse resp, ProcessorState state, CancellationToken ct)
{
state.Timer.Stop();
state.DurationInMs = state.Timer.ElapsedMilliseconds;
Console.WriteLine($"Request processed in: {state.DurationInMs}ms");
}
}
// Endpoint with processors
public class CreateUserEndpoint : Endpoint<UserRequest, UserResponse, ProcessorState>
{
public override void Configure()
{
Post("/users");
AllowAnonymous();
PreProcessor<AgeChecker>();
PostProcessor<DurationLogger>();
}
public override async Task HandleAsync(UserRequest req, CancellationToken ct)
{
// Access processor state
Console.WriteLine($"Is valid age from processor: {ProcessorState.IsValidAge}");
var response = new UserResponse
{
FullName = $"{req.FirstName} {req.LastName}",
IsOver18 = ProcessorState.IsValidAge
};
await SendOkAsync(response, ct);
}
}
|
Example 9: Event Bus (Pub/Sub Pattern)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
| // Event definition
public class UserCreatedEvent
{
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public int Age { get; set; }
public DateTime DateCreated { get; set; } = DateTime.UtcNow;
}
// Event handler
public class UserCreatedHandler : IEventHandler<UserCreatedEvent>
{
public async Task HandleAsync(UserCreatedEvent eventModel, CancellationToken ct)
{
// Handle the event - send email, log, update cache, etc.
Console.WriteLine($"User created: {eventModel.FirstName} {eventModel.LastName} at {eventModel.DateCreated}");
// Simulate async work
await Task.Delay(100, ct);
}
}
// Endpoint publishing events
public class CreateUserEndpoint : Endpoint<UserRequest, UserResponse>
{
public override void Configure()
{
Post("/users");
AllowAnonymous();
}
public override async Task HandleAsync(UserRequest req, CancellationToken ct)
{
// Create response
var response = new UserResponse
{
FullName = $"{req.FirstName} {req.LastName}",
IsOver18 = req.Age > 18
};
// Publish event
await PublishAsync(new UserCreatedEvent
{
FirstName = req.FirstName,
LastName = req.LastName,
Age = req.Age
}, Mode.WaitForAll, ct); // Wait for all handlers to complete
await SendOkAsync(response, ct);
}
}
|
Example 10: Command Bus with Middleware
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
| // Command definition
public class GetFullNameCommand : ICommand<string>
{
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
}
// Command handler
public class GetFullNameHandler : ICommandHandler<GetFullNameCommand, string>
{
public async Task<string> ExecuteAsync(GetFullNameCommand cmd, CancellationToken ct)
{
await Task.Delay(50, ct); // Simulate async work
return $"{cmd.FirstName} {cmd.LastName}";
}
}
// Command middleware (Chain of Responsibility)
public class CommandLogger<TCommand, TResult> : ICommandBehavior<TCommand, TResult>
where TCommand : ICommand<TResult>
{
public async Task<TResult> HandleAsync(TCommand cmd, CommandHandlerDelegate<TResult> next, CancellationToken ct)
{
Console.WriteLine($"Executing command: {typeof(TCommand).Name}");
var result = await next();
Console.WriteLine($"Command executed: {typeof(TCommand).Name}");
return result;
}
}
public class ResultLogger<TCommand, TResult> : ICommandBehavior<TCommand, TResult>
where TCommand : ICommand<TResult>
{
public async Task<TResult> HandleAsync(TCommand cmd, CommandHandlerDelegate<TResult> next, CancellationToken ct)
{
var result = await next();
Console.WriteLine($"Command result: {result}");
return result;
}
}
// Register middleware in Program.cs
builder.Services.AddFastEndpoints()
.AddCommandBus(typeof(CommandLogger<,>), typeof(ResultLogger<,>));
// Endpoint using commands
public class CreateUserEndpoint : Endpoint<UserRequest, UserResponse>
{
public override void Configure()
{
Post("/users");
AllowAnonymous();
}
public override async Task HandleAsync(UserRequest req, CancellationToken ct)
{
// Execute command through the bus
var fullName = await ExecuteAsync(new GetFullNameCommand
{
FirstName = req.FirstName,
LastName = req.LastName
}, ct);
var response = new UserResponse
{
FullName = fullName,
IsOver18 = req.Age > 18
};
await SendOkAsync(response, ct);
}
}
|
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
- 🏗️ 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 configuration
builder.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
| public class SecureEndpoint : Endpoint<SecureRequest, SecureResponse>
{
public override void Configure()
{
Post("/secure-endpoint");
// Require authentication
AuthSchemes(AuthSchemes.JwtBearer);
// Require specific permissions
Permissions("users:read", "users:write");
// Require specific roles
Roles("Admin", "Manager");
// Custom policies
Policies("MinimumAge");
}
}
|
3. Versioning Support
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| public class V1UserEndpoint : Endpoint<UserRequest, UserResponse>
{
public override void Configure()
{
Post("/v1/users");
Version(1);
AllowAnonymous();
}
}
public class V2UserEndpoint : Endpoint<UserRequestV2, UserResponseV2>
{
public override void Configure()
{
Post("/v2/users");
Version(2);
AllowAnonymous();
}
}
|
4. Testing FastEndpoints
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| // Integration test example
[Test]
public async Task CreateUser_ValidRequest_ReturnsOk()
{
// Arrange
var request = new UserRequest
{
FirstName = "John",
LastName = "Smith",
Age = 25
};
// Act
var (response, result) = await App.Client.POSTAsync<CreateUserEndpoint, UserRequest, UserResponse>(request);
// Assert
response.Should().BeSuccessful();
result.FullName.Should().Be("John Smith");
result.IsOver18.Should().BeTrue();
}
// Unit test example
[Test]
public async Task CreateUser_UnderageUser_ReturnsValidationError()
{
// Arrange
var endpoint = Factory.Create<CreateUserEndpoint>();
var request = new UserRequest
{
FirstName = "Jane",
LastName = "Doe",
Age = 16 // Under 18
};
// Act & Assert
await endpoint
.Invoking(e => e.HandleAsync(request, CancellationToken.None))
.Should()
.ThrowAsync<ValidationFailureException>();
}
|
Benchmark Results
FastEndpoints performs very similarly to minimal APIs:
- Minimal APIs: ~50μs per request
- FastEndpoints: ~52μs per request
- Controllers: ~75μs per request
Optimization Tips
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| // 1. Use ExecuteAsync for fine-grained control
public override async Task ExecuteAsync(UserRequest req, CancellationToken ct)
{
// Skip validation if not needed
DontValidate();
// Custom processing...
}
// 2. Configure validation strategically
public class OptimizedValidator : Validator<UserRequest>
{
public OptimizedValidator()
{
// Only validate critical fields in high-performance scenarios
RuleFor(x => x.Age)
.GreaterThan(0)
.When(x => x.Age != default); // Conditional validation
}
}
// 3. Use processors for cross-cutting concerns
public class CachingProcessor : IPreProcessor<UserRequest>
{
public async Task PreProcessAsync(IPreProcessorContext<UserRequest> ctx, CancellationToken ct)
{
// Check cache before expensive operations
var cached = await GetFromCache(ctx.Request);
if (cached != null)
{
await ctx.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 automatically
builder.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 associated
public class UserRequestValidator : Validator<UserRequest>
{
public UserRequestValidator()
{
// Validator rules here
}
}
// For ExecuteAsync, manually validate if needed
public override async Task ExecuteAsync(UserRequest req, CancellationToken ct)
{
await ValidateAsync(req, ct);
ThrowIfAnyErrors();
}
|
3. Swagger Documentation Issues
1
2
3
4
5
6
7
8
9
10
11
| // Ensure proper summary configuration
Summary(s => {
s.Summary = "Create User";
s.Description = "Creates a new user in the system";
s.ExampleRequest = new UserRequest
{
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.
Resources
Have you tried FastEndpoints in your projects? Share your experiences and questions in the comments below!
Join the conversation! Share your thoughts and connect with other readers.