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.

Extensions/ResourceMappingExtensions.cs
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:

usage
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.

Validations/SignUp/SignUpValidation.cs
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.");
    }
}
usage in a service
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 a try/catch around every action (see ApiResponse & Controllers), so a thrown ArgumentException or KeyNotFoundException from 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.

EmployeeRoleMapping.cs
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.

CreateResourceRequestValidation.cs
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.");
    }
}