There's a reflex in a lot of .NET teams: hit a small wall, and the first move is dotnet add package. It feels productive. Someone already solved this; why write it twice?
The trouble is that the framework and the language have quietly absorbed most of the reasons those packages existed. Records, pattern matching, collection expressions, LINQ, System.Text.Json, and — new in C# 14 — extension members cover an enormous amount of ground that used to justify a dependency. Reaching for a package out of habit means paying a recurring tax to avoid a one-time cost.
And in 2025, the size of that tax became very visible.
A dependency is a liability
A package isn't free just because it's free to install. Every one you add is something you now partly depend on but don't control:
- Vendor risk. The license can change, the maintainer can move on, ownership can be sold. In 2025 both AutoMapper and MediatR — two of the most-downloaded libraries in the ecosystem — announced a move to commercial licensing. MassTransit announced the same for its next major version.
- A larger attack surface. Every transitive dependency is code you ship and must trust.
- An upgrade treadmill. Breaking changes, deprecated APIs, framework-version churn — all on someone else's schedule.
- Runtime magic. Many convenience libraries trade compile-time guarantees for reflection and runtime configuration. Errors move from "won't build" to "blows up in production."
- Cognitive cost. Every new hire has to learn the library's conventions on top of the language.
None of this means never take a dependency. It means every dependency should pay rent. Let's look at one that mostly doesn't — and one that clearly does.
The cautionary tale: AutoMapper
AutoMapper's pitch was always appealing: stop hand-writing dto.Name = entity.Name a thousand times. Convention over boilerplate. But the convenience hides a real cost — it converts a compile-time problem into a runtime one.
// The mapping config lives far from the types it maps.
public class MappingProfile : Profile
{
public MappingProfile()
{
CreateMap<Person, PersonDto>()
.ForMember(d => d.FullName,
o => o.MapFrom(s => $"{s.FirstName} {s.LastName}"));
}
}
// Rename Person.Email to Person.EmailAddress and this still COMPILES.
// It breaks at runtime — or worse, silently maps null.
PersonDto dto = mapper.Map<PersonDto>(person);
The map lives in a profile, away from the types. The compiler can't see it, so a rename or a type change won't flag the broken mapping — you find out from a failing test if you're lucky, or a production incident if you're not. On top of that you pay a reflection and startup cost, you need configuration validation to catch typos, and stepping through a map in the debugger means stepping through someone else's engine.
Then the licensing changed. A foundational, often-transitive dependency suddenly carried a cost-and-compliance question — for the privilege of copying fields between two objects. For a problem this small, that's a bad trade.
The boring alternative that wins
Modern C# makes hand-written mapping pleasant. DTOs are record types, and C# 14's extension members let you hang a clean ToDto() right off the source type — no static-class noise, no dependency.
public record PersonDto(Guid Id, string FullName, string Email);
public static class PersonMappings
{
// C# 14 extension block: members hang off a Person receiver.
extension(Person person)
{
public PersonDto ToDto() => new(
person.Id,
$"{person.FirstName} {person.LastName}",
person.Email);
}
}
// Usage — reads like a method on the type itself.
PersonDto dto = person.ToDto();
List<PersonDto> dtos = [.. people.Select(p => p.ToDto())];
Now rename Email and the compiler points at every map that needs updating. The mapping is explicit — you can read exactly what goes where — there's no reflection, no startup profile, no validation step, and you can step straight into it. It's a handful of lines of code you own, instead of a licensed engine you rent.
The one place a mapper earned its keep
AutoMapper's ProjectTo had genuine value: it pushed the mapping into the SQL query so you didn't fetch whole entities just to throw most of the columns away. But EF Core does that natively with a plain Select.
// Translates to SELECT Id, FirstName, LastName, Email ...
// No entity materialization, no extra package.
List<PersonDto> dtos = await db.People
.Where(p => p.IsActive)
.Select(p => new PersonDto(
p.Id, p.FirstName + " " + p.LastName, p.Email))
.ToListAsync();
One honest nuance: you can't call p.ToDto() inside an IQueryable — EF can't translate an arbitrary method into SQL. Extension members are for in-memory mapping; Select is for the database. If you don't want to repeat the projection across queries, share it as an expression:
public static class PersonProjections
{
public static readonly Expression<Func<Person, PersonDto>> ToDto =
p => new(p.Id, p.FirstName + " " + p.LastName, p.Email);
}
// Reused, still translated to SQL:
var dtos = await db.People.Where(p => p.IsActive)
.Select(PersonProjections.ToDto)
.ToListAsync();
Two clear tools, both already in the box, both compile-time checked. No engine, no profiles, no license.
When a package is worth the risk: MassTransit
Here's the part that keeps this from being dogma. MassTransit — the de-facto .NET messaging library — announced the same commercial move as AutoMapper. Same vendor risk. So why would a careful team keep it?
Because what it does is genuinely hard, and rolling your own is a distributed-systems minefield. MassTransit gives you message contracts, retry with backoff, the outbox/inbox pattern for exactly-once-ish delivery, sagas and state machines, scheduling, dead-letter handling, a transport abstraction over RabbitMQ / Azure Service Bus / SQS, and the observability to debug all of it. A hand-rolled "simple message bus" is precisely how you end up with silent data loss and duplicate processing — bugs that cost far more than any license.
That's the whole distinction. AutoMapper replaces about twenty lines of obvious code. MassTransit replaces thousands of lines of code you would almost certainly get wrong. The vendor risk is identical; the value and the replaceability are not.
The takeaway
Reach for the standard library first, and make packages prove they're worth the liability. The best dependency is often the one you didn't add — and in modern .NET, that's far more of them than it used to be.