Mapping & Validation Extensions
Two small conventions that replace AutoMapper and FluentValidation with plain extension methods โ no reflection, no config, fully debuggable.
Mapping: ToDto() extension methods
Each entity-to-DTO conversion lives as an extension method grouped by feature area, not as a giant central mapper class. It reads left-to-right at the call site (entity.ToDto()) and each mapping is a plain method you can step through in the debugger.
public static class ResourceMappingExtensions
{
public static CategoryDto ToDto(this Category category) => new()
{
Id = category.Id,
Name = category.Name,
Description = category.Description,
IsActive = category.IsActive
};
public static RatingDto ToDto(this Rating rating) => new()
{
UserId = rating.UserId,
CategoryName = rating.Category?.Name ?? "Unknown",
CurrentRating = rating.CurrentRating,
MatchCount = rating.MatchCount
};
}
Used directly in a service method:
public async Task<IEnumerable<ActivityOverviewResponse>> GetUpcomingAsync()
{
var activities = await _repository.GetUpcomingAsync();
return activities.Select(a => a.ToOverviewResponse());
}
Validation: Validate() extension methods on request DTOs
Instead of a validation framework, each request type gets a static validator class with a Validate() extension that throws on the first problem it finds. Services call it as the very first line of the method, so invalid input never reaches business logic.
public static class SignUpValidation
{
public static void Validate(this SignUpRequest request, Activity? activity, Guid userId, int totalSignUps)
{
if (activity is null)
throw new KeyNotFoundException("Activity not found.");
if (activity.StartsAt <= DateTime.UtcNow)
throw new ArgumentException("Sign-up is closed for this activity.");
if (activity.MaxParticipants.HasValue && totalSignUps >= activity.MaxParticipants)
throw new ArgumentException("This activity is full.");
}
}
public async Task SignUpAsync(SignUpRequest request, Guid userId)
{
var activity = await _activityRepository.GetByIdAsync(request.ActivityId);
var totalSignUps = await _signUpRepository.CountByActivityIdAsync(request.ActivityId);
request.Validate(activity, userId, totalSignUps); // throws on failure
await _signUpRepository.AddAsync(request.ToSignUp(userId));
}
The controller layer already has atry/catcharound every action (see ApiResponse & Controllers), so a thrownArgumentExceptionorKeyNotFoundExceptionfrom a validator naturally becomes a 400 or 404 with a useful message โ no extra plumbing needed.
Bonus: explicit enum mapping between layers
Domain enums and their API-facing DTO counterparts are kept as two separate enums, converted by a small switch-based extension. This means the Domain enum can be reordered or renamed freely without silently shifting the API's wire values, and a missing case is a compile-time-adjacent ArgumentOutOfRangeException instead of a mystery.
public static class EmployeeRoleMapping
{
public static EmployeeRole ToDomain(this EmployeeRoleDto role) => role switch
{
EmployeeRoleDto.Admin => EmployeeRole.Admin,
EmployeeRoleDto.Manager => EmployeeRole.Manager,
EmployeeRoleDto.Cashier => EmployeeRole.Cashier,
EmployeeRoleDto.None => EmployeeRole.None,
_ => throw new ArgumentOutOfRangeException(nameof(role))
};
public static EmployeeRoleDto ToDto(this EmployeeRole role) => role switch
{
EmployeeRole.Admin => EmployeeRoleDto.Admin,
EmployeeRole.Manager => EmployeeRoleDto.Manager,
EmployeeRole.Cashier => EmployeeRoleDto.Cashier,
EmployeeRole.None => EmployeeRoleDto.None,
_ => throw new ArgumentOutOfRangeException(nameof(role))
};
}
Bonus: validating an uploaded file
Same Validate() extension convention, applied to a request that carries an IFormFile. Check content type against an allow-list (never trust the file extension) and size before the file ever reaches a service or gets written anywhere โ this pairs with the multipart upload pattern on the client side.
public static class CreateResourceRequestValidation
{
private static readonly List<string> ValidImageTypes = ["image/png", "image/jpeg", "image/webp"];
public static void Validate(this CreateResourceRequest request)
{
if (string.IsNullOrWhiteSpace(request.Name))
throw new ArgumentException("Name is required.");
if (request.Image.Length <= 0)
throw new ArgumentException("Invalid file size.");
if (!ValidImageTypes.Contains(request.Image.ContentType, StringComparer.OrdinalIgnoreCase))
throw new ArgumentException("Invalid file type.");
if (request.StartDate >= request.EndDate)
throw new ArgumentException("End date must be after start date.");
}
}