# Transaction Script vs Domain Model Pattern

> A transaction script puts a use case in one procedure; a domain model puts business rules inside domain objects. Compare equivalent C# order-pricing examples, the testing tradeoffs, and a gradual path from scripts to a richer model.

Published: 2026-09-22. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/transaction-script-vs-domain-model

A **transaction script** organizes a use case in one procedure, while a **domain model** places business rules in objects that represent domain concepts.
Use scripts for simple, independent operations.
A domain model becomes useful when rules are shared across operations, must protect object state, or change together as the business evolves.

## What Is a Transaction Script?

A transaction script handles one business operation: load the required data, validate it, apply rules, and save the result.
Common subroutines can still be extracted; the pattern does not require duplicating every rule.
This is the organization described in [**Martin Fowler's Transaction Script pattern**](https://martinfowler.com/eaaCatalog/transactionScript.html).

Consider an order that receives a 10% discount when its subtotal exceeds USD 100.
The example rounds the discounted total to two decimal places, with midpoint values rounded away from zero.
The application first loads prices from its catalog and verifies the customer, then runs the pricing procedure below.
`UnitPrice` is trusted catalog data, not a price supplied by an HTTP caller.

These types form a standalone example in `ScriptOrderService.cs`:

```csharp
namespace ScriptExample;

public sealed record OrderLine(Guid ProductId, int Quantity, decimal UnitPrice);
public sealed record OrderData(Guid Id, Guid CustomerId,
    IReadOnlyList<OrderLine> Lines, decimal TotalUsd);

public static class OrderService
{
    public static OrderData PlaceOrder(
        Guid customerId, IReadOnlyList<OrderLine> requestedLines)
    {
        if (customerId == Guid.Empty)
            throw new ArgumentException("A customer is required.");

        var lines = requestedLines.ToArray();
        if (lines.Length == 0)
            throw new ArgumentException("An order needs at least one line.");

        decimal subtotal = 0;
        foreach (var line in lines)
        {
            if (line.ProductId == Guid.Empty ||
                line.Quantity <= 0 || line.UnitPrice < 0)
            {
                throw new ArgumentException("Invalid line item.");
            }

            subtotal += line.Quantity * line.UnitPrice;
        }

        var total = subtotal > 100m
            ? decimal.Round(subtotal * 0.9m, 2, MidpointRounding.AwayFromZero)
            : subtotal;
        return new OrderData(Guid.NewGuid(), customerId,
            Array.AsReadOnly(lines), total);
    }
}
```

The rules live in `OrderService.PlaceOrder`.
`OrderData` carries the result without behavior.
Returning an immutable data record does not make it a rich domain model; the important distinction is where business behavior lives.

This example isolates the business calculation so you can run it without a database.
A real transaction script can include database operations directly or call a persistence abstraction around this calculation.
The word "transaction" here describes a business operation, not a guarantee that a database transaction has been started.

## What Is a Domain Model?

A domain model gives objects responsibility for their own rules.
The application coordinates loading and saving, while methods on the domain objects enforce valid state changes.

The equivalent model below uses the same USD discount and input rules.
Put it in `DomainOrder.cs`; the namespace separates it from the previous example:

```csharp
namespace ModelExample;

public sealed record OrderLine(Guid ProductId, int Quantity, decimal UnitPrice);
public enum OrderStatus { Draft, Placed }

public sealed class Order
{
    private readonly List<OrderLine> _lines = [];

    private Order(Guid customerId)
    {
        Id = Guid.NewGuid();
        CustomerId = customerId;
    }

    public Guid Id { get; }
    public Guid CustomerId { get; }
    public OrderStatus Status { get; private set; } = OrderStatus.Draft;
    public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
    public decimal TotalUsd
    {
        get
        {
            var subtotal = _lines.Sum(line => line.Quantity * line.UnitPrice);
            return subtotal > 100m
                ? decimal.Round(subtotal * 0.9m, 2, MidpointRounding.AwayFromZero)
                : subtotal;
        }
    }

    public static Order Create(Guid customerId)
    {
        if (customerId == Guid.Empty)
            throw new ArgumentException("A customer is required.");

        return new Order(customerId);
    }

    public void AddLineItem(Guid productId, int quantity, decimal unitPrice)
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Only drafts accept items.");
        if (productId == Guid.Empty || quantity <= 0 || unitPrice < 0)
            throw new ArgumentException("Invalid line item.");

        _lines.Add(new OrderLine(productId, quantity, unitPrice));
    }

    public void Place()
    {
        if (Status != OrderStatus.Draft || _lines.Count == 0)
            throw new InvalidOperationException("A nonempty draft is required.");

        Status = OrderStatus.Placed;
    }
}
```

The domain model can reject adding a line after placement, regardless of which use case calls it.
A second operation that edits a draft uses the same `AddLineItem` rule rather than reimplementing it.

Compare both approaches in a console application's `Program.cs`:

```csharp
var customerId = Guid.NewGuid();
var productId = Guid.NewGuid();

var scriptOrder = ScriptExample.OrderService.PlaceOrder(customerId,
    [new ScriptExample.OrderLine(productId, 3, 50m)]);

var domainOrder = ModelExample.Order.Create(customerId);
domainOrder.AddLineItem(productId, 3, 50m);
domainOrder.Place();

Console.WriteLine(scriptOrder.TotalUsd); // 135.00
Console.WriteLine(domainOrder.TotalUsd); // 135.00
```

Both examples apply the same pricing rules.
The domain model adds an explicit draft-to-placed lifecycle; that structure is useful when the application needs additional operations on an order.
For one short operation, it can be unnecessary ceremony.

Neither example allocates inventory, persists an order, or implements a complete checkout process.
Those responsibilities require application and infrastructure code whichever pattern you choose.
The richer version of that arrangement looks like this:

![A transaction script keeps validation, calculation, and persistence in one PlaceOrder method, while a richer model separates application orchestration from Order and Product behavior](https://milanjovanovic.tech/blogs/articles/transaction-script-vs-domain-model/logic-location.png)

## How Do the Tradeoffs Compare?

| Concern | Transaction Script | Domain Model |
| --- | --- | --- |
| Rule location | A procedure for each business operation | Objects responsible for domain behavior |
| State protection | Each operation must enforce the relevant rules | Object methods protect permitted transitions |
| Shared rules | Extract common calculations and procedures | Reuse behavior through domain objects |
| Testing | Pure calculations can be unit tested; persistence needs integration coverage | Domain rules can be tested without persistence |
| Initial cost | Less modeling for a short independent operation | More types and lifecycle decisions |
| Best fit | Simple CRUD or independent operations | Interconnected rules and evolving workflows |

A transaction script does not automatically require a database for every test.
The first example is a pure calculation and can be tested directly.
Likewise, a domain model does not make persistence or messaging tests unnecessary.

## What About Transactions and Concurrency?

Moving stock checks into a `Product` method does not prevent two requests from reserving the same stock.
Both requests can read the same starting quantity and pass the same domain check.
An invariant enforced in memory still needs a persistence strategy that detects conflicting writes.

For an EF Core implementation in one database, save the order and inventory changes atomically and configure optimistic concurrency or use an appropriate conditional database update.
Handle conflicts by reloading and reevaluating the operation.
The [**PostgreSQL concurrency guide**](https://milanjovanovic.tech/blog/ef-core-postgresql-xmin-concurrency) shows one way to detect updates made after an entity was loaded.

When inventory belongs to another bounded context, use a reservation workflow and explicit failure handling instead of hiding remote updates inside `Order.Place`.
A [**saga or process manager**](https://milanjovanovic.tech/blog/process-manager-vs-saga-pattern) can coordinate that workflow.
The domain model pattern does not supply a distributed transaction.

## When Should You Change the Design?

Start with a transaction script when the operation is short, rules are independent, and the system mainly moves data between a user and a database.
Extract shared calculations when duplication appears; a larger object model is not the only available refactoring.

Consider a [**rich domain model**](https://milanjovanovic.tech/blog/rich-vs-anemic-domain-model) when several operations must preserve the same invariants, object lifecycles become important, or rules change together.
The additional types should make those rules easier to locate and protect.

You do not need to choose one pattern for the entire application.
An order-placement workflow can use a domain model while a reporting endpoint or settings screen uses a straightforward script.

## Migrate One Rule at a Time

A gradual migration starts by adding behavior to the object that owns a rule.
For example, replace direct assignments to an order's status with a `Cancel` method that checks whether cancellation is allowed.
Then restrict the setter so other callers cannot bypass the rule.

Move additional behavior only when its ownership is clear.
Introduce value objects for meaningful concepts such as money, and define [**aggregate boundaries**](https://milanjovanovic.tech/blog/aggregate-design-ddd) around the state that must remain consistent.
The worked [**transaction-script refactoring example**](https://milanjovanovic.tech/blog/from-transaction-scripts-to-domain-models-a-refactoring-journey) follows that progression.

Keep tests around observable behavior during the transition.
If an input used to produce USD 135, moving the calculation into `Order` should preserve that result unless the business rule itself changed.

An anemic model becomes a problem when the design pays for elaborate domain layers but still cannot enforce its own rules.
Data records used intentionally by simple scripts are a valid choice.
The goal is coherent ownership of behavior, not removing every DTO.

## Summary

- **Transaction scripts** put a business operation in a procedure and work well for simple, independent rules.
- **Domain models** place behavior and state transitions in objects, which helps when several operations share invariants.
- **Both approaches** can have unit tests and both still need persistence and integration verification.
- **Concurrency** requires a database or workflow strategy; moving a check into an entity does not make it atomic.
- **Migration** can happen gradually as the domain's complexity becomes clearer.

Choose the structure that makes the actual business rules easiest to understand and change.
Add a richer model when it solves a concrete problem in those rules.

Thanks for reading.

And stay awesome!

---

## Frequently asked questions

### What is the transaction script pattern?

A transaction script organizes business logic as a procedure for a business operation. It can validate input, apply rules, and persist data, using common subroutines where useful. The pattern does not require an elaborate domain object model.

### What is the difference between a transaction script and a domain model?

A transaction script puts behavior in a procedure. A domain model gives objects responsibility for business rules and valid state transitions, while application code coordinates the use case.

### When is a transaction script good enough?

When operations are short, rules are independent, and the application is mostly CRUD. Extract shared calculations when needed, and introduce richer domain objects when their state protection or shared behavior solves a concrete problem.

### Can you migrate from transaction scripts to a domain model gradually?

Yes. Move a rule into the object that owns it, restrict direct state mutation, and preserve behavior with tests. Add further methods, value objects, and aggregate boundaries as ownership becomes clearer.

### Are data records an anemic domain model?

Data records are appropriate in many scripts and read models. An anemic model becomes a problem when a design pays for elaborate domain layers while leaving all rules in services and allowing objects to enter invalid states.
