A comprehensive ASP.NET Core response wrapper that transforms your APIs into enterprise-grade endpoints with zero code changes. Automatically wraps controller responses in a consistent format with rich metadata including execution timing, correlation IDs, request tracking, and comprehensive error handling. Features intelligent application status code extraction and promotion for complex workflow management, automatic pagination metadata separation using duck typing (works with ANY pagination library), database query statistics integration, and extensive configuration options. Includes built-in exception types for common scenarios (ValidationException, NotFoundException, BusinessException, etc.), customizable error messages for localization, global exception handling middleware, and smart exclusion capabilities for specific endpoints or result types. Perfect for microservices, complex business workflows, and APIs requiring consistent client-side error handling. Supports .NET 9.0+ with minimal performance overhead and extensive debugging capabilities.
$ dotnet add package FS.AspNetCore.ResponseWrapperAutomatic API response wrapping with metadata injection for ASP.NET Core applications.
FS.AspNetCore.ResponseWrapper provides a consistent, standardized response format for your ASP.NET Core APIs with zero boilerplate code. Transform your raw controller responses into rich, metadata-enhanced API responses that include execution timing, pagination details, correlation IDs, status codes, and comprehensive error handling.
Building robust APIs means handling consistent response formats, error management, timing information, status codes, and pagination metadata. Without a standardized approach, you end up with:
ResponseWrapper solves all these challenges by automatically wrapping your API responses with a consistent structure, comprehensive metadata, and intelligent error handling.
Transform any controller response into a standardized format without changing your existing code.
Built-in execution time tracking and database query statistics for performance optimization.
Automatic correlation ID generation and tracking for distributed systems debugging.
Intelligent status code extraction and promotion from response data, enabling complex workflow management and rich client-side conditional logic.
Automatic detection and clean separation of pagination metadata from business data using duck typing.
Global exception handling with customizable error messages and consistent error response format.
Extensive configuration options for customizing behavior, excluding specific endpoints, and controlling metadata generation.
Works with ANY pagination implementation - no need to change existing pagination interfaces.
Install the package via NuGet Package Manager:
dotnet add package FS.AspNetCore.ResponseWrapperOr via Package Manager Console:
Install-Package FS.AspNetCore.ResponseWrapperOr add directly to your .csproj file:
<PackageReference Include="FS.AspNetCore.ResponseWrapper" Version="9.1.0" />Getting started with ResponseWrapper is incredibly simple. Add it to your ASP.NET Core application in just two steps:
In your Program.cs file, add ResponseWrapper to your service collection:
using FS.AspNetCore.ResponseWrapper;
var builder = WebApplication.CreateBuilder(args);
// Add controllers
builder.Services.AddControllers();
// Add ResponseWrapper with default configuration
builder.Services.AddResponseWrapper();
var app = builder.Build();
// Configure the HTTP request pipeline
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();For comprehensive error handling, add the middleware:
var app = builder.Build();
// Add ResponseWrapper middleware for global exception handling
app.UseMiddleware<GlobalExceptionHandlingMiddleware>();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();That's it! Your API responses are now automatically wrapped. Let's see what this means in practice.
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
[HttpGet]
public async Task<List<User>> GetUsers()
{
return await _userService.GetUsersAsync();
}
}Raw Response:
[
{"id": 1, "name": "John Doe", "email": "john@example.com"},
{"id": 2, "name": "Jane Smith", "email": "jane@example.com"}
]Same Controller Code - No changes needed!
Enhanced Response:
{
"success": true,
"data": [
{"id": 1, "name": "John Doe", "email": "john@example.com"},
{"id": 2, "name": "Jane Smith", "email": "jane@example.com"}
],
"message": null,
"statusCode": null,
"errors": [],
"metadata": {
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2024-01-15T10:30:45.123Z",
"executionTimeMs": 42,
"version": "1.0",
"correlationId": "abc123",
"path": "/api/users",
"method": "GET",
"additional": {
"requestSizeBytes": 0,
"clientIP": "192.168.1.1"
}
}
}ResponseWrapper provides extensive configuration options to customize behavior according to your needs.
builder.Services.AddResponseWrapper(options =>
{
// Enable/disable execution time tracking
options.EnableExecutionTimeTracking = true;
// Enable/disable pagination metadata extraction
options.EnablePaginationMetadata = true;
// Enable/disable correlation ID tracking
options.EnableCorrelationId = true;
// Enable/disable database query statistics (requires EF interceptors)
options.EnableQueryStatistics = false;
// Control which responses to wrap
options.WrapSuccessResponses = true;
options.WrapErrorResponses = true;
// Exclude specific paths from wrapping
options.ExcludedPaths = new[] { "/health", "/metrics", "/swagger" };
// Exclude specific result types from wrapping
options.ExcludedTypes = new[] { typeof(FileResult), typeof(RedirectResult) };
});builder.Services.AddResponseWrapper(
options =>
{
options.EnableExecutionTimeTracking = true;
options.EnableQueryStatistics = true;
options.ExcludedPaths = new[] { "/health", "/metrics" };
},
errorMessages =>
{
errorMessages.ValidationErrorMessage = "Please check your input and try again";
errorMessages.NotFoundErrorMessage = "The requested item could not be found";
errorMessages.UnauthorizedAccessMessage = "Please log in to access this resource";
errorMessages.ForbiddenAccessMessage = "You don't have permission to access this resource";
errorMessages.BusinessRuleViolationMessage = "This operation violates business rules";
errorMessages.ApplicationErrorMessage = "We're experiencing technical difficulties";
errorMessages.UnexpectedErrorMessage = "An unexpected error occurred. Our team has been notified";
});builder.Services.AddResponseWrapper<CustomApiLogger>(
dateTimeProvider: () => DateTime.UtcNow, // Custom time provider for testing
configureOptions: options =>
{
options.EnableExecutionTimeTracking = true;
options.EnableQueryStatistics = true;
},
configureErrorMessages: errorMessages =>
{
errorMessages.ValidationErrorMessage = "Custom validation message";
errorMessages.NotFoundErrorMessage = "Custom not found message";
});ResponseWrapper provides comprehensive error handling that transforms exceptions into consistent API responses.
ResponseWrapper includes several exception types for common scenarios:
// For validation errors
throw new ValidationException(validationFailures);
// For missing resources
throw new NotFoundException("User", userId);
// For business rule violations
throw new BusinessException("Insufficient inventory for this order");
// For authorization failures
throw new ForbiddenAccessException("Access denied to this resource");Validation Error Response:
{
"success": false,
"data": null,
"message": "Please check your input and try again",
"statusCode": "VALIDATION_ERROR",
"errors": [
"Email is required",
"Password must be at least 8 characters"
],
"metadata": {
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2024-01-15T10:30:45.123Z",
"executionTimeMs": 15,
"path": "/api/users",
"method": "POST"
}
}Not Found Error Response:
{
"success": false,
"data": null,
"message": "The requested item could not be found",
"statusCode": "NOT_FOUND",
"errors": ["User (123) was not found."],
"metadata": {
"requestId": "550e8400-e29b-41d4-a716-446655440001",
"timestamp": "2024-01-15T10:32:15.456Z",
"executionTimeMs": 8,
"path": "/api/users/123",
"method": "GET"
}
}Customize error messages for different environments or languages:
// English messages
errorMessages.ValidationErrorMessage = "Please check your input and try again";
errorMessages.NotFoundErrorMessage = "The requested item could not be found";
// Turkish messages
errorMessages.ValidationErrorMessage = "Lütfen girdiğiniz bilgileri kontrol edin";
errorMessages.NotFoundErrorMessage = "Aradığınız öğe bulunamadı";
// Developer-friendly messages for development environment
if (environment.IsDevelopment())
{
errorMessages.ValidationErrorMessage = "Validation failed - check detailed errors";
errorMessages.ApplicationErrorMessage = "Application error - check logs for stack trace";
}One of ResponseWrapper's powerful features is its intelligent application status code handling, which enables complex workflow management beyond simple success/failure indicators.
HTTP status codes are great for transport-level communication, but modern applications often need richer status information:
{
"success": true,
"data": {"userId": 123, "email": "user@example.com"},
"statusCode": "EMAIL_VERIFICATION_REQUIRED",
"message": "Account created successfully. Please verify your email."
}This enables sophisticated client-side logic based on application state rather than just HTTP semantics.
ResponseWrapper automatically extracts status codes from your response data when they implement the IHasStatusCode interface:
// Your response DTO
public class UserRegistrationResult : IHasStatusCode
{
public int UserId { get; set; }
public string Email { get; set; }
public string StatusCode { get; set; } = "EMAIL_VERIFICATION_REQUIRED";
public string Message { get; set; } = "Please verify your email to complete registration";
}
// Your controller
[HttpPost("register")]
public async Task<UserRegistrationResult> RegisterUser(RegisterRequest request)
{
var result = await _userService.RegisterAsync(request);
return result; // StatusCode is automatically promoted to ApiResponse level
}Resulting Response:
{
"success": true,
"data": {
"userId": 123,
"email": "user@example.com"
},
"statusCode": "EMAIL_VERIFICATION_REQUIRED",
"message": "Please verify your email to complete registration",
"metadata": { ... }
}Notice how the status code and message are promoted to the top-level ApiResponse while the data remains clean and focused on business information.
// Authentication workflow
public class LoginResult : IHasStatusCode
{
public string Token { get; set; }
public UserProfile User { get; set; }
public string StatusCode { get; set; }
public string Message { get; set; }
}
// Different status codes for different scenarios
switch (authResult.Status)
{
case AuthStatus.Success:
return new LoginResult
{
Token = token,
User = user,
StatusCode = "LOGIN_SUCCESS",
Message = "Welcome back!"
};
case AuthStatus.RequiresTwoFactor:
return new LoginResult
{
StatusCode = "TWO_FACTOR_REQUIRED",
Message = "Please enter your authentication code"
};
case AuthStatus.PasswordExpired:
return new LoginResult
{
StatusCode = "PASSWORD_EXPIRED",
Message = "Your password has expired. Please update it."
};
}The status codes enable sophisticated client-side logic:
const response = await api.post('/auth/login', credentials);
if (response.success) {
switch (response.statusCode) {
case 'LOGIN_SUCCESS':
router.push('/dashboard');
break;
case 'TWO_FACTOR_REQUIRED':
showTwoFactorDialog();
break;
case 'PASSWORD_EXPIRED':
router.push('/change-password');
break;
case 'EMAIL_VERIFICATION_REQUIRED':
showEmailVerificationPrompt();
break;
}
} else {
handleErrors(response.errors);
}One of ResponseWrapper's most powerful features is its intelligent pagination handling using duck typing.
Most pagination libraries mix business data with pagination metadata:
{
"items": [...],
"page": 1,
"pageSize": 10,
"totalPages": 5,
"totalItems": 47
}This creates inconsistent API responses and makes client development more complex.
ResponseWrapper automatically detects pagination objects and separates business data from pagination metadata:
Clean Response with Separated Metadata:
{
"success": true,
"data": {
"items": [
{"id": 1, "name": "Product 1"},
{"id": 2, "name": "Product 2"}
]
},
"metadata": {
"pagination": {
"page": 1,
"pageSize": 10,
"totalPages": 5,
"totalItems": 47,
"hasNextPage": true,
"hasPreviousPage": false
},
"requestId": "...",
"executionTimeMs": 25
}
}ResponseWrapper uses duck typing, which means it works with ANY pagination implementation that has the required properties. You don't need to change your existing code!
Works with your existing pagination classes:
// Your existing pagination class - no changes needed!
public class MyCustomPagedResult<T>
{
public List<T> Items { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
public int TotalPages { get; set; }
public int TotalItems { get; set; }
public bool HasNextPage { get; set; }
public bool HasPreviousPage { get; set; }
}
// Your controller - no changes needed!
[HttpGet]
public async Task<MyCustomPagedResult<Product>> GetProducts()
{
return await _productService.GetPagedProductsAsync();
}Also works with third-party libraries:
// Works with EntityFramework extensions
public async Task<PagedList<User>> GetUsers()
{
return await context.Users.ToPagedListAsync(page, pageSize);
}
// Works with any library that follows the pagination pattern
public async Task<PaginatedResult<Order>> GetOrders()
{
return await _orderService.GetPaginatedOrdersAsync();
}ResponseWrapper automatically detects any object with these properties:
Items (List) - The business dataPage (int) - Current page numberPageSize (int) - Items per pageTotalPages (int) - Total number of pagesTotalItems (int) - Total number of itemsHasNextPage (bool) - Whether next page existsHasPreviousPage (bool) - Whether previous page exists[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
public ProductsController(IProductService productService)
{
_productService = productService;
}
// Simple product listing - automatically wrapped
[HttpGet]
public async Task<List<Product>> GetProducts()
{
return await _productService.GetActiveProductsAsync();
}
// Paginated products - pagination metadata automatically extracted
[HttpGet("paged")]
public async Task<PagedResult<Product>> GetPagedProducts(int page = 1, int pageSize = 10)
{
return await _productService.GetPagedProductsAsync(page, pageSize);
}
// Product creation with status codes - automatically wrapped with 201 status
[HttpPost]
public async Task<ProductCreationResult> CreateProduct(CreateProductRequest request)
{
// Validation happens automatically via ValidationException
if (!ModelState.IsValid)
{
var failures = ModelState.Values
.SelectMany(v => v.Errors)
.Select(e => new ValidationFailure("", e.ErrorMessage));
throw new ValidationException(failures);
}
return await _productService.CreateProductAsync(request);
}
// Product by ID - automatic 404 handling
[HttpGet("{id}")]
public async Task<Product> GetProduct(int id)
{
var product = await _productService.GetProductByIdAsync(id);
// This automatically becomes a 404 with proper error structure
if (product == null)
throw new NotFoundException("Product", id);
return product;
}
// File download - automatically excluded from wrapping
[HttpGet("{id}/image")]
public async Task<IActionResult> GetProductImage(int id)
{
var imageData = await _productService.GetProductImageAsync(id);
return new CustomExportResult(imageData, "product.jpg", "image/jpeg");
}
// Exclude specific endpoint from wrapping
[HttpGet("raw")]
[SkipApiResponseWrapper("Legacy endpoint for backward compatibility")]
public async Task<List<Product>> GetProductsRaw()
{
return await _productService.GetActiveProductsAsync();
}
}// Response DTO with status codes
public class UserActivationResult : IHasStatusCode
{
public User User { get; set; }
public string StatusCode { get; set; }
public string Message { get; set; }
}
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
public UsersController(IUserService userService)
{
_userService = userService;
}
[HttpPost("{id}/activate")]
public async Task<UserActivationResult> ActivateUser(int id)
{
var user = await _userService.GetUserByIdAsync(id);
if (user == null)
throw new NotFoundException("User", id);
// Business rule validation
if (user.IsActive)
{
return new UserActivationResult
{
User = user,
StatusCode = "ALREADY_ACTIVE",
Message = "User is already active"
};
}
if (user.IsSuspended)
{
return new UserActivationResult
{
StatusCode = "ACCOUNT_SUSPENDED",
Message = "Cannot activate suspended user. Please contact support."
};
}
var activatedUser = await _userService.ActivateUserAsync(id);
return new UserActivationResult
{
User = activatedUser,
StatusCode = "ACTIVATION_SUCCESS",
Message = "User activated successfully"
};
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteUser(int id)
{
// Authorization check
if (!User.IsInRole("Admin"))
throw new ForbiddenAccessException("Only administrators can delete users");
await _userService.DeleteUserAsync(id);
// Empty successful response
return Ok();
}
}public void ConfigureServices(IServiceCollection services)
{
services.AddResponseWrapper(
options =>
{
options.EnableExecutionTimeTracking = true;
options.EnablePaginationMetadata = true;
// Enable detailed query stats only in development
options.EnableQueryStatistics = Environment.IsDevelopment();
// Different excluded paths per environment
if (Environment.IsProduction())
{
options.ExcludedPaths = new[] { "/health" };
}
else
{
options.ExcludedPaths = new[] { "/health", "/swagger", "/debug" };
}
},
errorMessages =>
{
if (Environment.IsDevelopment())
{
// Detailed messages for development
errorMessages.ValidationErrorMessage = "Validation failed - check detailed error list";
errorMessages.ApplicationErrorMessage = "Application error - check logs for full stack trace";
}
else
{
// User-friendly messages for production
errorMessages.ValidationErrorMessage = "Please check your information and try again";
errorMessages.ApplicationErrorMessage = "We're experiencing technical difficulties";
}
});
}// Create a custom interceptor for query statistics
public class QueryStatisticsInterceptor : DbConnectionInterceptor
{
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
// Track query execution
var httpContext = GetHttpContext();
if (httpContext != null)
{
var queryStats = GetOrCreateQueryStats(httpContext);
queryStats["QueriesCount"] = (int)queryStats.GetValueOrDefault("QueriesCount", 0) + 1;
var executedQueries = (List<string>)queryStats.GetValueOrDefault("ExecutedQueries", new List<string>());
executedQueries.Add(command.CommandText);
queryStats["ExecutedQueries"] = executedQueries.ToArray();
}
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
}
}
// Register the interceptor
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString)
.AddInterceptors(new QueryStatisticsInterceptor());
});public class CustomApiLogger : ILogger<ApiResponseWrapperFilter>
{
private readonly ILogger<ApiResponseWrapperFilter> _innerLogger;
private readonly IMetrics _metrics;
public CustomApiLogger(ILogger<ApiResponseWrapperFilter> innerLogger, IMetrics metrics)
{
_innerLogger = innerLogger;
_metrics = metrics;
}
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
{
// Custom logging logic
_innerLogger.Log(logLevel, eventId, state, exception, formatter);
// Send metrics
if (logLevel >= LogLevel.Warning)
{
_metrics.Counter("api_errors").Increment();
}
}
// Implement other ILogger methods...
}
// Register with ResponseWrapper
services.AddResponseWrapper<CustomApiLogger>(
() => DateTime.UtcNow,
options => options.EnableExecutionTimeTracking = true);// Development: Enable everything for debugging
if (env.IsDevelopment())
{
services.AddResponseWrapper(options =>
{
options.EnableExecutionTimeTracking = true;
options.EnableQueryStatistics = true;
options.EnablePaginationMetadata = true;
options.EnableCorrelationId = true;
});
}
// Production: Optimize for performance
if (env.IsProduction())
{
services.AddResponseWrapper(options =>
{
options.EnableExecutionTimeTracking = true;
options.EnableQueryStatistics = false; // Disable if not needed
options.EnablePaginationMetadata = true;
options.EnableCorrelationId = true;
options.ExcludedPaths = new[] { "/health", "/metrics" };
});
}// Good: Specific exception types
if (user == null)
throw new NotFoundException("User", id);
if (!user.IsActive)
throw new BusinessException("User account is not active");
if (!User.IsInRole("Admin"))
throw new ForbiddenAccessException("Administrator access required");
// Avoid: Generic exceptions
// throw new Exception("Something went wrong");// Good: Rich status information
public class PaymentResult : IHasStatusCode
{
public string TransactionId { get; set; }
public decimal Amount { get; set; }
public string StatusCode { get; set; }
public string Message { get; set; }
}
// Different status codes for different outcomes
switch (paymentResponse.Status)
{
case PaymentStatus.Success:
return new PaymentResult { StatusCode = "PAYMENT_SUCCESS", ... };
case PaymentStatus.InsufficientFunds:
return new PaymentResult { StatusCode = "INSUFFICIENT_FUNDS", ... };
case PaymentStatus.CardExpired:
return new PaymentResult { StatusCode = "CARD_EXPIRED", ... };
}// Customize messages for better UX
errorMessages.ValidationErrorMessage = "Please review the highlighted fields and try again";
errorMessages.NotFoundErrorMessage = "We couldn't find what you're looking for";
errorMessages.UnauthorizedAccessMessage = "Please sign in to continue";options.ExcludedPaths = new[]
{
"/health", // Health checks
"/metrics", // Metrics endpoints
"/swagger", // API documentation
"/api/files", // File download endpoints
"/webhooks" // Webhook endpoints
};// Enable detailed monitoring in development
options.EnableQueryStatistics = Environment.IsDevelopment();
// Log execution times for performance monitoring
if (options.EnableExecutionTimeTracking)
{
// Monitor slow requests
if (executionTimeMs > 1000)
{
logger.LogWarning("Slow request detected: {RequestId} took {ExecutionTime}ms",
requestId, executionTimeMs);
}
}Problem: Some controller responses are not wrapped.
Solutions:
[ApiController] attributeExcludedPathsExcludedTypesWrapSuccessResponses is enabled[ApiController] // Required for automatic wrapping
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
// This will be wrapped
[HttpGet]
public async Task<List<User>> GetUsers() { ... }
}Problem: Pagination metadata is not extracted from custom pagination classes.
Solution: Ensure your pagination class has all required properties:
public class MyPagedResult<T>
{
// All these properties are required
public List<T> Items { get; set; } // Required
public int Page { get; set; } // Required
public int PageSize { get; set; } // Required
public int TotalPages { get; set; } // Required
public int TotalItems { get; set; } // Required
public bool HasNextPage { get; set; } // Required
public bool HasPreviousPage { get; set; } // Required
}Problem: Application status codes are not appearing in responses.
Solution: Implement the IHasStatusCode interface on your response DTOs:
public class MyResponse : IHasStatusCode
{
public string Data { get; set; }
public string StatusCode { get; set; } // This will be promoted to ApiResponse level
public string Message { get; set; } // This will also be promoted
}Problem: Custom error messages are not displayed.
Solutions:
GlobalExceptionHandlingMiddleware is registeredWrapErrorResponses is enabledvar app = builder.Build();
// Add this EARLY in the pipeline
app.UseMiddleware<GlobalExceptionHandlingMiddleware>();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();Problem: API responses are slower after adding ResponseWrapper.
Solutions:
options.EnableQueryStatistics = falseoptions.ExcludedPaths = new[] { "/api/high-frequency" }options.EnableExecutionTimeTracking = falseProblem: File download endpoints are returning JSON instead of files.
Solutions:
ISpecialResult interface for custom file resultsExcludedTypes[SkipApiResponseWrapper] attribute// Option 1: Use ISpecialResult
public class FileDownloadResult : ActionResult, ISpecialResult { ... }
// Option 2: Exclude file types
options.ExcludedTypes = new[] { typeof(FileResult), typeof(FileStreamResult) };
// Option 3: Skip specific endpoints
[SkipApiResponseWrapper("File download endpoint")]
public async Task<IActionResult> DownloadFile(int id) { ... }We welcome contributions to FS.AspNetCore.ResponseWrapper! Here's how you can help:
git checkout -b feature/amazing-feature)git commit -m 'Add amazing feature')git push origin feature/amazing-feature)# Clone the repository
git clone https://github.com/furkansarikaya/FS.AspNetCore.ResponseWrapper.git
# Navigate to the project directory
cd FS.AspNetCore.ResponseWrapper
# Restore dependencies
dotnet restore
# Build the project
dotnet build
# Run tests
dotnet testThis project is licensed under the MIT License - see the LICENSE file for details.
Made with ❤️ by Furkan Sarıkaya
Transform your ASP.NET Core APIs with consistent, metadata-rich responses and intelligent status code management. Install FS.AspNetCore.ResponseWrapper today and experience the difference!