I have shipped handlers that looked thin on day one and became the only place anyone could find the business logic by month six. The class was called PlaceOrderHandler. It validated stock, applied discounts, checked credit limits, and persisted the order. The domain model was a bag of properties with no behavior.
That is not Clean Architecture. It is a transaction script with extra folders.
Handlers coordinate; they do not decide
A handler’s job is to load what the use case needs, call the domain, persist the result, and publish side effects. The moment a handler starts encoding rules — “if the customer is VIP and the cart is over 500, apply free shipping” — those rules become invisible to everything else.
The same rule shows up again in the admin API. Then in a background job that recalculates orders. Then in a test helper someone wrote because the domain could not express the constraint. Each copy drifts slightly. The handler version wins because it is closest to the HTTP request.
Domain logic wants one home. Handlers are a bad home because they are entry-point specific and easy to treat as glue code.
Anemic models are a symptom, not the disease
test Teams often blame anemic domain models on “we use EF Core” or “we do not do DDD”. The real issue is usually simpler: nobody decided where a rule lives, so it landed in the nearest async method.
Fixing that does not require aggregates everywhere or event sourcing. It requires moving one invariant at a time into a place that can be called without knowing whether the caller is a MediatR handler, a console job, or a message consumer.
// The handler should read like this
var order = Order.Place(customer, lines, clock.UtcNow);
await _orders.AddAsync(order, ct);
await _unitOfWork.SaveChangesAsync(ct);
If Place throws because credit is insufficient, the handler does not need a nested if. The rule has a name. It has a test that does not boot ASP.NET.
Where this breaks in production
The pain shows up at boundaries. A consumer receives RecalculateOrderRequested and needs the same pricing rules as the HTTP API. If those rules live in PlaceOrderHandler, the consumer either duplicates them or calls the handler like a service, which is worse.
It also shows up under change. Product asks for “same discount logic in the mobile checkout preview”. If the logic is scattered across three handlers, you estimate three places and still miss one. If it is in OrderPricing, you estimate one.
I am not arguing for a fat domain layer on day one. I am arguing against letting handlers absorb rules by default because it is faster in the current sprint.
What I do instead
New invariant? It goes in the domain type that owns the data, or a small domain service if the rule spans types. Handler gets thinner, not smarter.
Cross-cutting validation that is truly about the request shape — required fields, max string length — stays in FluentValidation or similar. That is input hygiene, not business policy.
When I review a handler and it has more than a screen of conditional logic, I ask: which of these would still be true if the caller were not HTTP? Those lines move out.
The goal is not purity. The goal is that the next person — or the next integration point — can find the rules without reading every handler in the Application layer.