Transaction Script vs Domain Model Pattern

Transaction Script vs Domain Model Pattern

By

7 min read··

ddddotnetsoftware-architecture

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.

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:

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:

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:

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

How Do the Tradeoffs Compare?

ConcernTransaction ScriptDomain Model
Rule locationA procedure for each business operationObjects responsible for domain behavior
State protectionEach operation must enforce the relevant rulesObject methods protect permitted transitions
Shared rulesExtract common calculations and proceduresReuse behavior through domain objects
TestingPure calculations can be unit tested; persistence needs integration coverageDomain rules can be tested without persistence
Initial costLess modeling for a short independent operationMore types and lifecycle decisions
Best fitSimple CRUD or independent operationsInterconnected 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 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 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 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 around the state that must remain consistent. The worked transaction-script refactoring example 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.

  • Aggregate Design in DDD - Rules, Boundaries, and Consistency

    Aggregates are the most important tactical pattern in Domain-Driven Design. They define consistency boundaries, enforce invariants, and protect your domain model. Here are the rules and practical guidance for designing aggregates in .NET.

  • Aggregate Root in DDD: Rules and Implementation

    The Aggregate Root is the gatekeeper of consistency in Domain-Driven Design. Here are the rules for designing aggregate roots and implementing them in C#.

  • The Always-Valid Domain Model

    An always-valid domain model enforces business invariants at construction and on every state change. Private constructors, validated value objects, and guarded methods reduce repeated validation while keeping persistence and concurrency checks explicit.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.