TL;DR
- AutoMapper 14 now fails builds with a high-severity advisory (GHSA-rvv3-g6hj-g44x), the patched versions need a license, and MediatR 12.5.0 is the end of its free line too.
- An inventory script showed shallow usage, so this team replaced both instead of licensing: Mapperly plus a few hand-written maps, and a small
ISender facade over plain handlers. - Every pull request stayed shippable: parity tests compare old and new mapping output, pipeline behaviors become Scrutor decorators, and one test proves every request has a handler.
Level: Advanced · Prerequisites: AutoMapper or MediatR in an ASP.NET Core API; AutoMapper vs Mapster vs Manual Mapping helps · Reading time: 13 min
This is an illustrative case study. It combines situations that are common in production .NET systems. The company, people, and details are invented, and no numbers are presented as real measurements unless they come from the demo code in this article.
The pipeline went red on a Tuesday morning, on a pull request that only fixed a typo:
1
| warning NU1903: Package 'AutoMapper' 14.0.0 has a known high severity vulnerability, https://github.com/advisories/GHSA-rvv3-g6hj-g44x
|
The build treats warnings as errors, so nothing could merge. The team had pinned AutoMapper 14.0.0 and MediatR 12.5.0 a year earlier, on purpose, when both libraries moved to commercial licensing. “Stay on the last free version” had been the plan. The advisory turned that plan into a decision with a deadline.
The situation#
The team maintains Store.Api for a mid-size Turkish e-commerce company: ASP.NET Core controllers, EF Core on SQL Server, and a classic MediatR layout. Controllers call IMediator.Send, handlers implement IRequestHandler<,>, a LoggingBehavior wraps every request, and handlers use IMapper.Map or ProjectTo to turn entities into DTOs (data transfer objects).
The facts on the table, checked against the vendor’s site, the GitHub advisory, and nuget.org:
- AutoMapper 15.0.0 and later, and MediatR 13.0.0 and later, are published by Lucky Penny Software under a dual license: the Reciprocal Public License 1.5 or a commercial license. For a closed-source company product, that means a license key. Earlier versions stay under their original licenses: AutoMapper 14.0.0 is MIT, and MediatR 12.5.0 was the last Apache 2.0 release.
- A free Community license exists for organizations under 5 million US dollars in annual gross revenue that have never received more than 10 million US dollars in outside capital. Government and quasi-government agencies do not qualify, and neither do universities using the libraries for institutional software. Paid tiers are flat subscriptions by team size, not per seat. The team’s company was above the revenue limit. The vendor sets these terms and can change them, so they need a fresh check before any decision.
- GHSA-rvv3-g6hj-g44x (CVE-2026-32933) is a high-severity denial of service in AutoMapper. Mapping a deeply nested object graph recurses without a default depth limit, and the resulting
StackOverflowException ends the whole process. Affected versions are everything before 15.1.1, and 16.0.0 up to (not including) 16.1.1. The patched versions, 15.1.1 and 16.1.1, are both in the commercially licensed line. Staying on 14.0.0 now has a security cost.
Symptoms#
- Every build failed on NU1903 during restore.
- The security team opened a ticket: no known high-severity vulnerability in production without a dated exception.
- The obvious fix, upgrading to 16.1.1 or later, needed a license key and a purchase approval.
- MediatR was not affected, but 12.5.0 is the end of its free line too.
Investigation#
Step 1: is the advisory reachable? The team looked for maps over object graphs that an attacker could make deep. Request bodies go through System.Text.Json first, which rejects JSON nested deeper than 64 levels by default (JsonSerializerOptions.MaxDepth). Most maps were flat DTOs. The risk was low but not zero, and the scanner does not read code. The security team granted a 60-day exception, on the condition of a plan.
Step 2: how much do we actually use? Before arguing about licenses, the team counted. This PowerShell script counts call sites and the AutoMapper features that are hard to port, skipping bin and obj:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| $patterns = [ordered]@{
'IMapper.Map' = '\b_?mapper\.Map(<|\()'
'ProjectTo' = '\.ProjectTo<'
'CreateMap' = '\bCreateMap<'
'Mapping logic' = '\.(ForMember|ForCtorParam|ConvertUsing|AfterMap|BeforeMap|ReverseMap|IncludeBase)\b|IValueResolver<'
'IMediator.Send' = '\b_?(mediator|sender)\.Send\('
'IRequestHandler' = '\bIRequestHandler<'
'INotificationHandler' = '\bINotificationHandler<'
'IPipelineBehavior' = '\bIPipelineBehavior<'
}
$files = Get-ChildItem -Path src -Recurse -Filter *.cs | Where-Object FullName -notmatch '[\\/](bin|obj)[\\/]'
foreach ($name in $patterns.Keys) {
$hits = @($files | Select-String -Pattern $patterns[$name])
'{0,-21} {1,5} hits {2,4} files' -f $name, $hits.Count, @($hits.Path | Sort-Object -Unique).Count
}
# The files with the most call sites
$files | Select-String -Pattern '\b_?mapper\.Map(<|\()|\.ProjectTo<|\b_?(mediator|sender)\.Send\(' |
Group-Object Path | Sort-Object Count -Descending | Select-Object -First 10 Count, Name
|
The same counts in bash, for the build agents:
1
2
3
4
5
6
7
8
9
10
| count() {
hits=$(grep -rEi --include='*.cs' --exclude-dir=bin --exclude-dir=obj "$2" src | wc -l)
files=$(grep -rEil --include='*.cs' --exclude-dir=bin --exclude-dir=obj "$2" src | wc -l)
printf '%-21s %5s hits %4s files\n' "$1" "$hits" "$files"
}
count 'IMapper.Map' '\b_?mapper\.Map(<|\()'
count 'ProjectTo' '\.ProjectTo<'
count 'Mapping logic' '\.(ForMember|ForCtorParam|ConvertUsing|AfterMap|BeforeMap|ReverseMap|IncludeBase)\b|IValueResolver<'
count 'IMediator.Send' '\b_?(mediator|sender)\.Send\('
count 'IPipelineBehavior' '\bIPipelineBehavior<'
|
Both are case-insensitive, so _mapper, mapper, and Mapper all count. On this article’s sample project (the before code below), the PowerShell version prints:
1
2
3
4
5
6
7
8
| IMapper.Map 0 hits 0 files
ProjectTo 1 hits 1 files
CreateMap 2 hits 1 files
Mapping logic 1 hits 1 files
IMediator.Send 1 hits 1 files
IRequestHandler 1 hits 1 files
INotificationHandler 0 hits 0 files
IPipelineBehavior 1 hits 1 files
|
On the real codebase, the shape was what mattered. Most maps were plain property copies, the “Mapping logic” row was short, MediatR was only Send plus two behaviors, and there was a single notification.
Step 3: decide. The team wrote the options down:
| Option | Cost | Risk |
|---|
| Stay on AutoMapper 14 and MediatR 12.5 | Nothing today | Unpatched advisory, no future fixes |
| Upgrade and buy licenses | Yearly subscription, a key on every environment | Low; no code changes |
| Replace both | Developer time, spread over weeks | Mapping regressions, wiring mistakes |
Shallow usage made replacement cheap, and the team preferred code with no runtime magic. The license stayed as the fallback if the migration stalled before the exception expired. A team with hundreds of complex profiles could reasonably decide the other way.
Root cause#
Both libraries sat in every feature, so a licensing and security change in one package became a change to the whole codebase. The pin to the last free version was a postponed decision, not a solution: it stopped receiving fixes the day it was made.
The fix#
The plan had one rule: every pull request leaves the app working, and both libraries can coexist until the last feature moves.
Mapping: AutoMapper to Mapperly#
The before code, a typical profile and a query handler:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // File: Orders/OrderMappingProfile.cs (before)
using AutoMapper;
namespace Store.Api.Orders;
public sealed record OrderLineDto(int ProductId, int Quantity, decimal UnitPrice, decimal LineTotal);
public sealed record OrderDto(int Id, DateTime CreatedAtUtc, OrderStatus Status, decimal Total, List<OrderLineDto> Lines);
public sealed class OrderMappingProfile : Profile
{
public OrderMappingProfile()
{
CreateMap<Order, OrderDto>();
CreateMap<OrderLine, OrderLineDto>()
.ForCtorParam(nameof(OrderLineDto.LineTotal), o => o.MapFrom(s => s.Quantity * s.UnitPrice));
}
}
|
Mapperly (Riok.Mapperly, Apache 2.0, latest stable 4.3.1) is a source generator: it writes the mapping code at build time, with no reflection at runtime. Install it with dotnet add package Riok.Mapperly. The replacement:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // File: Orders/OrderMapper.cs (after)
using Riok.Mapperly.Abstractions;
namespace Store.Api.Orders;
[Mapper]
public static partial class OrderMapper
{
[MapperIgnoreSource(nameof(Order.UserId))]
public static partial OrderDto ToDto(this Order order);
public static partial IQueryable<OrderDto> ProjectToDto(this IQueryable<Order> query);
// Hand-written because it has logic. Mapperly uses it for every line and inlines it into projections.
private static OrderLineDto MapLine(OrderLine line) =>
new(line.ProductId, line.Quantity, line.UnitPrice, line.Quantity * line.UnitPrice);
}
|
ToDto replaces mapper.Map<OrderDto>(order), and ProjectToDto replaces ProjectTo<OrderDto>(...). EF Core translates the projection to SQL, so only the mapped columns are read.- The DTO records move unchanged to
Orders/OrderDtos.cs. [MapperIgnoreSource] documents that UserId is left out on purpose. MapLine is an ordinary method, so the one computed value is plain C#. It is expression-bodied, which lets Mapperly inline it into the IQueryable projection.- Mapperly reports unmapped members as build warnings. The team made the important one an error, which plays the role of AutoMapper’s
AssertConfigurationIsValid:
1
2
3
4
| # File: .editorconfig
[*.cs]
# RMG012: a target member has no source member
dotnet_diagnostic.RMG012.severity = error
|
For the few maps with real business logic, the team skipped Mapperly and wrote extension methods by hand. Either way, the same test guards the change.
Prove the output is identical#
During the migration both mappers exist, so the test can compare them on the same inputs. The test project references AutoMapper until the very end:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
| // File: tests/Store.Api.Tests/OrderMappingParityTests.cs
using System.Text.Json;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Store.Api.Orders;
using Xunit;
namespace Store.Api.Tests;
public sealed class OrderMappingParityTests
{
private static readonly IMapper Legacy =
new MapperConfiguration(cfg => cfg.AddProfile<OrderMappingProfile>()).CreateMapper();
private static readonly Order[] Samples =
[
new Order { Id = 1, UserId = "u1", CreatedAtUtc = new DateTime(2026, 3, 14, 9, 30, 0, DateTimeKind.Utc),
Status = OrderStatus.Pending, Total = 59.80m,
Lines = [new OrderLine { Id = 10, OrderId = 1, ProductId = 7, Quantity = 2, UnitPrice = 29.90m }] },
new Order { Id = 2, UserId = "u2", Status = OrderStatus.Cancelled, Lines = [] },
new Order { Id = 3, UserId = "u3", Status = OrderStatus.Shipped, Total = 0.30m,
Lines = [new OrderLine { ProductId = 1, Quantity = 3, UnitPrice = 0.10m },
new OrderLine { ProductId = 2, Quantity = 1, UnitPrice = 0m }] },
];
public static TheoryData<Order> Orders() => new(Samples);
[Theory]
[MemberData(nameof(Orders))]
public void In_memory_mapping_matches_AutoMapper(Order order) =>
Assert.Equal(Json(Legacy.Map<OrderDto>(order)), Json(order.ToDto()));
[Fact]
public void Projection_matches_AutoMapper()
{
var legacy = Samples.AsQueryable().ProjectTo<OrderDto>(Legacy.ConfigurationProvider).ToList();
var current = Samples.AsQueryable().ProjectToDto().ToList();
Assert.Equal(Json(legacy), Json(current));
}
private static string Json<T>(T value) => JsonSerializer.Serialize(value);
}
|
Comparing JSON sidesteps record equality (two List<T> instances are never equal) and shows the exact difference on failure. The samples cover the edges that break mappers: an empty collection, a zero price, decimal precision, and a non-default enum value. The AsQueryable() test checks the projection’s logic; SQL translation is covered by the integration tests against a real SQL Server.
Instead of editing every controller at once, the team added a small ISender with MediatR’s signature, backed by plain handler interfaces:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| // File: Infrastructure/Handlers/Handlers.cs
using System.Collections.Concurrent;
namespace Store.Api.Infrastructure.Handlers;
// Same shapes as MediatR's IRequest<T> and ISender, so call sites keep compiling.
public interface IRequest<TResult> { }
public interface IHandler<in TRequest, TResult> where TRequest : IRequest<TResult>
{
Task<TResult> HandleAsync(TRequest request, CancellationToken ct);
}
public interface ISender
{
Task<TResult> Send<TResult>(IRequest<TResult> request, CancellationToken cancellationToken = default);
}
public sealed class Sender(IServiceProvider services) : ISender
{
private static readonly ConcurrentDictionary<Type, object> Invokers = new();
public Task<TResult> Send<TResult>(IRequest<TResult> request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var invoker = (Invoker<TResult>)Invokers.GetOrAdd(request.GetType(),
type => Activator.CreateInstance(typeof(Invoker<,>).MakeGenericType(type, typeof(TResult)))!);
return invoker.Invoke(request, services, cancellationToken);
}
private abstract class Invoker<TResult>
{
public abstract Task<TResult> Invoke(IRequest<TResult> request, IServiceProvider services, CancellationToken ct);
}
private sealed class Invoker<TRequest, TResult> : Invoker<TResult> where TRequest : IRequest<TResult>
{
public override Task<TResult> Invoke(IRequest<TResult> request, IServiceProvider services, CancellationToken ct) =>
services.GetRequiredService<IHandler<TRequest, TResult>>().HandleAsync((TRequest)request, ct);
}
}
|
The invoker is built once per request type and cached; after that, each call is a typed GetRequiredService plus a direct method call. Sender is scoped, so it resolves handlers from the request’s scope.
Move one handler at a time#
Before and after for one feature. The query and handler change their interfaces, and the controller changes one type and one using:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // File: Orders/GetOrder.cs (before)
using AutoMapper;
using AutoMapper.QueryableExtensions;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Store.Api.Data;
namespace Store.Api.Orders;
public sealed record GetOrderQuery(int Id, string UserId) : IRequest<OrderDto?>;
public sealed class GetOrderHandler(AppDbContext db, IMapper mapper) : IRequestHandler<GetOrderQuery, OrderDto?>
{
public Task<OrderDto?> Handle(GetOrderQuery request, CancellationToken cancellationToken) =>
db.Orders.Where(o => o.Id == request.Id && o.UserId == request.UserId)
.ProjectTo<OrderDto>(mapper.ConfigurationProvider)
.SingleOrDefaultAsync(cancellationToken);
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // File: Orders/GetOrder.cs (after)
using Microsoft.EntityFrameworkCore;
using Store.Api.Data;
using Store.Api.Infrastructure.Handlers;
namespace Store.Api.Orders;
public sealed record GetOrderQuery(int Id, string UserId) : IRequest<OrderDto?>;
public sealed class GetOrderHandler(AppDbContext db) : IHandler<GetOrderQuery, OrderDto?>
{
public Task<OrderDto?> HandleAsync(GetOrderQuery request, CancellationToken ct) =>
db.Orders.Where(o => o.Id == request.Id && o.UserId == request.UserId)
.ProjectToDto()
.SingleOrDefaultAsync(ct);
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // File: Orders/OrdersController.cs (after)
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Store.Api.Auth;
using Store.Api.Infrastructure.Handlers; // was: using MediatR;
namespace Store.Api.Orders;
[ApiController]
[Authorize]
[Route("api/orders")]
public sealed class OrdersController(ISender sender, ICurrentUser currentUser) : ControllerBase // was: IMediator mediator
{
[HttpGet("{id:int}")]
public async Task<ActionResult<OrderDto>> GetById(int id, CancellationToken ct)
{
var order = await sender.Send(new GetOrderQuery(id, currentUser.UserId!), ct);
return order is null ? NotFound() : order;
}
}
|
One trap: MediatR’s ISender also has Send(object request, ...), which returns Task<object?>. Send a migrated query through IMediator and the compiler picks that overload. Code that needs the typed result, like GetById above, fails to compile. Code that ignores the result, or hands it to something that accepts object such as Ok(...), compiles and throws at runtime: ArgumentException: GetOrderQuery does not implement IRequest. A search for mediator.Send after each move catches it. Controllers with mixed calls kept IMediator for a sprint and took the new sender through an alias, using AppSender = Store.Api.Infrastructure.Handlers.ISender;, next to using MediatR;. Importing both namespaces instead fails with CS0104, because MediatR and the facade both define ISender and IRequest<T>. ICurrentUser is the interface from Issuing and Validating JWT Access Tokens in ASP.NET Core (The .NET Implementation Playbook). The single INotification became a direct call to its one subscriber.
Behaviors become decorators#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // File: Infrastructure/Pipeline/LoggingBehavior.cs (before)
using System.Diagnostics;
using MediatR;
namespace Store.Api.Infrastructure.Pipeline;
public sealed class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
var started = Stopwatch.GetTimestamp();
try
{
return await next();
}
finally
{
logger.LogInformation("{Request} handled in {ElapsedMs:F1} ms",
typeof(TRequest).Name, Stopwatch.GetElapsedTime(started).TotalMilliseconds);
}
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // File: Infrastructure/Handlers/LoggingDecorator.cs (after)
using System.Diagnostics;
namespace Store.Api.Infrastructure.Handlers;
public sealed class LoggingDecorator<TRequest, TResult>(
IHandler<TRequest, TResult> inner, ILogger<LoggingDecorator<TRequest, TResult>> logger)
: IHandler<TRequest, TResult> where TRequest : IRequest<TResult>
{
public async Task<TResult> HandleAsync(TRequest request, CancellationToken ct)
{
var started = Stopwatch.GetTimestamp();
try
{
return await inner.HandleAsync(request, ct);
}
finally
{
logger.LogInformation("{Request} handled in {ElapsedMs:F1} ms",
typeof(TRequest).Name, Stopwatch.GetElapsedTime(started).TotalMilliseconds);
}
}
}
|
The body is the same; await next() became await inner.HandleAsync(request, ct). Registration uses Scrutor (MIT, dotnet add package Scrutor):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| // File: Infrastructure/Handlers/HandlerRegistration.cs
using Microsoft.Extensions.DependencyInjection;
namespace Store.Api.Infrastructure.Handlers;
public static class HandlerRegistration
{
public static IServiceCollection AddHandlers(this IServiceCollection services)
{
services.AddScoped<ISender, Sender>();
services.Scan(scan => scan
.FromAssemblyOf<Sender>()
.AddClasses(classes => classes
.AssignableTo(typeof(IHandler<,>))
.Where(type => !type.IsGenericTypeDefinition))
.AsImplementedInterfaces()
.WithScopedLifetime());
// Scrutor: the LAST decorate call is the outermost wrapper.
// MediatR runs the FIRST AddOpenBehavior outermost, so list decorators in reverse.
// TryDecorate: Decorate throws DecorationException at startup while no IHandler<,> exists yet.
services.TryDecorate(typeof(IHandler<,>), typeof(LoggingDecorator<,>));
return services;
}
}
|
In Program.cs, builder.Services.AddHandlers(); sits next to the old AddMediatR(...) call until the end. The Where filter keeps the open generic decorator from registering itself as a handler, and Scrutor’s scan only picks up public classes.
Test the wiring#
MediatR failed at the first Send when a handler was missing, and ValidateOnBuild cannot catch it, because handlers are resolved at call time. The facade behaves the same, so the team moved that failure into a test:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| // File: tests/Store.Api.Tests/HandlerRegistrationTests.cs
using Microsoft.Extensions.DependencyInjection;
using Store.Api.Infrastructure.Handlers;
using Xunit;
namespace Store.Api.Tests;
public sealed class HandlerRegistrationTests
{
[Fact]
public void Every_request_has_a_registered_handler()
{
var services = new ServiceCollection().AddLogging().AddHandlers();
using var provider = services.BuildServiceProvider();
var isService = provider.GetRequiredService<IServiceProviderIsService>();
var missing =
from type in typeof(Sender).Assembly.GetTypes()
where !type.IsAbstract
from contract in type.GetInterfaces()
where contract.IsGenericType && contract.GetGenericTypeDefinition() == typeof(IRequest<>)
let handler = typeof(IHandler<,>).MakeGenericType(type, contract.GetGenericArguments()[0])
where !isService.IsService(handler)
select type.Name;
Assert.Empty(missing);
}
}
|
IServiceProviderIsService answers “is this registered?” without constructing anything, so the test does not need a database.
Removal checklist#
When the inventory script prints zeros for everything except the facade’s own sender.Send calls:
1
2
3
4
5
6
7
8
9
10
11
12
| # 1. No file imports either library (prints nothing when done)
Get-ChildItem -Path src, tests -Recurse -Filter *.cs | Where-Object FullName -notmatch '[\\/](bin|obj)[\\/]' |
Select-String -Pattern '^\s*using (AutoMapper|MediatR)\b'
# 2. Remove the packages (and AutoMapper.Extensions.Microsoft.DependencyInjection if an older project has it)
dotnet remove src/Store.Api/Store.Api.csproj package AutoMapper
dotnet remove src/Store.Api/Store.Api.csproj package MediatR
dotnet remove tests/Store.Api.Tests/Store.Api.Tests.csproj package AutoMapper
# 3. Nothing brings them back transitively, and the advisory is gone
dotnet list src/Store.Api/Store.Api.csproj package --include-transitive | Select-String 'AutoMapper|MediatR'
dotnet list src/Store.Api/Store.Api.csproj package --vulnerable --include-transitive
|
Then delete the profiles, the old behaviors, the AddAutoMapper and AddMediatR lines, and the parity tests (they need AutoMapper, and they have done their job). Check shared class libraries for MediatR.Contracts too.
Results#
- The build went green again when the last AutoMapper reference left, well inside the exception window.
- Mapping code is now readable C#: “Go to Definition” on
ToDto lands on generated code a developer can step through, and a new DTO property without a source fails the build instead of arriving as a default value. - Controllers changed a type and a
using each, which kept every pull request small enough to review in minutes.
What the team would do differently#
- Treat a pin to “the last free version” as a dated decision with an owner, not a solution.
- Keep third-party interfaces out of every file. The facade made this migration cheap; having it from day one would have made it trivial.
- Write the parity test before the first mapper changes, and keep adding a sample whenever a bug report involves a DTO.
Checklist for your project#
- Run the inventory script and look at the “Mapping logic” row before estimating anything.
- Check whether your organization qualifies for the Community license, and get a price for the paid tier, before deciding.
- Read the advisory and record whether any map runs on attacker-controlled, deeply nested data.
- Introduce an
ISender-compatible facade first, then move one feature per pull request. - Compare old and new mapping output in tests, for in-memory maps and for projections.
- Register decorators in the reverse of your MediatR behavior order.
- Finish with the removal checklist, including transitive references and
--vulnerable.
- AutoMapper vs Mapster vs Manual Mapping
- MediatR vs Mediator vs Plain Handler Classes
- CQRS Without MediatR: Commands, Queries, and a Small Dispatcher (in “The .NET Implementation Playbook”)
- DTOs and Mapping: Manual Mapping, Mapster, and AutoMapper (in “The .NET Implementation Playbook”)
Part of The .NET Field Guide. Previous: AutoMapper vs Mapster vs Manual Mapping. Next in the series: MediatR vs Mediator vs Plain Handler Classes (coming soon).
Get the next articles by email: subscribe to the newsletter.
How this article was made: drafted with AI assistance, then reviewed by Softinbit Technology before publishing.