Skip to content
Kumar Chandrachooda
.NET

The Mapper in the Middle

Seventeen parts of reading FastEndpoints and the series never once mentioned mappers - the entity-translation layer half the endpoint base classes exist to serve. The Missing Chapters opens with the biggest gap: where mappers live, how they are found, and why they are secretly singletons. Part 1 of FastEndpoints — The Missing Chapters.

By Kumar Chandrachooda 27 May 2026 5 min read
Request in, entity in the middle, response out - the mapper sits at both borders

Last year I wrote seventeen parts about the open-source FastEndpoints library - discovery, binding, validation, security, messaging, job queues, source generators, the lot. When I recently re-inventoried the repository against that series, the audit was humbling: the deep dive covered the spine, but a surprising amount of muscle never got a paragraph. Whole packages - OData, health checks, a native OpenAPI stack, MCP and A2A agent integrations - and, more embarrassingly, features I use every week in the core library itself.

This companion series is the write-up of those gaps. Same rules as before: FastEndpoints is a third-party MIT library by Đĵ ΝιΓΞΗΛψΚ and contributors; I am a power user and source-reader, not its author. Where the implementation is the story I quote it, attributed; everything else is fresh example code.

We start with the gap that sits closest to the REPR pattern itself: mappers.

The layer REPR forgot to mention

The REPR pitch is Request in, Endpoint, Response out. But most handlers have a fourth participant: the entity - the EF Core model, the domain aggregate, the thing your database actually stores. The translation between DTOs and entities is exactly the kind of repetitive sideways code that clutters handlers, and FastEndpoints ships a whole sub-system for it that the original series skipped entirely.

public class UserMapper : Mapper<CreateUserRequest, UserResponse, User>
{
    public override User ToEntity(CreateUserRequest r) => new()
    {
        UserName = r.UserName.Trim().ToLowerInvariant(),
        Email    = r.Email
    };

    public override UserResponse FromEntity(User e) => new()
    {
        Id       = e.Id,
        UserName = e.UserName
    };
}

public class CreateUserEndpoint : Endpoint<CreateUserRequest, UserResponse, UserMapper>
{
    public override void Configure() => Post("/users");

    public override async Task HandleAsync(CreateUserRequest req, CancellationToken ct)
    {
        var user = Map.ToEntity(req);
        await userRepo.AddAsync(user, ct);
        await Send.OkAsync(Map.FromEntity(user));
    }
}

Third generic argument on the endpoint, a Map property, done. Under that convenience sits a small type system worth knowing.

Two directions, four base classes

The contracts live in Src/Library/Endpoint/Mapper/ and split cleanly by direction. IRequestMapper<TRequest, TEntity> covers the way in: ToEntity, ToEntityAsync, and - a nice touch for update endpoints - UpdateEntity/UpdateEntityAsync, which take an existing entity and apply the request to it. IResponseMapper<TResponse, TEntity> covers the way out: FromEntity/FromEntityAsync. Both extend a bare IMapper marker.

Mapper<TRequest, TResponse, TEntity> implements both directions, and every method is virtual with a body that throws NotImplementedException("Please override the ToEntity method!") - override only what you use, get a named exception (not a silent null) if you forgot one. One-directional endpoints get slimmer bases: RequestMapper<TRequest, TEntity> and ResponseMapper<TResponse, TEntity>.

The endpoint side mirrors that. Endpoint<TReq, TRes, TMapper> is the full both-ways variant; EndpointWithMapper<TReq, TMapper> pairs with a request-only mapper; EndpointWithoutRequest<TRes, TMapper> with a response-only one. And if you'd rather not have a separate class at all, EndpointWithMapping<TReq, TRes, TEntity> puts MapToEntity/MapFromEntity methods directly on the endpoint - same shape, no extra type.

The full and response-only variants add one more convenience, SendMapped:

public override async Task HandleAsync(GetUserRequest req, CancellationToken ct)
{
    var user = await userRepo.FindAsync(req.Id, ct)
               ?? throw new UserNotFound(req.Id);
    await SendMapped(user, cancellation: ct);   // FromEntity + Send in one call
}

Reading its implementation taught me something about the generics: SendMapped<TEntity> performs a hard cast of the endpoint's mapper to Mapper<TRequest, TResponse, TEntity>, with TEntity inferred from the argument at the call site, not from the mapper's declaration. Pass an object your mapper doesn't actually handle and the failure is an InvalidCastException at runtime, not a compile error. The compiler checks the first two generic parameters for you; the entity type is on the honour system.

How the mapper reaches the endpoint

Nothing registers a mapper. Discovery works the same way as everything else in this library (parent Part 2): at startup, EndpointData inspects each endpoint's interfaces for the open generic IHasMapper<> and records the generic argument as the endpoint's MapperType. The code comment explains a subtlety worth stealing for your own reflection code: IsAssignableTo() is no good here if the user inherits the interface - so it walks the interface list and compares generic type definitions instead.

At request time, the endpoint's Map property looks like this (from the FastEndpoints source, Endpoint.cs):

[DontInject]
public TMapper Map
{
    get => _mapper ??= Definition.GetMapper() as TMapper
           ?? throw new InvalidOperationException("Endpoint mapper is not set!");
    set => _mapper = value; //allow unit tests to set mapper from outside
}

Three details, three stories. [DontInject] keeps the property-injection machinery (parent Part 3) from trying to resolve a mapper from your DI container. The public setter exists only so tests can hand an endpoint a fake mapper - the testing package's Factory.Create re-derives the mapper type the same way discovery does, and you can overwrite it. And Definition.GetMapper() is where the lifetime is decided.

Secretly a singleton, deliberately

GetMapper() lazily creates the mapper and caches it on the EndpointDefinition - the startup-built metadata object, of which there is exactly one per endpoint class. Creation goes through the library's internal ServiceResolver.CreateSingleton, not through a DI registration. Two consequences:

  1. One mapper instance per endpoint type, for the life of the process. The XML docs on every mapper base class repeat the same warning: "entity mappers are used as singletons for performance reasons. do not maintain state in the mappers." The same rule as validators (parent Part 5), enforced by the same mechanism.
  2. Your container never sees it - so constructor-injecting a scoped DbContext into a mapper is a trap. The escape hatch is that every mapper base class also implements the library's service-resolution surface: inside a mapper you can call Resolve<T>() or CreateScope(), and resolution routes through the ambient request scope when one exists.
public class OrderMapper : Mapper<PlaceOrderRequest, OrderResponse, Order>
{
    public override async Task<Order> ToEntityAsync(PlaceOrderRequest r, CancellationToken ct)
    {
        var pricing = Resolve<IPricingService>();       // request-scoped resolution, singleton mapper
        return new() { Total = await pricing.QuoteAsync(r.Lines, ct) };
    }
}

The object is a process singleton; its service lookups are request-aware. That contract is subtle enough that the whole next stretch of this series orbits it - the static resolver that makes it work gets a part of its own.

Is this better than AutoMapper or Mapster? Different question, honestly. There is no convention magic here at all - you write both directions by hand, which for two-DTO-one-entity slices is usually less code than the configuration those libraries need, and it is grep-able. What FastEndpoints adds over a plain static class is placement (the mapper is discovered, attached, and testable as part of the slice) and the async variants, which mapping libraries traditionally handle badly.

Where the series goes

The audit found twelve chapters' worth of gaps. The plan:

  1. The mapper in the middle - this post.
  2. Every way to send a response - the Send facade, files and ranges, redirects, interceptors.
  3. The antiforgery check that fails at boot - the opt-in middleware nobody documents.
  4. All configuration is static - the Config object graph and its hooks.
  5. The resolver behind everything - FastEndpoints.Core, FastEndpoints.Attributes, and the lifetime machinery.
  6. Health probes without an endpoint class - the HealthChecks package.
  7. IQueryable in, OData out - the OData bridge and the seam it rides.
  8. Life after NSwag - the new Microsoft.AspNetCore.OpenApi stack.
  9. Four ways to generate an API client - NSwag CodeGen and Kiota, twice each.
  10. Rules that plan commands - the CommandRules package.
  11. Your endpoints are now tools - the MCP integration.
  12. An agent card for your API - A2A, and the close of both series.

If the original seventeen parts were the anatomy lesson, these twelve are the field notes from the organs the scalpel missed. Next week: the response surface.