Full disclosure first: I wrote the library at the end of this post. The problem, though, is one I kept hitting long before I wrote anything, and I think it's worth walking through even if you never install it.
The bug
You have an order aggregate in EF Core. An Order with a Customer and a list of Lines. The client sends an OrderDto with the edited version, and you want to save it.
The obvious code looks fine:
var order = await db.Orders
.Include(o => o.Customer)
.Include(o => o.Lines)
.SingleAsync(o => o.OrderId == dto.OrderId);
mapper.Map(dto, order); // AutoMapper, Mapperly, hand-written, doesn't matter
await db.SaveChangesAsync();
Then you look at the SQL. Every line gets deleted and reinserted. The customer row too, or you get a foreign key exception, depending on how your model is configured. Identity columns roll forward on every save. Audit triggers fire for rows that didn't change. Anything else that referenced a line by its id now points at nothing.
What happened is that the mapper did exactly what it was asked to do. order.Lines = <new list of new LineEntity> is a perfectly correct assignment. EF just sees a collection of untracked objects replacing a collection of tracked ones, and delete-and-insert is the honest interpretation of that.
What you actually wanted was:
- for each incoming line, find the existing line with the same id and copy the values onto that instance
- add the lines that are new
- remove the lines the client no longer sent
- and do the same for
Customer: copy into the existing instance, don't swap it out
Why the mappers don't do this
I checked, because I assumed I was missing a setting.
AutoMapper maps into a nested existing object, but collections are replaced unless you pull in the separate AutoMapper.Collection package and configure an EqualityComparison per pair. And AutoMapper moved to a commercial license in 2025, which is what got a lot of us looking at alternatives in the first place.
Mapperly is the source generator most people have moved to, and it's very good. But nested objects on an existing-target mapping get a fresh instance (that's riok/mapperly#1311, open since 2024), and there's no keyed collection merge at all (#665, open since 2023). Those are its second and sixth most-upvoted open requests, so it's not just me.
So the usual answer is a hand-written Update method per aggregate. Which works, and which nobody keeps in sync with the DTO.
What I ended up building
I'd been working on a compile-time mapper called Mapwright for a while, mostly because I wanted the generated code to be readable, and because I wanted stale configuration to be a build error rather than something a test might catch. Version 1.3 adds the piece above. You declare an in-place copy and tell the mapper that existing targets should be merged:
[Mapper(ExistingTargets = ExistingTargetStrategy.Merge)]
public static partial class OrderMapper
{
public static partial void Update(OrderDto source, OrderEntity target);
}
And the generator writes this. This is the actual file it puts under obj/generated, not a sketch:
public static partial void Update(OrderDto source, OrderEntity target)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(target);
UpdateCore(source, target, new HashSet<object>(ReferenceEqualityComparer.Instance));
}
private static void UpdateCore(OrderDto source, OrderEntity target, HashSet<object> visited)
{
if (!visited.Add(target))
{
// Already merged during this call: a cyclic graph (a back-reference) ends here.
return;
}
target.OrderId = source.OrderId;
target.Number = source.Number;
if (source.Customer is null)
{
target.Customer = null!;
}
else if (target.Customer is null)
{
target.Customer = MapCustomerDtoToCustomerEntity(source.Customer);
}
else
{
MergeCustomerDtoIntoCustomerEntity(source.Customer, target.Customer, visited);
}
// Lines: merged by LineId — matching elements are updated in place, new ones added, and missing ones removed.
if (source.Lines is null)
{
target.Lines = null!;
}
else if (target.Lines is null)
{
target.Lines = MapwrightList_LineDto_To_LineEntity(source.Lines);
}
else
{
var existing = new Dictionary<int, LineEntity>();
foreach (var current in target.Lines)
{
existing[current.LineId] = current;
}
var seen = new HashSet<int>();
var added = new List<LineEntity>();
foreach (var item in source.Lines)
{
if (existing.TryGetValue(item.LineId, out var match))
{
MergeLineDtoIntoLineEntity(item, match, visited);
seen.Add(item.LineId);
}
else
{
added.Add(MapLineDtoToLineEntity(item));
}
}
target.Lines.RemoveAll(current => !seen.Contains(current.LineId));
foreach (var current in added)
{
target.Lines.Add(current);
}
}
}
(I've trimmed the global:: prefixes for the blog; otherwise it's verbatim.)
A few things I care about in that output:
It's the code you would have written. A dictionary of what's there, one loop, a RemoveAll. You can put a breakpoint on any line of it. If it does something you don't expect, you read it, you don't reverse-engineer an execution plan.
The key is found by convention, and guessing is a build error. LineId on LineEntity is picked up automatically (Id, Key and <TypeName>Id are the conventions). If an element type has nothing that looks like a key, you get a compiler error telling you to name one, rather than a merge that silently matched on the wrong thing. A merge that guessed a foreign key would fold every line into one, which is worse than the original bug.
Back-references don't blow the stack. Real EF entities have Line.Order pointing back at the order. The visited set means each target instance is merged once per call and the back-reference keeps pointing at the original instance.
Get-only collections work. The EF idiom public ICollection<Line> Lines { get; } = new List<Line>(); is merged into, because a merge never assigns the property. Most mappers can't map into it at all.
If you only want one collection merged, or you want a different key, or you don't want missing elements removed, that's a per-method attribute and the rest of the mapper stays as it was:
[MergeCollection(nameof(Order.Lines), Key = nameof(OrderLine.Sku), RemoveMissing = false)]
public static partial void Patch(OrderDto source, Order target);
The rest of it, briefly
The merge is the new thing, but it sits on the same ideas the rest of Mapwright is built on:
-
Verification is the build. An unmapped destination property is a warning on every keystroke, and a
[MapIgnore("RemovedLastSprint")]pointing at a property that no longer exists is an error. Ignore lists can't rot. - Nothing runs at runtime. The package is attributes only; the generator is a build-time analyzer. Native AOT and trimming aren't a compatibility exercise.
-
EF projections are real projections. An
Expression<Func<Entity, Dto>>orIQueryable<Dto>partial generates the whole shape inlined, soSelect(OrderMapper.SummaryProjection())translates to SQL because it is a hand-written projection. -
Migrating from AutoMapper doesn't mean rewriting the Profiles.
Mapwright.Migrationis an analyzer that offers "Convert to a Mapwright mapper" on everyProfileand writes the equivalent class beside it, with aTODOfor anything it couldn't translate instead of dropping it. - It targets .NET Standard 2.0, .NET 8 and .NET 10, so it works from .NET Framework 4.6.1 up, and the generator adapts the code it writes to what your target actually has.
On the benchmark in the repo it's about 10% faster than Mapperly on a 100-item collection and about 8% slower on a single object, with identical allocations. I mention that mostly so you don't expect miracles either way; the point of the tool is what the generated code looks like and what the compiler catches, not nanoseconds.
Being honest about where it stands
If you're on Mapperly and happy, stay. It has years of production hardening, a big community, and a much wider configuration surface. Mapwright is new and small; when it meets a shape nobody has tried, you're the one who finds out. The comparison page in the docs says this in more detail, and it's the first thing on the page on purpose.
Where I'd suggest trying it: you're leaving AutoMapper and want the compiler doing the checking; you have EF aggregates and the update problem above; or you've ever spent an evening figuring out what a mapper actually did.
dotnet add package Mapwright
Repo, docs and the honest comparison: github.com/lodestar-labs/Mapwright. MIT. Issues and "this shape doesn't work" reports are very welcome, that's how it gets the hardening it doesn't have yet.
Top comments (0)