From Transaction Scripts to Domain Models: A Refactoring Journey
Learn how to refactor messy transaction script code into a clean domain model in .NET 10. Simple examples, diagrams, tables, and EF Core code to guide your journey.
Cooking from a recipe card vs. a trained cook
Imagine a small tea stall. In the beginning, there is one helper. Every time an order comes, the owner shouts out the full recipe: "Take a cup, add two spoons of sugar, add tea leaves, pour milk, boil for three minutes, strain, serve." The helper follows each step from start to finish. This is simple. It works. One order, one full set of steps.
Now imagine the stall grows into a busy restaurant. There are hundreds of dishes, and each has its own rules. If the cook still shouted every step for every plate, the kitchen would be chaos. Mistakes would creep in. So instead, the restaurant trains skilled cooks. Each cook knows how to make their dish correctly. You just say "one paneer butter masala," and the cook protects all the rules — the right spices, the right heat, the right order — on their own.
In software, the first style is a transaction script: one method does the whole job, step by step. The second style is a domain model: smart objects that know their own rules and protect themselves. This article is the journey from the first to the second, done slowly and safely.
What is a transaction script?
A transaction script organizes business logic as procedures. Each procedure handles one request from the outside world. It reads data, checks the rules, changes things, and saves. Everything for that one action sits together in a single method.
Here is a transaction script for placing an order in a small shop. Read it like a recipe — top to bottom.
public class OrderService
{
private readonly AppDbContext _db;
public OrderService(AppDbContext db) => _db = db;
public async Task PlaceOrderAsync(int customerId, int productId, int quantity)
{
var customer = await _db.Customers.FindAsync(customerId);
var product = await _db.Products.FindAsync(productId);
// Rule 1: customer must not be blocked
if (customer!.IsBlocked)
throw new InvalidOperationException("Blocked customers cannot order.");
// Rule 2: quantity must be positive
if (quantity <= 0)
throw new InvalidOperationException("Quantity must be at least 1.");
// Rule 3: enough stock must exist
if (product!.Stock < quantity)
throw new InvalidOperationException("Not enough stock.");
product.Stock -= quantity;
var order = new Order
{
CustomerId = customerId,
ProductId = productId,
Quantity = quantity,
TotalPrice = product.Price * quantity,
Status = "Pending"
};
_db.Orders.Add(order);
await _db.SaveChangesAsync();
}
}This is fine when the app is small. The glory of the transaction script is its simplicity. Anyone can read it once and understand the whole flow.
Where the trouble begins
The trouble starts when the app grows. New features need the same rules. Now you have PlaceOrderAsync, PlaceBulkOrderAsync, ImportOrderAsync, and a background job — and each one copies the "enough stock" and "not blocked" checks.
Soon one copy is updated but another is forgotten. A blocked customer slips through the import path. Stock goes negative in the bulk path. The rules are spread out, so no single place is the "truth."
The same rule scattered across many scripts
Steps
Place order
Has stock + blocked checks
Bulk order
Forgot the blocked check
Import order
Stock check is slightly different
Background job
No checks at all — bug!
This is the moment Martin Fowler describes in Patterns of Enterprise Application Architecture: start with a transaction script, and refactor toward a domain model when the complexity demands it. The signal is not "the calendar says so." The signal is duplicated, drifting business rules.
What is a domain model?
A domain model puts data and behavior together. Instead of a plain Order with only properties, the Order object knows how to be created correctly and how to change safely. The rules live inside the object that owns them.
In Domain-Driven Design language, this self-protecting object is often called an aggregate. It guards its own consistency. You cannot put it into a broken state from outside, because the only way to change it is through methods that check the rules first.
Anemic vs. rich
Be careful here. Many teams create classes with only get and set properties and no behavior, then keep all logic in services. That looks like a domain model but is not. Fowler calls it an anemic domain model — an anti-pattern. It has the cost of extra objects without the benefit of protected rules.
| Aspect | Transaction script | Anemic model (avoid) | Rich domain model |
|---|---|---|---|
| Data location | In entities | In entities | In entities |
| Logic location | In service methods | Still in services | Inside the entities |
| Rule duplication | High | High | Low (one place) |
| Can reach a broken state? | Yes, easily | Yes, easily | No, protected |
| Best for | Simple logic | Nothing — skip it | Complex business rules |
The refactoring journey, step by step
We will not rewrite everything at once. We refactor in small, safe steps, keeping the app working the whole time.
A safe refactoring path
Steps
Tests
Cover current behavior first
Push rules
Move one check into the entity
Factory
One safe way to create an Order
Thin service
Service just coordinates
Step 1: Protect creation with a factory method
The first move is to stop creating Order with a public object initializer. We give it a private constructor and a Create method that checks the rules. Now there is exactly one safe way to make an order.
public class Order
{
public int Id { get; private set; }
public int CustomerId { get; private set; }
public int ProductId { get; private set; }
public int Quantity { get; private set; }
public decimal TotalPrice { get; private set; }
public OrderStatus Status { get; private set; }
private Order() { } // EF Core needs this
public static Order Create(Customer customer, Product product, int quantity)
{
if (customer.IsBlocked)
throw new DomainException("Blocked customers cannot order.");
if (quantity <= 0)
throw new DomainException("Quantity must be at least 1.");
product.RemoveStock(quantity); // delegate the stock rule
return new Order
{
CustomerId = customer.Id,
ProductId = product.Id,
Quantity = quantity,
TotalPrice = product.Price * quantity,
Status = OrderStatus.Pending
};
}
}Notice every property now has a private set. Outside code cannot quietly change Status to a random string. The object guards itself.
Step 2: Push the stock rule into Product
The "stock cannot go negative" rule belongs to the Product. It is Product's data, so it should be Product's responsibility. We move it there.
public class Product
{
public int Id { get; private set; }
public decimal Price { get; private set; }
public int Stock { get; private set; }
public void RemoveStock(int quantity)
{
if (quantity <= 0)
throw new DomainException("Quantity must be positive.");
if (Stock < quantity)
throw new DomainException("Not enough stock.");
Stock -= quantity;
}
}Now no caller can ever reduce stock below zero. There is one method, one rule, one truth. Every order path — place, bulk, import, background — gets the same protection automatically.
Step 3: Make the service thin
With rules living inside Order and Product, the service shrinks. Its only job now is to load data, call the domain, and save.
public class OrderService
{
private readonly AppDbContext _db;
public OrderService(AppDbContext db) => _db = db;
public async Task PlaceOrderAsync(int customerId, int productId, int quantity)
{
var customer = await _db.Customers.FindAsync(customerId);
var product = await _db.Products.FindAsync(productId);
var order = Order.Create(customer!, product!, quantity); // rules live here
_db.Orders.Add(order);
await _db.SaveChangesAsync();
}
}Compare this to where we started. The rules did not vanish — they moved to a better home. The service is now easy to read and almost impossible to misuse.
How the responsibility shifts
This table shows what each piece owns before and after the journey. The goal is to give every rule a clear owner.
| Responsibility | Before (transaction script) | After (domain model) |
|---|---|---|
| Validate quantity | In the service method | In Order.Create |
| Block check | In the service method | In Order.Create |
| Stock cannot go negative | In the service method | In Product.RemoveStock |
| Set initial status | In the service method | In Order.Create |
| Load and save data | In the service method | Still in the service (correct) |
The application service keeps the jobs it is actually good at: talking to the database and coordinating. Everything about business rules moves into the model.
The order lifecycle as a state machine
A rich model is great for lifecycles. An order moves through states, and not every jump is allowed. You cannot ship a cancelled order. A domain method like Ship() can refuse illegal moves.
Here is how a guarded transition looks in code. The rule lives next to the data it protects.
public void Ship()
{
if (Status != OrderStatus.Paid)
throw new DomainException("Only paid orders can be shipped.");
Status = OrderStatus.Shipped;
}No service can set Status = "Shipped" on an unpaid order, because no service can set Status at all. The only door in is Ship(), and that door checks the rule.
A quick word on EF Core and modern .NET
EF Core works nicely with rich models. The private parameterless constructor and private set properties are fully supported — EF Core can read and write them through its backing field support. You map your aggregates as normal entities, and your business rules stay in the C# objects, not in the database.
This pattern fits the current ecosystem well. On .NET 10 (LTS) with C# 14, you can use modern features like primary constructors and collection expressions to keep aggregates tidy. If you reach for a mediator-style library to dispatch domain events, just note that MediatR and MassTransit are now commercially licensed for many uses — check the license before adding them, or use a small hand-written dispatcher instead. The domain model pattern itself needs no special library at all; it is just well-organized C#.
When NOT to do this
Refactoring to a domain model has a cost. If your "business logic" is just simple reads and writes with almost no rules — a basic CRUD admin screen, a lookup table editor — a transaction script is the right tool. Adding aggregates there is over-engineering.
Use this simple guide:
- Few rules, simple flows: keep the transaction script. It is honest and clear.
- Rules repeating across many paths: push them into a domain model.
- A real lifecycle with illegal states: a domain model with guarded methods shines.
The wisest teams start simple and earn their way into a domain model only when the pain of duplication and bugs makes it worth it. That is evolutionary design.
References and further reading
- Domain Model — Martin Fowler, Patterns of Enterprise Application Architecture
- Transaction Script — Martin Fowler, Patterns of Enterprise Application Architecture
- Anemic Domain Model — Martin Fowler
- From Transaction Scripts to Domain Models: A Refactoring Journey — Milan Jovanović
- Anemic domain model — Wikipedia
Quick recap
- A transaction script does one whole request in one method. It is simple and perfect for small apps.
- The pain begins when the same rules get copied into many methods and the copies drift apart, causing bugs.
- A domain model moves data and behavior into smart objects (aggregates) that protect their own rules.
- Watch out for the anemic model: classes with only properties and no behavior. That is an anti-pattern, not a domain model.
- Refactor in small safe steps: add tests, add a factory
Createmethod, push each rule into the object that owns it, then make the service thin. - Use
private setand private constructors so objects cannot reach a broken state from outside. - EF Core on .NET 10 supports rich models well. Check licenses for MediatR/MassTransit — they are now commercial — but the pattern itself needs no library.
- Choose the tool to fit the job: scripts for simple logic, domain models for complex rules and real lifecycles.
Related Posts
Soft Delete with EF Core: Delete Data Without Losing It
Learn soft delete in EF Core the right way. Use an interceptor and global query filters to hide deleted rows automatically, with simple examples, diagrams, code, and best practices for .NET 10.
Exploring Data Mapping Options in EF Core: A Beginner's Guide
Learn EF Core data mapping the easy way: owned entities, complex types, table splitting, value conversions, and JSON columns explained with simple analogies and code.
Calling Views, Stored Procedures and Functions in EF Core
A friendly, beginner guide to calling database views, stored procedures, and functions in EF Core with FromSql, SqlQuery, ExecuteSql, and ToView.
5 Hidden EF Core NuGet Packages That Make Your .NET Code Better
Five lesser-known EF Core NuGet packages for clean exceptions, naming conventions, bulk speed, dynamic queries, and auditing — with simple examples and diagrams.
Clean Architecture Folder Structure in .NET: A Simple Guide
Learn how to organise a .NET solution with Clean Architecture. Understand the Domain, Application, Infrastructure, and Presentation layers, the dependency rule, and the exact folders, with diagrams and examples.
Refactoring Overgrown Bounded Contexts in Modular Monoliths (.NET)
Learn how to spot and split an overgrown bounded context in a .NET modular monolith using safe, step-by-step refactoring, with diagrams, tables and code.