DEV Community

Stanislav Vyshchepan
Stanislav Vyshchepan

Posted on AI-assisted

How and Why I Built My Own .NET Mapper

I don't like Clean Architecture, I don't like DTOs and I don't like mappers.

Together they turn saving one row to the database into a journey through four projects, three layers of abstraction and a Mappings folder nobody has opened since the initial commit. To add one field to a form you change the entity, the DTO, the command, the validator, the mapping profile and the mapping profile test. Six files for one column. And if you forget one of them, nobody tells you.

So, naturally, I wrote a mapper.

Seriously though, there are two places where the problem is real. In this post I'll show what they are, why popular mappers don't really solve them, and what I did about it.

AI SLOP WARNING. Most of the code in this library was written by LLMs: Qwen, GLM-5.3-Flash and Claude. I set the tasks, reviewed, rewrote the architecture and fixed what they broke - and what I broke myself. If that offends you, feel free to stop reading here. For everyone else, I'll point out who did what along the way.

Originally published in Russian on Habr.

Why a mapper at all

A typical CRUD app. There's an entity with thirty columns:

public class User
{
    public Guid Id { get; set; }
    public string FirstName { get; set; } = "";
    public string LastName { get; set; } = "";
    public string? Bio { get; set; }
    public bool IsBlocked { get; set; }
    public DateTimeOffset CreatedAt { get; set; }
    // ...and two dozen more
}
Enter fullscreen mode Exit fullscreen mode

There's a read model and a write model:

public class UserResponse
{
    public Guid Id { get; init; }
    public string FirstName { get; init; } = "";
    public string LastName { get; init; } = "";
    public bool IsBlocked { get; init; }
}

public class UpdateUserRequest
{
    public string FirstName { get; init; } = "";
    public string LastName { get; init; } = "";
    public string? Bio { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

Nobody designs these models. They are just slices of User. Two hand-written places connect them to the entity.

Place one - the projection in a query:

var users = await db.Users
    .Where(u => !u.IsBlocked)
    .Select(u => new UserResponse
    {
        Id = u.Id,
        FirstName = u.FirstName,
        LastName = u.LastName,
        IsBlocked = u.IsBlocked,
    })
    .ToListAsync(ct);
Enter fullscreen mode Exit fullscreen mode

The projection is there so you don't load the whole entity for four fields. Less data over the wire, no change tracking.

Place two - assignments in a command handler or an endpoint:

var user = await db.Users.FindAsync([request.Id], ct);

user.FirstName = request.FirstName;
user.LastName = request.LastName;
user.Bio = request.Bio;

await db.SaveChangesAsync(ct);
Enter fullscreen mode Exit fullscreen mode

This code is needed to validate incoming data and to prevent overposting in endpoints.

Nobody writes blog posts about this code, because there's nothing to discuss. But this is exactly the code that breaks when you least expect it.

What can go wrong

A new requirement: the response needs CreatedAt. I add the property:

public class UserResponse
{
    // ...
    public DateTimeOffset CreatedAt { get; init; } // new
}
Enter fullscreen mode Exit fullscreen mode

If I forget to add CreatedAt = u.CreatedAt to the projection, nothing happens. An object initializer doesn't have to set every property. The build is green, the tests are green, and the API returns 0001-01-01T00:00:00. According to the service, your user signed up in the year 1 AD.

Same with the handler. You add a property to the entity that must be set on write - the assignments are not obliged to set it.

The compiler only checks one side:

  • rename a property in the source - the build fails
  • a new unassigned property in the target - silence

What about required?

At this point an attentive reader says: C# 11 has required for exactly this.

public class UserResponse
{
    public required DateTimeOffset CreatedAt { get; init; }
}

var r = new UserResponse { Id = Guid.NewGuid() };
// error CS9035: Required member 'CreatedAt' must be set in the object initializer.
Enter fullscreen mode Exit fullscreen mode

It works. But only where the compiler creates the object. Runtime mappers create objects via reflection or compiled expressions, bypassing the object initializer. To them required is just another property. It's not a bug in a particular library, it's a direct consequence of the runtime approach.

So here's what I actually need:

  • an IQueryable projection that translates to SQL and follows model changes on its own
  • updating an existing entity tracked by EF, without recreating it
  • an unassigned target property is a build-time signal, not a null in production

Not a mapping framework. Two places and a mismatch detector.

What's on the market

Let's go one by one. Spoiler: it's all bad.

AutoMapper

I never added AutoMapper to a project myself. But almost every project I joined already had it. Usually bundled with Clean Architecture, MediatR and that very Mappings folder.

public class UserProfile : Profile
{
    public UserProfile()
    {
        CreateMap<User, UserResponse>();
        CreateMap<UpdateUserRequest, User>();
    }
}
Enter fullscreen mode Exit fullscreen mode

Looks innocent. Now point by point.

1. Errors only at runtime. Forgot a CreateMap - you'll find out from AutoMapperMappingException: Missing type map configuration or unsupported mapping in the logs. A target property has no source - you won't find out at all, it just stays default. There's AssertConfigurationIsValid() for that, which you have to remember to call in tests. Guess how often people forget.

2. Renaming silently breaks mappings. Convention-based mapping is string matching. Rename a property with an IDE refactoring - the IDE updates every reference, the profile notices nothing. Find Usages on a DTO property won't show where it's populated. F12 leads nowhere.

3. Configuration is code, only worse. As soon as conventions aren't enough, you get ForMember, MapFrom, ConvertUsing, IValueResolver, AfterMap. Resolvers pull dependencies from DI, calculations move into MapFrom, and a year later the profiles contain business logic nobody sees, because nobody opens profiles. To figure out where a value in the response came from, you find the profile, in the profile the ForMember, in it the resolver, in the resolver the service.

4. Used for the wrong job. AutoMapper's author himself wrote that the library is designed for one scenario: the destination is a subset of the source, a rich model gets flattened into a view model. If your problem is different, the tool isn't a good fit. Meanwhile projects use AutoMapper to map commands onto entities - exactly the opposite direction.

5. IMapper everywhere. Map needs IMapper, ProjectTo needs IConfigurationProvider. So it's AddAutoMapper in DI, assembly scanning at startup and IMapper in the constructor of every handler. A dependency injected just to copy fields from one object to another.

6. required doesn't work, see above.

7. Slow. AutoMapper is 8x slower than hand-written code. Numbers below.

License. Since version 15 (July 2025) AutoMapper is commercially licensed, the last MIT version is 14. On top of the technical problems that came with the project, you now have a legal one.

That's a legitimate decision by the author. But if you have to migrate anyway, why migrate to another AutoMapper?

Mapperly

Mapperly is the best of the three, which makes its downsides all the more annoying. A source generator, readable C# output, zero reflection. It catches unmapped target members (RMG012) and respects required, because it emits a plain object initializer and the language itself reports CS9035.

Now the price:

[Mapper]
public static partial class UserMapper
{
    public static partial UserResponse ToResponse(User user);
    public static partial void UpdateUser(UpdateUserRequest request, User user);
    public static partial IQueryable<UserResponse> ProjectToResponse(this IQueryable<User> q);
}
Enter fullscreen mode Exit fullscreen mode

1. A mapper method per pair. Thirty DTOs - thirty partial methods. Plus a separate method for each projection. Plus a separate method for mapping into an existing object. The hand-written mapping we wanted to get rid of became hand-written mapping declarations. Fewer lines, same ceremony.

2. Configuration moved into attributes. A non-standard case is [MapProperty(nameof(User.FirstName), nameof(UserResponse.Name))] or [MapperIgnoreTarget(...)]. The same ForMember from AutoMapper, just in square brackets.

3. Not at the call site. Want a mapping - find which mapper declares the method you need. None - go declare one. The mapper becomes one more layer to maintain. Exactly what Clean Architecture already has enough of.

4. Generic code doesn't work. Mapperly only generates code for type pairs known at compile time. If the mapping is called inside a generic method, say a base repository or a generic handler, there are no concrete types, only TEntity and TDto:

public Task<List<TDto>> GetAll<TEntity, TDto>() where TEntity : class
{
    IQueryable<TEntity> query = db.Set<TEntity>();
    IQueryable<TDto> projected = ???; // which partial mapper method goes here?
    return projected.ToListAsync();
}
Enter fullscreen mode Exit fullscreen mode

Code like this needs a runtime fallback that builds the mapping once the types are known. Mapperly doesn't have one: its generic mapper methods can only pick from pairs declared upfront in the same mapper.

Mapster

Mapster is AutoMapper that decided to skip the profiles. Easier to start with and faster, but it has architectural problems too.

var dto = user.Adapt<UserResponse>();                                    // new object
request.Adapt(user);                                                     // into existing object
var list = await db.Users.ProjectToType<UserResponse>().ToListAsync(ct); // projection
Enter fullscreen mode Exit fullscreen mode

Non-standard cases go into configuration:

TypeAdapterConfig<User, UserResponse>
    .NewConfig()
    .Map(d => d.FullName, s => s.FirstName + " " + s.LastName);
Enter fullscreen mode Exit fullscreen mode

1. Adapt hangs off object. The method is an extension on object, so IntelliSense offers it on everything. 42.Adapt<UserResponse>() compiles just fine. The compiler doesn't know the source type, so there's nothing to check.

2. Global mutable state. By default configuration lives in the static TypeAdapterConfig.GlobalSettings. Any code in any assembly can change it, and initialization order is your problem.

3. Errors at runtime. A mapping is compiled on first use. You can enable a check that every target member has a source: RequireDestinationMemberSource = true plus a Compile() call at startup. It's off by default. And even when enabled, it fires when the app starts, not when it builds.

4. A generator on the side. Mapster does have code generation, but it's a separate tool, Mapster.Tool. You install it as a dotnet tool and wire it up to run after build:

<Target Name="Mapster" AfterTargets="AfterBuild">
  <Exec WorkingDirectory="$(ProjectDir)" Command="dotnet tool restore" />
  <Exec WorkingDirectory="$(ProjectDir)"
        Command="dotnet mapster extension -a &quot;$(TargetDir)$(ProjectName).dll&quot;" />
</Target>
Enter fullscreen mode Exit fullscreen mode

The tool reads the already built DLL and generates mapper files from attributes, interfaces or fluent configuration. For example, with attributes:

[AdaptTo("[name]Dto"), GenerateMapper]
public class User { ... }

// Generates a UserDto class and a static UserMapper:
var dto = user.AdaptToDto();
user.AdaptTo(existingDto);
var list = db.Users.Select(UserMapper.ProjectToDto);
Enter fullscreen mode Exit fullscreen mode

What's wrong here:

  • it's not a Roslyn source generator that runs in the IDE on every change, it's a separate post-build step
  • generation happens after compilation, so a fresh mapper only makes it into the build on the next build
  • the tool has to be installed locally, on CI and for every new developer
  • the generated code has a different API, not Adapt<T>(): switching from runtime to generation means rewriting every call
  • no diagnostics at the call site

Summary

Need Who provides it
Call at the site, no mapper classes Mapster, AutoMapper
No DI registration Mapster, Mapperly
Build error if a target member is unassigned Mapperly (at a price)
IQueryable projection to SQL AutoMapper, Mapster, Mapperly
Works in generic code AutoMapper, Mapster (runtime)
required is respected Mapperly
All of the above, no ceremony Nobody

When the market is empty, you have two options: live with it or write your own. I chose the second.

What I want

At first I wanted this API:

var dto = user.MapTo<UserResponse>();            // new object
request.MapTo(user);                             // into existing object
var list = db.Users.ProjectTo<UserResponse>();   // projection to SQL
Enter fullscreen mode Exit fullscreen mode

It works for a runtime implementation: the method is declared as MapTo<TTarget>(this object source), and the source type comes from source.GetType().

It doesn't work for a generator. The generator needs the static source type, so it has to be in the signature: MapTo<TSource, TTarget>(this TSource source). And C# infers generic arguments all or nothing. You can't specify only TTarget, so the call turns into this:

var dto = user.MapTo<User, UserResponse>();
var list = db.Users.ProjectTo<User, UserResponse>();
Enter fullscreen mode Exit fullscreen mode

Verbose, and the source type is repeated at every call. Mapping into an existing object is fine - both types are inferred from the arguments. But you don't design an API around one method.

So the call is split into two steps. An extension method returns a wrapper struct that captures the source type, and the wrapper has To<TTarget>() where you only specify the target:

public readonly struct FusionSource<TSource>(TSource value)
{
    public TTarget To<TTarget>() => FusionMapper<TSource, TTarget>.Map(value);
    public TTarget To<TTarget>(TTarget target) => FusionMapper<TSource, TTarget>.Map(value, target);
}
Enter fullscreen mode Exit fullscreen mode

The wrapper is a struct, so there are no extra allocations. The final API:

var dto = user.Map().To<UserResponse>();            // new object
request.Map().To(user);                             // into existing object
var list = db.Users.Project().To<UserResponse>();   // projection to SQL
Enter fullscreen mode Exit fullscreen mode

No profiles, no mapper classes, no DI registration. A target member without a source - a warning at the call site. A required member without a source - a compile error.

The library is called FusionMapper: a fusion of a runtime mapper and a source-generated one. Considering how much of the code AI generated, I wanted to call it SlopMapper.

How it was built

Here's how I did it and what I ran into.

Runtime first

The first commit is August 5. I deliberately started not with a generator but with a prototype on expression trees.

Generators are slow to iterate on. And mapping conventions have to be defined first, which is easiest on a working core: add a rule, run the tests.

The runtime mapper and its tests were mostly written by Qwen. The free one, right in the chat on its website, no IDE integration. I described a rule, got code and tests, ran them, pasted back the errors. I only did some refactoring myself. The first days went into semantics:

  • name matching: exact, case-insensitive, ignoring a leading underscore; fields on par with properties
  • flattening: CustomerAddressCity ← Customer.Address.City, at any depth
  • nullable reference types: going through a nullable object yields null, not a NullReferenceException; a nullable string into a non-nullable member is a contract error
  • constructors, required and init: constructor selection with parameters matched by name, [SetsRequiredMembers] is honored
  • aggregates - more on those below

By August 12 there were benchmarks, and the expression-based version was already faster than Mapster. But it didn't deliver the main thing: errors still appeared only at runtime, and the IDE stayed silent until you ran the tests.

Aggregates

Almost every view model has a summary over a collection: the sum of order lines, "has active lines", the last buyer.

I was curious how others handle this. AutoMapper has exactly one aggregate by convention - Count. And it's not a feature but a side effect of flattening: the name LinesCount splits into Lines + Count, and Count is just a property of List<T>. Declare the collection as IEnumerable<T> and it stops working. I checked on AutoMapper 15: LinesCount maps without configuration, LinesAmountSum silently stays zero. Everything else is written by hand:

CreateMap<Order, OrderDto>()
    .ForMember(d => d.Total, o => o.MapFrom(s => s.Lines.Sum(l => l.Amount)))
    .ForMember(d => d.HasActive, o => o.MapFrom(s => s.Lines.Any(l => l.IsActive)));
Enter fullscreen mode Exit fullscreen mode

In FusionMapper the aggregate is described by the property name:

DTO property name Generated code
LinesCount Lines.Count
LinesAmountSum Lines.Sum(x => x.Amount)
LinesAmountAverage, ...Max, ...Min aggregate over a selector
LinesIsActiveAny, LinesIsActiveAll Lines.Any/All(x => x.IsActive)
LinesBuyerLastOrDefault Lines.Select(x => x.Buyer).LastOrDefault()

Eleven aggregates in total. No configuration, and inside Project().To<>() it all goes to SQL.

Two honest caveats:

  • the property name is a magic string, a typo in the suffix is caught not by an error but by the FMAP005 warning
  • empty collections behave like LINQ: Average, Max, First throw, which is what the *OrDefault variants are for

And yes, if you really want the property to be called just Total, you'll have to give in. There's no ForMember equivalent.

Source generator

I took the source generator scaffold with interceptors from Andrew Lock - he has detailed posts and samples on the topic. Then I asked Qwen to turn the runtime mapper into a source generator on top of that scaffold. The mapping logic moved over almost unchanged, only the output changed: C# strings instead of Expression.

Interceptors

The main idea I wanted to test was C# interceptors. A generator can replace a specific call on a specific line with its own method. The user writes:

var dto = user.Map().To<UserDto>();
Enter fullscreen mode Exit fullscreen mode

And the compiler calls the generated method:

public static UserDto Map__User__To__UserDto(User source)
{
    if (source == null) return default;
    return new UserDto
    {
        Name = source.Name,
        Age = source.Age
    };
}
Enter fullscreen mode Exit fullscreen mode

A plain object initializer, zero reflection. And an impossible mapping becomes a compile error, not an exception in production.

Sounds great, but in practice debugging is very hard. If you mess up the interceptors, the call simply doesn't get replaced, and you don't even know whether the generated method or the runtime fallback ran. You only see it in the stack trace if a test fails inside the generated method.

There's one more constraint that cost me a lot of time. You can't apply an interceptor inside an expression tree: there's no method call there, only data describing the call. So the generator has to filter call sites and skip those inside a lambda compiled into Expression<...>.

The first working version of interceptors for all .To<T>() calls was generated by Qwen. It had that filter. Then I accidentally broke it during a refactoring. Interceptors started being applied to calls inside expressions, and the compiler started crashing. Not reporting an error in the code - crashing entirely, with no meaningful message and no line number.

I debugged it for a long time. The commit history for August 15-16:

Added interceptors for To method calls
Refactoring? temporary disabled generators
Восстановлены интерсепторы
Enter fullscreen mode Exit fullscreen mode

When I finally fixed it, I wrote the commit message in Russian, my native language ("Interceptors restored"). The only time in the entire repository history. I think that says it all.

So we end up with two modes:

  • .NET 9+ - interceptors by default, no reflection, an impossible mapping is a compile error
  • .NET 8 or EnableFusionMapperInterceptor=false - generated mappers are registered at startup via [ModuleInitializer], whatever the generator couldn't build is built at runtime, and diagnostics become warnings

The expression-based runtime version never went away, and that solves the generic code problem. If a call sits inside a generic method and the types aren't known at compile time, the generator skips it, and the mapping is built at runtime on the first call with concrete types:

public Task<List<TDto>> GetAll<TEntity, TDto>() where TEntity : class
    => db.Set<TEntity>().Project().To<TDto>().ToListAsync();
Enter fullscreen mode Exit fullscreen mode

Where types are known, generated code runs. Where they aren't, the runtime fallback does. The calling code is the same.

Projections

Project().To<T>() needs not code but data - an Expression<Func<TSource, TTarget>> that EF Core translates to SQL. Interceptors don't help here. So the generator builds the expressions ahead of time and, via the same [ModuleInitializer], puts them in a cache when the assembly loads. No expression trees are built at runtime.

Caching in the generator

What Qwen generated worked, but it worked slowly. Roslyn runs a generator on practically every keystroke, and if the generator recomputes every mapping each time, Visual Studio on a large solution gets noticeably sluggish.

An incremental generator caches the results of pipeline steps, but only if the models between steps compare by value. Arrays don't compare by value, and you can't keep references to Roslyn symbols in the model. So I rewrote the generator architecture myself: models became immutable records without references to the semantic model, collections became EquatableArray, hash codes are computed explicitly. I borrowed EquatableArray and the generator caching tests from Andrew Lock as well. Such a test runs the generator a second time on a copy of the same compilation and checks that every tracked pipeline step returned Cached or Unchanged, meaning nothing was recomputed. Meanwhile Qwen was fixing the failing tests.

Boring engineering, but it's what separates "works on my machine" from "works for users". And it's the part the LLM didn't do on its own: code without caching passes all the tests, the problem is only visible in the IDE.

Control

This is what the whole thing was for.

You added a property to UserResponse and forgot the source - every call site gets a warning:

warning FMAP005: The following members of 'UserResponse' have no matching source members
and will keep their default values: CreatedAt.
Enter fullscreen mode Exit fullscreen mode

A member is intentionally not mapped - put [FusionMapperIgnore] on it. It's the only attribute in the whole API. You can suppress FMAP005 for the whole project, but then it's unclear why you need this library.

If a member is required and has no source, that's FMAP001: a compile error with interceptors, or a MappingException listing the members in runtime mode. The generator builds a real object initializer, so the language's promise is finally kept.

One rule for every ambiguity: a build diagnostic or an exception listing the members. Never a zero in a field.

Collections

A naive generator emits Select(...).ToList() for everything. But the target can be IReadOnlyList<T>, a collection with an IEnumerable<T> constructor, a collection with AddRange, or with just Add. The generator looks at the target type and picks how to create it. For interfaces it emits a collection expression [.. items].

Mapping into an existing object is a separate story. You can't just replace the collection with a new one: WPF or MAUI bindings break, and EF's change tracker sees something very different from what you expect. So it calls Clear() and then AddRange() or Add() in a loop, keeping the same collection reference. A small thing you only learn about after catching the bug.

Benchmarks

BenchmarkDotNet, Intel i9-9900KF, .NET 10.

Flat object:

Method Mean Allocated
FusionMapper 6.24 ns 48 B
Mapperly 6.56 ns 48 B
Hand-written 6.61 ns 48 B
Mapster 16.66 ns 48 B
AutoMapper 52.08 ns 48 B

Faster than hand-written code by 0.37 nanoseconds is noise, not a win. Parity with Mapperly, which is even slightly ahead on nested graphs. The only real difference is against AutoMapper on hot paths: 6-8x.

Nested object, 4 levels:

Method Mean Allocated
Hand-written 8.88 ns 48 B
Mapperly 16.68 ns 48 B
FusionMapper 17.44 ns 48 B
Mapster 39.17 ns 48 B
AutoMapper 104.04 ns 48 B

EF query projection, 1000 rows: hand-written 1.09 ms, AutoMapper 1.11 ms, Mapster 1.13 ms, FusionMapper 1.14 ms. Parity, which makes sense: the database does all the work.

Performance was never the goal. It's a consequence of the approach: if you generate the same code you'd write by hand, it runs just as fast.

The optimizations after benchmarking were done by GLM-5.3-Flash.

A real project

A mapper always looks good on a User class, so I took Jason Taylor's Clean Architecture template: .NET 10, Aspire, EF Core, MediatR. And removed AutoMapper 16 from it completely.

  • 5 files changed
  • 3 profiles deleted entirely, along with the only ForMember in the whole project
  • AddAutoMapper removed from DI, there's nothing to register anymore
  • IMapper is no longer injected into the handler

The hardest part - the EF projection:

// Before
Lists = await _context.TodoLists
    .ProjectTo<TodoListDto>(_mapper.ConfigurationProvider)
    .OrderBy(t => t.Title)
    .ToListAsync(cancellationToken);

// After
Lists = await _context.TodoLists
    .Project()
    .To<TodoListDto>()
    .OrderBy(t => t.Title)
    .ToListAsync(cancellationToken);
Enter fullscreen mode Exit fullscreen mode

Both translate to SQL. The mapping didn't go anywhere, the models are the same. The infrastructure around it is gone.

The example was converted by GLM-5.3-Flash too. A perfect task for it: mechanical, with a clear definition of done - it builds and the tests are green.

The example code is here: https://github.com/gandjustas/FusionMapper/tree/main/examples/CleanArchitecture

What's not there

  • manual configuration: no MapFrom, ConvertUsing or resolvers
  • cyclic graphs
  • runtime polymorphism: mapping uses static types
  • support for anything below .NET 8

I checked every feature with one question: is it needed in those two places from the beginning of the post? Polymorphism - no. Cycles - no. Configuration - no.

When you don't need FusionMapper

  • your project has three DTOs - write the mapping by hand or let AI generate it, that's fine
  • the mapping can't be derived from names and you need manual configuration
  • models with cyclic references or polymorphism - that's not about DTOs anymore
  • your project is on .NET Framework or .NET 6

When you do

  • lots of EF projections and handlers that populate entities
  • you want a model change to break the build, not production
  • you're moving off AutoMapper after the license change and don't want a mapper class per type pair

Wrapping up

Who did what, all in one place:

  • Qwen - runtime mapper, tests, port to a source generator, fixing tests
  • GLM-5.3-Flash - optimizations after benchmarks, converting the examples
  • Claude - final polishing
  • me - API, conventions, generator architecture with caching, decisions about what the library won't do

From the first commit to a NuGet package took 16 days. 37 commits, 201 tests. Without LLMs it would have taken many times longer. But the things the LLMs didn't do on their own - caching in the generator and saying no to extra features - are exactly what decided whether this became a library at all.

The main thing I took away: silence is the worst kind of error. Or, as a wise book put it: crash, don't trash.

The moral: if your project has three DTOs, write the mapping by hand. If it has thirty, let the build watch them, not your users in production.

GitHub logo gandjustas / FusionMapper

Modern, high-performance object mapping library for .NET

FusionMapper

NuGet NuGet Pre-release .NET C#

FusionMapper is a modern, high-performance object mapping library for .NET. It combines the developer-friendly, zero-configuration convention-based approach of AutoMapper with the compile-time code generation and zero-overhead performance of Mapperly and Mapster.

Built for .NET 8/9/10 and C# 12/13/14, it leverages Source Generators and C# Interceptors to eliminate runtime reflection, boilerplate configuration, and mapping overhead.


📦 1. Installation

Install FusionMapper via NuGet:

Package Manager:

Install-Package FusionMapper -AllowPrereleaseVersions
Enter fullscreen mode Exit fullscreen mode

.NET CLI:

dotnet add package FusionMapper --prerelease 
Enter fullscreen mode Exit fullscreen mode

Prerequisites: FusionMapper uses Source Generators and C# Interceptors. Ensure your project targets at least .NET 8.0 and uses C# 12 or higher. Interceptors are fully utilized on .NET 9+.


🚀 2. Basic Usage

FusionMapper requires zero configuration. No profiles, no CreateMap, no manual setup. Just use the fluent extension methods.

Create a new object

var source = new User { FirstName = "Alice", LastName = "Smith", Age = 30 }
…
Enter fullscreen mode Exit fullscreen mode
dotnet add package FusionMapper
Enter fullscreen mode Exit fullscreen mode

If you find a case where the conventions broke silently, that's a bug by definition. Open an issue on GitHub.

Top comments (0)