Domain Services, Application Services, and the Anemic Domain Model
Behavior goes first to the entity or value object that owns it; a stateless domain calculation that naturally belongs to none of them becomes a domain service, while loading, saving, and the transaction stay in a thin application service. We move Bookstore's copied shipping rule out of anemic services into Order and a ShippingFeeCalculator, with real output, and cover policies, factories, and the trap of injecting repositories into domain services.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
In Bookstore's layered starting point, the ordering code in the Bookstore.Business project looks like this. Order is nothing but properties; CheckoutService does the math at checkout, and OrderAdminService does it when customer support removes a book from an order. The rule is simple: domestic orders with a subtotal of 50 EUR or more ship free, otherwise 4.90 EUR; international orders pay 12.90 EUR.
namespace Bookstore.Business;
public sealed class Order
{
public Guid Id { get; set; }
public string CustomerEmail { get; set; } = "";
public string Country { get; set; } = "TR";
public int Status { get; set; } // 0 = Placed, 1 = Shipped, 2 = Cancelled
public List<OrderItem> Items { get; set; } = [];
public decimal ShippingFee { get; set; }
public decimal Total { get; set; }
}
public sealed class OrderItem
{
public Guid BookId { get; set; }
public string Title { get; set; } = "";
public decimal UnitPrice { get; set; }
public int Quantity { get; set; }
}
public sealed class CheckoutService
{
public Order Checkout(string email, string country, List<OrderItem> items)
{
Order order = new() { Id = Guid.CreateVersion7(), CustomerEmail = email, Country = country, Items = items };
decimal subtotal = order.Items.Sum(i => i.UnitPrice * i.Quantity);
decimal baseFee = country == "TR" ? 4.90m : 12.90m;
order.ShippingFee = country == "TR" && subtotal >= 50m ? 0m : baseFee;
order.Total = subtotal + order.ShippingFee;
return order;
}
}
public sealed class OrderAdminService
{
public void RemoveItem(Order order, Guid bookId)
{
if (order.Status == 1)
throw new InvalidOperationException("Order already shipped.");
order.Items.RemoveAll(i => i.BookId == bookId);
decimal subtotal = order.Items.Sum(i => i.UnitPrice * i.Quantity);
order.ShippingFee = subtotal > 50m ? 0m : 4.90m;
order.Total = subtotal + order.ShippingFee;
}
}
Ada orders three books to Izmir: Dune for 30, Emma for 20, The Hobbit for 15 EUR. The next day she calls to say she doesn't want The Hobbit, and customer support removes it from the order. What's left is a 50 EUR basket. What shipping fee ends up on Ada's invoice? What would she have paid had she ordered the same two books from the start? And if someone cancels the order with order.Status = 2, what happens to Total?
One Rule in Two Services, Two Different Answers
I ran the scenario in a console app:
// Anemic: the rule is copied into two services
Legacy.Order legacy = new Legacy.CheckoutService().Checkout("ada@example.com", "TR",
[
new() { BookId = dune, Title = "Dune", UnitPrice = 30m, Quantity = 1 },
new() { BookId = emma, Title = "Emma", UnitPrice = 20m, Quantity = 1 },
new() { BookId = hobbit, Title = "The Hobbit", UnitPrice = 15m, Quantity = 1 },
]);
Console.WriteLine($"Checkout: shipping {F(legacy.ShippingFee)}, total {F(legacy.Total)}");
new Legacy.OrderAdminService().RemoveItem(legacy, hobbit);
Console.WriteLine($"Admin remove: shipping {F(legacy.ShippingFee)}, total {F(legacy.Total)}");
Legacy.Order fresh = new Legacy.CheckoutService().Checkout("ada@example.com", "TR",
[
new() { BookId = dune, Title = "Dune", UnitPrice = 30m, Quantity = 1 },
new() { BookId = emma, Title = "Emma", UnitPrice = 20m, Quantity = 1 },
]);
Console.WriteLine($"Same basket: shipping {F(fresh.ShippingFee)}, total {F(fresh.Total)}");
legacy.Status = 2;
Console.WriteLine($"Cancelled via setter: status {legacy.Status}, total still {F(legacy.Total)}");
Checkout: shipping 0.00, total 65.00
Admin remove: shipping 4.90, total 54.90
Same basket: shipping 0.00, total 50.00
Cancelled via setter: status 2, total still 54.90
The same basket gets two different prices. The 50 EUR basket that went through checkout ships free; the identical basket that passed through customer support pays 4.90 EUR. The cause is a single character: when OrderAdminService copied the rule, it wrote > instead of >=. It also forgot the international rate; had Ada lived in Berlin, removing a book would have dropped her shipping from 12.90 to 4.90. The last line is a separate problem: cancelling is just writing a number into a setter. The total doesn't change, no rule runs, and the fact that 2 means "cancelled" lives only in a comment.
None of these is a big bug on its own. The real problem is that the answer to "how is the shipping fee calculated?" is not in the model. Someone reading the Order class cannot see the rule; to find it they have to read the services one by one, and every new screen (returns, campaigns, bulk import) copies the rule one more time. That is exactly the question the tactical side of DDD (Domain-Driven Design) answers: where should behavior live?
Fowler's Critique of the Anemic Domain Model
Martin Fowler named this structure the Anemic Domain Model in a short bliki entry in 2003. The description is familiar: the class names come from the domain and there are relationships between them, but the classes are almost empty; the behavior lives in services. Fowler's objection has two layers. First, it runs against the basic idea of object-oriented design, which is to put data and the behavior that works on it together. Second, and more practically, you pay the full cost of a domain model (mapping, an ORM (Object-Relational Mapping), class hierarchies) without getting the benefit, because the services have really turned into the Transaction Scripts of PoEAA (Patterns of Enterprise Application Architecture, Martin Fowler, 2002).
There is an often-skipped sentence in that entry: Fowler is not against a service layer. In the definition he quotes from Evans's book, the application layer stays thin, holds no business rules or business knowledge, and coordinates work by delegating to domain objects. So the question is not "should there be a service" but "what goes into the service". The general introduction to the anemic model as an anti-pattern, including how an import job punches through the rule, is in the Design Patterns series post "Anti-Patterns: God Object, Service Locator, Singleton Abuse, Anemic Domain Model and Patternitis"; here we separate three kinds of service from a DDD point of view and put each behavior in the right place.
Evans's Three Services: Application, Domain, and Infrastructure
In Domain-Driven Design (2003), Eric Evans points out that the word "service" is used for three different things and separates them by layer. The book's example is a bank funds transfer: the part that digests input and reports the result is an application service, the rule that moves money between two accounts is a domain service, and the part that sends the email is an infrastructure service. Adapted to Bookstore, the table looks like this:
| Kind | What it does | What it doesn't know | Bookstore example |
|---|---|---|---|
| Application service | Loads the aggregate, delegates to the domain, saves; transaction and authorization boundary | Business rules | RemoveOrderItemHandler |
| Domain service | A domain calculation that naturally belongs to no entity or value object | Database, HTTP (Hypertext Transfer Protocol), transactions | ShippingFeeCalculator |
| Infrastructure service | Technical conversation with the outside world | Business rules | Email-sending adapter, client for the carrier's API (Application Programming Interface) |
Evans lists three characteristics of a good domain service: the operation relates to a domain concept but is not a natural part of an entity or value object; its interface is defined in terms of other elements of the domain model (here OrderItem, Address, Money); and the operation is stateless. The third does not mean "has no fields"; it means there is no conversational state carried between calls. Taking an immutable configuration through the constructor doesn't break it.
The order matters: first you look for the entity or value object that owns the behavior, and only if you can't find one do you reach for a domain service. Do it the other way around and put everything in domain services, and you have rebuilt the anemic model under a new name.
Moving Behavior into the Entity
I did the move in three small steps and reran the opening scenario after each one. The first step puts computed values where they are computed: Subtotal and Total are no longer fields with setters but properties derived from the items; no service can update them wrongly. The second step hands the code that makes the change to its owner: RemoveItem and the "items can't be removed once shipped" rule moved into Order, and the item list is exposed read-only. The third step separates the calculation that has no owner; we'll get to it in the next section. The result:
using Bookstore.SharedKernel;
namespace Bookstore.Ordering;
public enum OrderStatus { Placed, PaymentPending, Paid, Shipped, Delivered, Cancelled }
public sealed record OrderItem(BookId BookId, string Title, Money UnitPrice, int Quantity)
{
public Money LineTotal => UnitPrice * Quantity;
}
public sealed record Address(string Street, string City, string PostalCode, string Country);
public sealed class DomainException(string code, string message) : Exception(message)
{
public string Code { get; } = code;
}
public sealed class Order
{
private readonly List<OrderItem> _items;
private Order(OrderId id, CustomerId customerId, Address shippingAddress, List<OrderItem> items)
{
Id = id;
CustomerId = customerId;
ShippingAddress = shippingAddress;
_items = items;
}
public OrderId Id { get; }
public CustomerId CustomerId { get; }
public Address ShippingAddress { get; }
public OrderStatus Status { get; private set; } = OrderStatus.Placed;
public IReadOnlyList<OrderItem> Items => _items;
public Money ShippingFee { get; private set; }
public Money Subtotal => _items.Aggregate(Money.Zero(_items[0].UnitPrice.Currency), (sum, i) => sum + i.LineTotal);
public Money Total => Subtotal + ShippingFee;
// Factory: an invalid Order is never created (post 08)
public static Order Place(CustomerId customerId, Address shippingAddress,
IEnumerable<OrderItem> items, ShippingFeeCalculator shipping)
{
List<OrderItem> list = [.. items];
if (list.Count == 0)
throw new DomainException("order.empty", "An order needs at least one item.");
Order order = new(OrderId.New(), customerId, shippingAddress, list);
order.ShippingFee = shipping.Calculate(order.Items, shippingAddress);
return order;
}
// The domain service arrives as a parameter; Order does not keep it
public void RemoveItem(BookId bookId, ShippingFeeCalculator shipping)
{
if (Status is not (OrderStatus.Placed or OrderStatus.PaymentPending))
throw new DomainException("order.not-editable", $"Order is {Status}; items cannot be removed.");
if (_items.Count == 1)
throw new DomainException("order.last-item", "Cannot remove the last item; cancel the order instead.");
_items.RemoveAll(i => i.BookId == bookId);
ShippingFee = shipping.Calculate(_items, ShippingAddress);
}
public void MarkShipped()
{
if (Status is OrderStatus.Cancelled)
throw new DomainException("order.cancelled", "A cancelled order cannot be shipped.");
Status = OrderStatus.Shipped;
}
}
Status is no longer a number but the OrderStatus enum, with a private setter; the state changes only through meaningful methods such as MarkShipped. The item list is an IReadOnlyList<OrderItem>; nobody outside can call Add or RemoveAll. Removing the last item is forbidden too: if the customer wants nothing, that's a cancellation, not an "empty order". Rule violations throw a DomainException with a code, following the error model from post 08 of the series. This rule existed nowhere in the anemic model; once the behavior moved into the entity, we had to ask "in which state may this method be called?", and the rule surfaced on its own. We covered protecting invariants with constructors, factories, and guards in post 08 of this series and the aggregate boundary in post 09.
Behavior That Belongs to No Entity: ShippingFeeCalculator
We could have written the shipping fee as a method inside Order. We didn't, because most of the calculation's inputs don't belong to the order. The rate table comes from the contract with the carrier and can change every quarter. The "free shipping" threshold is marketing's decision and changes during campaigns. The zone (domestic or international) comes from the address. Order only knows its items. Put the calculation in Order and the order has to learn the details of the carrier contract and the campaign calendar; putting it in Address makes even less sense. This is a textbook case of Evans's first criterion:
using Bookstore.SharedKernel;
namespace Bookstore.Ordering;
public enum ShippingZone { Domestic, International }
// The carrier contract: a rate table that can change every quarter (value object)
public sealed record ShippingRateTable(Money DomesticBase, Money InternationalBase, Money PerExtraBook)
{
public Money BaseFor(ShippingZone zone) => zone == ShippingZone.Domestic ? DomesticBase : InternationalBase;
}
// Policy: the "free shipping" rule changes with the campaign
public interface IFreeShippingPolicy
{
bool Qualifies(Money subtotal, ShippingZone zone);
}
public sealed record ThresholdFreeShipping(Money Threshold) : IFreeShippingPolicy
{
public bool Qualifies(Money subtotal, ShippingZone zone) =>
zone == ShippingZone.Domestic && subtotal.IsAtLeast(Threshold);
}
public sealed class NoFreeShipping : IFreeShippingPolicy
{
public bool Qualifies(Money subtotal, ShippingZone zone) => false;
}
// Domain service: the calculation belongs neither to Order nor to the address; no state, no I/O
public sealed class ShippingFeeCalculator(ShippingRateTable rates, IFreeShippingPolicy freeShipping)
{
public Money Calculate(IReadOnlyCollection<OrderItem> items, Address destination)
{
ShippingZone zone = destination.Country == "TR" ? ShippingZone.Domestic : ShippingZone.International;
Money subtotal = items.Aggregate(Money.Zero(rates.DomesticBase.Currency), (sum, i) => sum + i.LineTotal);
if (freeShipping.Qualifies(subtotal, zone))
return Money.Zero(subtotal.Currency);
int books = items.Sum(i => i.Quantity);
return rates.BaseFor(zone) + rates.PerExtraBook * (books - 1);
}
}
ShippingFeeCalculator touches no database, no HTTP, no clock. It takes the rate table as a value object and the campaign rule as a policy through its constructor, and always gives the same answer for the same input. That's why its test needs a one-line setup; no mocks, no database:
using Bookstore.Ordering;
using Bookstore.SharedKernel;
namespace Bookstore.Ordering.Tests;
public sealed class ShippingFeeCalculatorTests
{
private static readonly ShippingRateTable Rates =
new(new Money(4.90m, "EUR"), new Money(12.90m, "EUR"), new Money(0.50m, "EUR"));
private static readonly Address Izmir = new("Kordon 12", "Izmir", "35220", "TR");
private static readonly Address Berlin = new("Unter den Linden 5", "Berlin", "10115", "DE");
private static OrderItem Book(decimal price, int quantity = 1) =>
new(BookId.New(), "Dune", new Money(price, "EUR"), quantity);
[Theory]
[InlineData(49.99, 4.90)]
[InlineData(50.00, 0.00)]
public void Domestic_threshold_is_inclusive(decimal price, decimal expectedFee)
{
ShippingFeeCalculator calculator = new(Rates, new ThresholdFreeShipping(new Money(50m, "EUR")));
Money fee = calculator.Calculate([Book(price)], Izmir);
Assert.Equal(new Money(expectedFee, "EUR"), fee);
}
[Fact]
public void International_orders_pay_base_plus_extra_books()
{
ShippingFeeCalculator calculator = new(Rates, new ThresholdFreeShipping(new Money(50m, "EUR")));
Money fee = calculator.Calculate([Book(30m, 3)], Berlin);
Assert.Equal(new Money(13.90m, "EUR"), fee);
}
[Fact]
public void Campaign_policy_can_turn_free_shipping_off()
{
ShippingFeeCalculator calculator = new(Rates, new NoFreeShipping());
Money fee = calculator.Calculate([Book(80m)], Izmir);
Assert.Equal(new Money(4.90m, "EUR"), fee);
}
}
Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4, Duration: 8 ms - Bookstore.P11.Tests.dll (net10.0)
The >/>= mix-up from the opening is now documented by a test name: the threshold is inclusive. Because the rule is written once in code, the customer support screen, checkout, and the returns flow that will come later are all forced to give the same answer.
You'll see the same shape in .NET's own libraries. IPasswordHasher<TUser> in ASP.NET Core Identity, with its HashPassword(TUser user, string password) and VerifyHashedPassword(...) methods, offers an operation that works on a user but doesn't belong to the user class; the hash algorithm and iteration count are not IdentityUser's responsibility. TimeZoneInfo.ConvertTime(DateTimeOffset, TimeZoneInfo) is also a stateless operation that works between two values and belongs to neither. Both answer the same question the same way: this behavior has no natural owner, but it has a name in the domain language.
Policy and Factory Objects
IFreeShippingPolicy is what Evans calls a Policy: the domain-language name for the Strategy pattern from the GoF (Gang of Four: Gamma, Helm, Johnson, and Vlissides, authors of the 1994 Design Patterns book) catalog. If marketing says "free domestic shipping over 30 EUR" for the back-to-school campaign, a new ThresholdFreeShipping(new Money(30m, "EUR")) instance is enough; if the campaign has to be switched off temporarily for a cost calculation, there's NoFreeShipping. The skeleton of the calculation stays the same while the changing part of the rule plugs in and out. The various .NET forms of Strategy (keyed services, IEnumerable<IStrategy>, Func) are in the Design Patterns series post "Strategy Pattern in Depth"; the most familiar policy object in the BCL (Base Class Library) is the StringComparer.OrdinalIgnoreCase you pass to the constructor of a Dictionary<string, T>: the dictionary's "when are two keys the same" rule is plugged in from outside.
On the factory side, Order.Place is a static factory method. Evans recommends that the knowledge of creating a complex aggregate be gathered in one place rather than scattered across the client and the aggregate's constructor; when the creation rule belongs to the aggregate itself, the most natural place is a factory method on the root. If creation gathers information from more than one aggregate (cart, customer, campaign), a separate factory class is worth considering; but it should remain a domain concept and not call repositories.
Note that Order.Place and RemoveItem take the domain service as a parameter. In Implementing Domain-Driven Design (2013), Vaughn Vernon considers injecting a repository or a domain service into an aggregate harmful and recommends that the caller of the command method prepare the dependency and pass it in. That's the case here: the entity doesn't keep the service in a field and doesn't know the DI (dependency injection) container; the caller hands it over at the moment it's needed. The alternative is for the application service to calculate the fee and call order.SetShippingFee(fee). That looks less "clever" but brings back a public setter: anyone can write any fee, and the rule escapes into the service again. With the parameter technique, the rule "when items change, shipping is recalculated" stays in Order and can't be forgotten.
Application Service: Load, Delegate, Save
What remains is the thin layer that connects the use case to the outside world:
using Bookstore.Ordering;
namespace Bookstore.Ordering.Application;
public interface IOrderRepository
{
Task<Order?> FindAsync(OrderId id, CancellationToken ct);
void Add(Order order);
}
public interface IShippingTermsProvider
{
Task<(ShippingRateTable Rates, IFreeShippingPolicy FreeShipping)> GetCurrentAsync(CancellationToken ct);
}
public interface IUnitOfWork
{
Task SaveChangesAsync(CancellationToken ct);
}
public sealed record RemoveOrderItem(OrderId OrderId, BookId BookId);
// Application service: load, delegate to the domain, save. No business rules.
public sealed class RemoveOrderItemHandler(
IOrderRepository orders, IShippingTermsProvider shippingTerms, IUnitOfWork unitOfWork)
{
public async Task HandleAsync(RemoveOrderItem command, CancellationToken ct)
{
Order order = await orders.FindAsync(command.OrderId, ct)
?? throw new KeyNotFoundException($"Order {command.OrderId.Value} not found.");
(ShippingRateTable rates, IFreeShippingPolicy freeShipping) = await shippingTerms.GetCurrentAsync(ct);
order.RemoveItem(command.BookId, new ShippingFeeCalculator(rates, freeShipping));
await unitOfWork.SaveChangesAsync(ct);
}
}
There isn't a single if in the handler. It loads the order, gets the current shipping terms through a port (without knowing whether they come from the database or configuration), delegates the work to Order, and saves. The transaction boundary is here: one use case, one SaveChangesAsync. The handler shape itself, where validation happens, and decorators without MediatR are covered in the Clean, Onion, and Hexagonal Architecture series post "The Application Layer and Use Cases: Handlers, Validation, and Transaction Boundaries Without MediatR"; the only thing DDD adds to that picture is that you shouldn't look for business rules inside the handler.
The opening scenario running on the rich model:
ShippingRateTable rates = new(new Money(4.90m, "EUR"), new Money(12.90m, "EUR"), new Money(0.50m, "EUR"));
IFreeShippingPolicy standard = new ThresholdFreeShipping(new Money(50m, "EUR"));
InMemoryOrders store = new();
FixedShippingTerms terms = new(rates, standard);
Order order = Order.Place(CustomerId.New(), new Address("Kordon 12", "Izmir", "35220", "TR"),
[
new OrderItem(new BookId(dune), "Dune", new Money(30m, "EUR"), 1),
new OrderItem(new BookId(emma), "Emma", new Money(20m, "EUR"), 1),
new OrderItem(new BookId(hobbit), "The Hobbit", new Money(15m, "EUR"), 1),
], new ShippingFeeCalculator(rates, standard));
store.Add(order);
Console.WriteLine($"Placed: shipping {order.ShippingFee}, total {order.Total}");
RemoveOrderItemHandler handler = new(store, terms, store);
await handler.HandleAsync(new RemoveOrderItem(order.Id, new BookId(hobbit)), CancellationToken.None);
Console.WriteLine($"Item removed: shipping {order.ShippingFee}, total {order.Total}, saves {store.Saves}");
Order abroad = Order.Place(CustomerId.New(), new Address("Unter den Linden 5", "Berlin", "10115", "DE"),
[new OrderItem(new BookId(dune), "Dune", new Money(30m, "EUR"), 3)], new ShippingFeeCalculator(rates, standard));
Console.WriteLine($"International: shipping {abroad.ShippingFee}, total {abroad.Total}");
order.MarkShipped();
try
{
await handler.HandleAsync(new RemoveOrderItem(order.Id, new BookId(emma)), CancellationToken.None);
}
catch (DomainException ex)
{
Console.WriteLine($"After shipping: {ex.Message} (saves {store.Saves})");
}
Placed: shipping 0.00 EUR, total 65.00 EUR
Item removed: shipping 0.00 EUR, total 50.00 EUR, saves 1
International: shipping 13.90 EUR, total 103.90 EUR
After shipping: Order is Shipped; items cannot be removed. (saves 1)
When The Hobbit is removed, the basket drops to 50 EUR and shipping stays free, because the same calculation as checkout runs. Three copies of Dune to Berlin pay 12.90 + 2 × 0.50 = 13.90 EUR shipping. The attempt to remove a book from a shipped order is stopped inside Order, and SaveChangesAsync isn't called a second time; the counter stays at 1.
Common Pitfalls
Injecting a repository and an HttpClient into a domain service
The most common mistake is wiring the domain service to the outside world because "it's a service anyway":
using System.Net.Http.Json;
using Bookstore.SharedKernel;
namespace Bookstore.Ordering.Bad;
public interface IShippingRateRepository
{
Task<ShippingRateTable> GetCurrentAsync(CancellationToken ct);
}
// Trap: a repository and an HttpClient have crept into the domain service
public sealed class ShippingFeeCalculator(IShippingRateRepository rates, HttpClient carrierApi)
{
public async Task<Money> CalculateAsync(IReadOnlyCollection<OrderItem> items, Address destination, CancellationToken ct)
{
ShippingRateTable table = await rates.GetCurrentAsync(ct);
using HttpResponseMessage response = await carrierApi.GetAsync($"surcharges/{destination.PostalCode}", ct);
decimal surcharge = await response.Content.ReadFromJsonAsync<decimal>(ct);
int books = items.Sum(i => i.Quantity);
return table.DomesticBase + table.PerExtraBook * (books - 1) + new Money(surcharge, "EUR");
}
}
It compiles and it runs, but the price is steep. The calculation is now async; Order.RemoveItem, which calls it, has to become async and carry a CancellationToken, which means I/O has entered the entity. The answer for the same basket depends on the state of the network; testing it needs a fake repository and a fake HttpMessageHandler. When the carrier API slows down, so does the order-editing screen. The right way is to gather the data in the application service and hand it to the domain service as values; the code that calls the external API stays an infrastructure adapter.
Writing business rules in the application service
If you see a line like if (order.Items.Sum(...) >= 50) in a handler, the story of the two services from the opening has started again. A practical test: if you had to make the same decision from a console command or a background job, would you have to copy this line? If so, the rule is in the wrong place.
Moving everything into domain services
Methods like OrderDomainService.Cancel(order) and OrderDomainService.AddItem(order, ...) are the anemic model all over again; only the service's namespace changed. Wherever a behavior changes the state of a single entity, the method belongs on that entity. A domain service is the exception for calculations that truly have no owner, not the rule.
Manager, Helper, Util
Names like OrderManager or PricingHelper are a sign that what the class does is unclear. A domain service's name comes from the domain language and describes a job the domain expert would recognize: ShippingFeeCalculator, "the shipping fee calculation". If you can't name it, you probably haven't found the concept yet.
When Not to Use It
All this separation pays off where rules actually exist. For an admin screen that adds book categories, a reporting query, or a form that really does nothing but CRUD (Create, Read, Update, Delete), a Transaction Script is enough and an honest choice; Fowler recommends it for simple logic in PoEAA as well. The anemic model is a problem when it looks like a model but can't carry rules. Building a rich model where there are no rules at all is a cost in itself: in Bookstore, Catalog's category management is such a place, Ordering is not. We answered the question of which areas deserve this investment in post 03 of this series, with the core, supporting, and generic subdomain split.
A Small Experiment
★☆☆ Add a scenario to ShippingFeeCalculatorTests where the item list contains two different currencies. Which type throws, with which message? Why is it better for this check to live in Money rather than in ShippingFeeCalculator?
★★☆ Marketing brings a new rule: "During the back-to-school campaign (September 1–15), the domestic free shipping threshold is 30 EUR." Write it as an IFreeShippingPolicy implementation. Where will you get the date from? Instead of DateTime.UtcNow, who provides TimeProvider, and where?
★★★ You have to keep the rule behind the surcharge call in BadCalculator.cs: the carrier charges extra for some postal codes. Write down the pros and cons of loading the surcharge list up front in the application service and adding it to ShippingRateTable, versus leaving it behind an infrastructure adapter.
The shipping fee is now calculated in one place, but releasing stock when an order is cancelled, the confirmation email, and notifying the carrier still depend on someone remembering to do them. In the next post we'll raise these from inside the aggregates as domain events and see how to dispatch them in Entity Framework Core (EF Core) 10's SaveChanges.