Most of us have opened a legacy C# codebase and found an OrderService class that does everything. It calculates prices, applies discounts, checks stock, verifies credit limits and writes to the database, all inside a single method that nobody wants to touch. This is the classic anemic domain model problem: entities are plain data holders, and every rule about how that data should behave lives somewhere else.
An anemic model is not wrong from day one. It is often how a project starts, and it works fine for the first few features. The trouble begins when the team keeps adding rules to the same service class, because test coverage drops and every small change turns into a guessing game about what else might break.
This article walks through a practical refactor that moves business rules out of a bloated service and into the domain object itself, one step at a time. I have used this exact pattern on production codebases, and I will point out where I would push back on doing it fully, because DDD purity is not always worth the extra code.
Starting Point: The God Service Class
Here is a fairly typical OrderService.PlaceOrder method. It fetches the customer, loops over order items, checks inventory, applies a 5 percent VIP discount, calculates totals, and validates the customer’s credit limit before saving to the database.
// OrderService.cs
public void PlaceOrder(Guid customerId, IEnumerable<OrderItemDto> items)
{
var customer = _db.Customers.Find(customerId);
if (customer is null)
{
throw new ArgumentException("Customer not found");
}
var order = new Order { CustomerId = customerId };
foreach (var dto in items)
{
var inventory = _inventoryService.GetStock(dto.ProductId);
if (inventory < dto.Quantity)
{
throw new InvalidOperationException("Item out of stock");
}
var price = _pricingService.GetPrice(dto.ProductId);
var lineTotal = price * dto.Quantity;
if (customer.IsVip)
{
lineTotal *= 0.95m; // 5% discount for VIPs
}
order.Items.Add(new OrderItem
{
ProductId = dto.ProductId,
Quantity = dto.Quantity,
UnitPrice = price,
LineTotal = lineTotal
});
}
order.Total = order.Items.Sum(i => i.LineTotal);
if (customer.CreditUsed + order.Total > customer.CreditLimit)
{
throw new InvalidOperationException("Credit limit exceeded");
}
_db.Orders.Add(order);
_db.SaveChanges();
}
Run this with a VIP customer whose order pushes past the credit limit, and it throws after already looping through every item and computing the full total. That wasted computation is a minor issue. The real problem is where the logic lives, not how efficient it is.
What’s Wrong Here
Three things stand out on a closer look. Discount logic, stock validation and credit checks live inside the service instead of the objects they operate on. OrderService needs to know about pricing, inventory and EF Core just to place a single order. And every unit test needs fakes for the database, the pricing service, the inventory service, plus separate runs for VIP and non VIP customers.
The goal of the refactor is to move these rules inside the domain, so the application layer only orchestrates: fetch the customer, ask the aggregate to build itself, save it.
Guiding Principles Before Touching Code
Before changing anything, it helps to set a few ground rules. Protect invariants close to the data, so stock checks, discounts and credit limits live inside the Order aggregate rather than scattered across services. Expose intent and hide mechanics, so the application layer reads like a short story: place the order, not calculate totals, check credit, write to the database.
Refactor in slices, so every commit still compiles and passes tests, with no need for a big rewrite over a weekend. And balance purity with pragmatism. Move a rule into the domain only when the payoff in clarity, safety or testability is worth the extra code. Not every getter needs to turn into a rich behavior method.
Step One: Embed Creation and Validation Logic
The first move is to give the aggregate a static factory method that owns its own construction. Instead of application code building an Order and mutating it field by field, a single Create method becomes the only entry point, and every invariant is checked before the object exists.
// Order.cs (Factory Method)
public static Order Create(
Customer customer,
IEnumerable<(Guid productId, int quantity)> lines,
IPricingService pricingService,
IInventoryService inventoryService)
{
var order = new Order(customer.Id);
foreach (var (productId, quantity) in lines)
{
if (inventoryService.GetStock(productId) < quantity)
{
throw new InvalidOperationException("Item out of stock");
}
var unitPrice = pricingService.GetPrice(productId);
order.AddItem(productId, quantity, unitPrice, customer.IsVip);
}
order.EnsureCreditWithinLimit(customer);
return order;
}
Creation now fails fast the moment an invariant is broken, and the service layer no longer needs to know that VIP customers get a discount or that stock has to be checked before an item is added. Notice the shift here: instead of the service asking questions and then acting on the answers, we are telling the Order to create itself correctly. That is the Tell, Don’t Ask principle in practice.
Passing IPricingService and IInventoryService into a static domain method looks odd on first read, since domain objects are usually expected to stay free of infrastructure dependencies. But passing services explicitly as parameters, rather than resolving them internally, keeps the object testable and keeps the orchestration inside the model where the rule naturally belongs. I would only reach for this when the operation clearly needs external data to enforce an invariant, not as a habit for every method.
One trade-off worth flagging: coupling order creation to a live inventory check means Order.Create can now fail because a downstream service is slow or unavailable, not just because of bad input from the customer. In a system handling a high volume of concurrent orders, you may prefer to raise a domain event and reserve stock asynchronously rather than block creation on a live inventory call. Which approach is right depends on how strict a guarantee you actually need at the point of order placement.
Step Two: Guard the Aggregate’s Internal State
Next, close the back door that let application code manipulate the order’s item list directly. Expose only a read-only view of the items, and push every mutation through private methods that enforce quantity and pricing rules.
// Order.cs (excerpt)
private readonly List<OrderItem> _items = new();
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
private void AddItem(Guid productId, int quantity, decimal unitPrice, bool isVip)
{
if (quantity <= 0)
{
throw new ArgumentException("Quantity must be positive");
}
var finalPrice = isVip ? unitPrice * 0.95m : unitPrice;
_items.Add(new OrderItem(productId, quantity, finalPrice));
RecalculateTotal();
}
private void EnsureCreditWithinLimit(Customer customer)
{
if (customer.CreditUsed + Total > customer.CreditLimit)
{
throw new InvalidOperationException("Credit limit exceeded");
}
}
This buys you four things in practice. Callers can no longer mutate the internal list directly, so the aggregate’s own invariants always hold. The domain protects its own consistency instead of relying on the service layer to remember every check. Objects now genuinely combine data and behavior, which is what object orientation was meant to look like from the start. And application services get simpler, because there is nothing left in them to validate.
Step Three: Shrink the Application Layer to Pure Orchestration
With creation and mutation both owned by the aggregate, the application service shrinks down to orchestration only. It fetches the customer, hands the input to Order.Create, and saves the result.
public void PlaceOrder(Guid customerId, IEnumerable<OrderLineDto> lines)
{
var customer = _db.Customers.Find(customerId);
if (customer is null)
{
throw new ArgumentException("Customer not found");
}
var input = lines.Select(l => (l.ProductId, l.Quantity));
var order = Order.Create(customer, input, _pricingService, _inventoryService);
_db.Orders.Add(order);
_db.SaveChanges();
}
PlaceOrder drops from 44 lines with embedded business rules down to 14 lines that do nothing but fetch, delegate and save. Unit testing this method now means testing orchestration, not business rules, which is exactly the separation you want. The business rules get tested directly against the Order aggregate, with no database or EF Core in sight.
What We Gained
Before the refactor, the service owned pricing, stock, discount and credit logic all at once. Unit tests needed heavy EF Core setup along with fakes for pricing and inventory, and adding a new business rule meant touching multiple files and hoping nothing got missed.
After the refactor, the Order aggregate owns every business rule and the service only orchestrates. Domain tests run without a database, a container, or any infrastructure at all. Most new rules stay isolated to the aggregate itself, which is where the real payoff of this exercise shows up over the next few sprints.
When This Refactor Is Actually Worth It
This pattern earns its keep when your domain has real invariants worth protecting, like the credit limits, stock checks and discount eligibility in this example. It is less useful for CRUD-heavy screens where a form simply maps to a database row and there is no meaningful business rule to enforce. Forcing a rich domain model onto a plain data entry screen adds ceremony without adding safety.
Watch out for one common mistake I see teams make: moving validation into the aggregate but leaving the old service-level checks in place out of caution. You end up with the same rule enforced twice, in two different layers, and the two copies drift apart the moment one gets updated and the other does not. Delete the old check once the new one is in place and covered by tests.
If your aggregate needs to call several external services just to validate itself, the way Order.Create here depends on pricing and inventory, keep an eye on how many dependencies you are threading through the domain layer. Once a single factory method needs four or five injected services, it is worth asking whether some of that validation belongs in an application-level saga or a domain event handler instead of inside the aggregate itself.
Wrapping Up
The value in this refactor is not really about following DDD by the book. It is about reducing the blast radius of future changes, making business rules explicit and testable, and creating room for patterns like domain events and the outbox pattern later, once dispatching side effects becomes a real requirement.
You do not need to rewrite an entire service in one sitting. Pick one rule, move it into the aggregate, write a test for it, and move on to the next rule. That is how a legacy codebase slowly turns into something you can actually reason about, one slice at a time.
Leave a Reply