Skip to main content
SEMastery
Data Accessintermediate

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.

12 min readUpdated April 2, 2026

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.

Figure 1: A transaction script handles one request from top to bottom inside a single method.

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

Place order
Bulk order
Import order
Background job

Steps

1

Place order

Has stock + blocked checks

2

Bulk order

Forgot the blocked check

3

Import order

Stock check is slightly different

4

Background job

No checks at all — bug!

When logic lives in services, each new entry point copies the rules — and copies drift apart over time.

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.

Figure 2: In a domain model, behavior moves inside the objects. The service becomes thin and only coordinates.

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.

AspectTransaction scriptAnemic model (avoid)Rich domain model
Data locationIn entitiesIn entitiesIn entities
Logic locationIn service methodsStill in servicesInside the entities
Rule duplicationHighHighLow (one place)
Can reach a broken state?Yes, easilyYes, easilyNo, protected
Best forSimple logicNothing — skip itComplex 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

Tests
Push rules
Factory
Thin service

Steps

1

Tests

Cover current behavior first

2

Push rules

Move one check into the entity

3

Factory

One safe way to create an Order

4

Thin service

Service just coordinates

Move logic into the domain one rule at a time, never breaking the build.

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.

ResponsibilityBefore (transaction script)After (domain model)
Validate quantityIn the service methodIn Order.Create
Block checkIn the service methodIn Order.Create
Stock cannot go negativeIn the service methodIn Product.RemoveStock
Set initial statusIn the service methodIn Order.Create
Load and save dataIn the service methodStill 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.

Figure 3: The Order aggregate guards its own state changes. Illegal transitions simply cannot happen.

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#.

Figure 4: The full request flow with a domain model. The service is thin; the model holds the rules.

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

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 Create method, push each rule into the object that owns it, then make the service thin.
  • Use private set and 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