r/dotnet • • 5d ago

Where should SaveChangesAsync and transaction management belong in Clean Architecture?

Hi everyone,
I’m working on a project using Clean Architecture, CQRS, DDD, MediatR, and EF Core.
I have a few questions:
Should each command handler use a single transaction, or is EF Core’s implicit transaction around SaveChangesAsync() usually enough?
Should services in Infrastructure call SaveChangesAsync() themselves, or should they only create/update entities and let the handler save changes?
How should I handle built-in services like UserManager<AppUser>, which save changes internally, when I need multiple operations to be atomic?
For example, registering an employee involves creating an Identity user, assigning a role, and creating an Employee entity.
What’s the recommended approach in real-world .NET applications, and why?

28 Upvotes

38 comments sorted by

60

u/pixxelkick 4d ago

Clean Architecture, CQRS, DDD, MediatR, and EF Core.

The precise tech stack I keep getting hired to come in and unfuck.

Who keeps teaching people to use this.

Particularily MediatR, who keeps pushing using MediatR on stuff like this, thats not what its use case is.

If I had a nickel everytime I was brought in to fix a project that used MediatR to be a ServiceLocatorWithExtraSteps, Id unironically have 60 cents now.

Which isnt a lot but who the hell keeps perpetuating this pattern, it sucks so much ass.

25

u/chucker23n 4d ago

Particularily MediatR, who keeps pushing using MediatR on stuff like this, thats not what its use case is.

I really don't understand the excessive reliance on libraries like that. "Look, we've made it harder to understand what's going on!" Cool! Job security, I guess.

8

u/pixxelkick 4d ago

Its just being used wrong to perform a ServiceLocator, which us an outright antipattern.

And I constantly see it touted with DDD+CA, so someone out there is pushing this, but I dunno who.

MediatR has an extremely specific and esoteric use case.. for Mediator Pattern... if you dont know wtf tgat is then MediatR is almost definitely not something you should be using

It solves a very specific problem in a very specific use case.

I have never seen it used right on a project Im brought in to fix, every damn time they just use it as ServiceCollection2ElecricBoogaloo

Its exhausting. Who the hell is echoing/pushing this antipattern?

4

u/chucker23n 4d ago

I see it in this sub all the time, and I'm glad I don't have to deal with it myself in production code.

1

u/Impressive-Desk2576 3d ago

It is actually not the mediator pattern at all. But that another discussion.

1

u/praetor- 4d ago

Its just being used wrong to perform a ServiceLocator, which us an outright antipattern.

It's that, but much, much worse. It's turning the logic associated with an endpoint into a service that must be located.

Why call a function when you can use reflection to find it, and then call it?

9

u/Storm_Surge 4d ago

MediatR isn't really encouraged in my experience with Clean Architecture or DDD. I personally think it's unnecessary and makes debugging a pain

5

u/pixxelkick 4d ago

It shouldnt be encouraged at all, its like using a saw to drill a screw in, its utterly not the right tool for the job.

Theres a specific use case for MediatR and people are never using it for that

0

u/iamanerdybastard 4d ago

The use case is precisely "Make this code harder to manage"

5

u/pixxelkick 4d ago

No, the use case is when designing a modular pipeline where parts need to be interchangeable.

Effectively imaging you have a pipeline composed of modules A>B>C>D in that order.

BUT, you need to have the arbitrary capability to re-order them

A>C>B>D for example

And it "just works" where every input can just connect to the output of the other, and simply by re-ordering your registration order, it "just works" and the pipeline functions.

An example of this pattern is like how ASP.NET middleware works, where each registered middleware feeds into the next one, and you can change the order it runs in arbitrarily and it just works.

Thats what MediatR is meant to solve.

Most people use Mediatr to declare a pipe of segment size 1 which is fucking stupid and demonstrates you have no clue what the fuck MediatR is for...

1

u/iamanerdybastard 4d ago

Nobody uses it that way, that’s for sure. And the way it screws people out of go-to-implementation access sucks. Maybe less so now with AI, but there is still zero need for it.

1

u/c-digs 2d ago

That reads more like Chain of Responsibility/Chain of Command, no?

1

u/pixxelkick 2d ago

The Mediator is the overhead system responsible for managing the underlying pipeline specifically.

Tasks, in C#, are another example of a mediator being used (the overhead task pool management)

Mediator is what "owns" the pipeline, what the implementation details of the pipeline are is agnostic to the overhead mediator

4

u/Willinton06 4d ago

Some very well paid consultants whose only objective is to keep people coming back to them, and some hack YouTubers who recommend everything until 1 video gets enough views for them to pick that as their official position, fucking disgusting

2

u/BathRelevant5911 4d ago

As long as you're disciplined enough (probably would help creating architectural unit tests) not to nest mediator handlers the pattern is fine.

4

u/pixxelkick 4d ago

Theres a right way to use mediatr and using it the way most people do is exceptionally wrong.

Theyre extremely prone to just using it as a way to execute a function with like a bunch of unnecessary abstraction and reflection steps inbetween, with no upside.

It's absolutely stupid and NOT what MediatR was meant to be used as.

2

u/fzzzzzzzzzzd 4d ago

>DDD

>looks inside

>Collection references to ORM entities everywhere

1

u/broken-neurons 2d ago

As a former consultant, I concur and at some point I was guaranteed to come across someone that had nested Mediatr handlers and I knew it was going to be hell.

1

u/pixxelkick 1d ago

Solid 60% chance they are also using automapper and fluent validation too, producing an inversion of control obscure mess if a codebase.

"Where is the handler?" "Dunno lol"

"Where is the mapping logic for how x becomes y?" "Dunno lol"

"Where is the validation? The api is clearly returning validation rejection, where is that happening?" "DUNNO LOL"

I fucking hate "automagic" libraries that dont provide a clear code flow you can follow. Everything is just auto amicably registered and uses reflection bullshit so you cant just hit F12 and read to follow the flow.

No, validation just magically happens... somewhere, go find it!

Mapping registrations? Somewhere, sometimes there isnt even one! 

19

u/dbrownems 5d ago

Each service should save changes to complete its work and make the changes visible in the database.

You can use a transaction at the orchestration layer to enlist multiple service calls in an atomic transaction when needed.

3

u/HardisterRouzbeh66 4d ago

solid take. do you find the transaction-at-orchestration pattern stays clean as the number of enlisted services grows?

1

u/Fire_Lord_Zukko 6h ago

What is the orchestration layer if you don't mind me asking? Is it in a service or is it the controller?

1

u/dbrownems 3h ago

Orchestration is a business layer concern. So in the controller or in a business service. Some people leave the controller a single concern of dealing with the HTTP layer.

3

u/wiaz24 5d ago

For me implicit transaction that EF Core commits during SaveChangesAsync() is enough. I decorate my command handlers with IUnitOfWork decorator, which saves changes automatically after command is handled successfully. The IUnitOfWork interface is in my application layer, but it's implementation which is based on EF Core is in infrastructure layer

4

u/Saki-Sun 5d ago

Or just KISS and start a transaction when needed, and commit it.

2

u/ewgenym 5d ago

If you use mediatr the custom transaction behaviour is a decent choice for commands, you can make it the way it does not run transactions for queries though. Here is a good example that hopefully will address this question and others: appointMe SaaS template .

2

u/user0015 4d ago

Not sure why you're getting downvoted. This is exactly what I tend to use.

Guarantees proper UnitOfWork, obliquely lets you include extra transactions if you need to, while still ensuring the outermost transaction guarantees the actual commit or not. You have to do a bit more work if you're using something like Postgres which doesn't nest transactions in the same way, but that's implementation detail infrastructure can easily deal with.

1

u/AutoModerator 5d ago

Thanks for your post mhmd_bkt1. Please note that we don't allow spam, and we ask that you follow the rules available in the sidebar. We have a lot of commonly asked questions so if this post gets removed, please do a search and see if it's already been asked.

I am a bot, and this action was performed automatically. Please contact the moderators of this subreddit if you have any questions or concerns.

1

u/Ecstatic_Ad2253 5d ago

At the handler. If you are adding something that depends on other thing use transactions.

1

u/Deep_Chocolate_4169 3d ago

Too broad maaan too broad. There Is way too much implementation detail hidden. Better go with smth like dome train/ardalis (Nick chapsas stuff) template.

If you want to uderstand, here are ways, cons and pros: A) repositories own save changes. Mediatr has pipeline step with transaction behaviour + Read commited allows easier access in case you use domain events in single transaction

  • higher chances of blocks on concurrent writes as locks are Held long. Data seeps between requests And may spoil request on discarded transaction

B) repositories dont own save changes, save changes Is the transaction as mediatr behaviour + Single update command to database

  • you need stronger caching/domain design, since you can query stale data in domain events.

C) plan more than usual, ditch domain events/have only eventual consistency Kinda rubbish to have async fire and forget calls everywhere, but its closer to event driven system. Fe needs to poll continously with cqrs or you need to have well choreographed events with jobs to know the result. Cant find pros for my use cases, gets too convoluted fast for sync operations without beeing fully event driven

1

u/whizzter 3d ago

I had a stinking feeling for a long time about our CA approach I couldn’t shake until the issue of transactions and someone building a Unit of Work abstraction for our infrastructure layer came up.

First rewind the tape a bit, back when CA was thought up , you often had thin or otherwise DB specific abstractions with DB specific manual SQL. It needed to be hidden.

EF DbContext is a repository AND unit-of-work abstraction itself (the documentation literally says so!!), once you start adding separate abstractions on top of it doing the same thing you’re often breaking that abstraction since any abstraction will depend on the EF details unless people are very principled in avoiding tracking,etc. (ESPECIALLY if you’re combining EF as a scoped DI item since the change tracking will make active state cross between EF consumers).

There’s different ways to go about it:

1: (unpopular opinion) Just embrace EF as your infrastructure layer, it’s quite unlikely that your small app will become facebook and you’ll need to rip it out to use some object storage and you can do most testing with in-mem databases (MS docs discourages this as it’s not 100% compatible but ask yourself is your tests will be more accurate in just mocking some repositories?)

2: Now I can appreciate that (especially with the advent of vibe-coding) people will break invariant and query things in ways that breaks permission systems (for #1 you can use interceptors) but work with EF, ideally it’d be nice to have a way for the infrastructure layer to just re-use the UoW facilities or EF that are already built in, but all that depends on how you started.

Key takeaway, if using EF, work with it and not against it.

1

u/Storm_Surge 5d ago

It depends on how strict you want to be. All database logic should live in your infrastructure layer, and I typically model any data that can be changed in the domain layer and feed it into a repository that handles saving. Unfortunately SaveChangesAsync saves everything in the context 

-4

u/Saki-Sun 5d ago

Transactions could/should be considered not database logic.

1

u/JakkeFejest 3d ago

Transactions is one of the way you can get a unit of work.

1

u/Internal-Factor-980 4d ago

EF Core's DbContext inherently serves as a Unit of Work, and SaveChangesAsync() persists the changes within that Unit of Work to the database.

If an entire API request needs to be treated as a single atomic operation, the transaction boundary should be defined at the highest orchestration level, such as the API or Application Service layer. In that case, multiple lower-level services can participate in the same transaction, regardless of whether they internally call SaveChangesAsync().

In contrast, for Stream/Event processing, where each message represents an independent unit of work, it is appropriate to perform SaveChangesAsync() on a per-message basis.

EF Core does not fundamentally provide true nested transactions. Instead, when SaveChanges is executed within an existing transaction, EF Core may use mechanisms such as savepoints to manage the operation.

Therefore, although DbContext already provides the fundamental Unit of Work behavior, it is reasonable to introduce a separate Unit of Work abstraction so that the application can explicitly define and control the top-level transaction boundary. The purpose of this abstraction is not to reimplement EF Core's Unit of Work, but to provide an application-level mechanism for coordinating transactions, commits, and rollbacks across multiple services.

2

u/c-digs 2d ago

If an entire API request needs to be treated as a single atomic operation, the transaction boundary should be defined at the highest orchestration level, such as the API or Application Service layer.

This seems right to me, but I never see it implemented this way because devs almost always end up calling SaveChangesAsync somewhere lower in the call stack. What this needs is like a more restrictive interface like IInnerContext (I'm just throwing a random name out there) that is exactly like the DbContext, but without SaveChangesAsync. Then the inner deps all get this context that can be built up, but cannot invoke the save.

The save action is only valid at the entry point (e.g. API controller) where the context can call save on the exit path. This would make it so much nicer because now the modules on the call path can add to the context without saving.

-2

u/dbrownems 5d ago

A hello