# From Transaction Scripts to Domain Models: A Refactoring Journey

> Transaction Scripts organizes business logic by procedures where each procedure handles a single request from the presentation. We will explore when you should consider introducing a Domain Model.

Published: 2024-06-15. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/from-transaction-scripts-to-domain-models-a-refactoring-journey

A Transaction Script organizes business logic by procedures, where each procedure handles a single request from the presentation layer.
It's a simple and effective approach early on, but as business rules accumulate and similar logic gets duplicated across scripts, the code becomes difficult to maintain.
The fix is to push the domain logic down into a Domain Model, an object model of the domain that incorporates both behavior and data.

I once led the development of a fitness tracking app.
We started with Transaction Scripts to handle features like workout creation and exercise logging.
It was a simple and effective approach for the app's early stages.

However, as we added more complex features, our business logic became bloated.
New business rules intertwined with existing logic, making the code difficult to maintain.
Each change could introduce unintended consequences.

We solved our problem by introducing a Domain Model.
The domain model shifts the focus from procedures to domain objects.
Our code became more expressive, easier to reason about, and less prone to errors.

This experience taught me a valuable lesson, that I want to share in this newsletter.

## Transaction Script

At its core, business applications operate through distinct interactions (transactions) with their users.
These transactions can range from simple data retrieval to complex operations involving multiple validations, calculations, and updates to the system's database.

The Transaction Script pattern provides a simple way to encapsulate the logic behind each transaction.
It organizes all the necessary steps, from data access to business rules, into a single, self-contained procedure.

> Organizes business logic by procedures where each procedure handles a single request from the presentation.

_&mdash; [Transaction Script](https://martinfowler.com/eaaCatalog/transactionScript.html), Patterns of Enterprise Application Architecture_

Here's an example of adding exercises to a workout:

```csharp
internal sealed class AddExercisesCommandHandler(
    IWorkoutRepository workoutRepository,
    IUnitOfWork unitOfWork)
    : ICommandHandler<AddExercisesCommand>
{
    public async Task<Result> Handle(
        AddExercisesCommand request,
        CancellationToken cancellationToken)
    {
        Workout? workout = await workoutRepository.GetByIdAsync(
            request.WorkoutId,
            cancellationToken);

        if (workout is null)
        {
            return Result.Failure(WorkoutErrors.NotFound(request.WorkoutId));
        }

        List<Error> errors = [];
        foreach (ExerciseRequest exerciseDto in request.Exercises)
        {
            if (exerciseDto.TargetType == TargetType.Distance &&
                exerciseDto.DistanceInMeters is null)
            {
                errors.Add(ExerciseErrors.MissingDistance);

                continue;
            }

            if (exerciseDto.TargetType == TargetType.Time &&
                exerciseDto.DurationInSeconds is null)
            {
                errors.Add(ExerciseErrors.MissingDuration);

                continue;
            }

            var exercise = new Exercise(
                Guid.NewGuid(),
                workout.Id,
                exerciseDto.ExerciseType,
                exerciseDto.TargetType,
                exerciseDto.DistanceInMeters,
                exerciseDto.DurationInSeconds);

            workouts.Exercises.Add(exercise);
        }

        if (errors.Count != 0)
        {
            return Result.Failure(new ValidationError(errors.ToArray()));
        }

        await unitOfWork.SaveChangesAsync(cancellationToken);

        return Result.Success();
    }
}
```

There isn't much logic here. We're just checking whether the workout exists and whether the exercises are valid.

What happens when we start to add more logic?

Let's add another business rule.
We must enforce a limit on the number of exercises allowed in a single workout (e.g., no more than 10 exercises).

```csharp {47-52}
internal sealed class AddExercisesCommandHandler(
    IWorkoutRepository workoutRepository,
    IUnitOfWork unitOfWork)
    : ICommandHandler<AddExercisesCommand>
{
    public async Task<Result> Handle(
        AddExercisesCommand request,
        CancellationToken cancellationToken)
    {
        Workout? workout = await workoutRepository.GetByIdAsync(
            request.WorkoutId,
            cancellationToken);

        if (workout is null)
        {
            return Result.Failure(WorkoutErrors.NotFound(request.WorkoutId));
        }

        List<Error> errors = [];
        foreach (ExerciseRequest exerciseDto in request.Exercises)
        {
            if (exerciseDto.TargetType == TargetType.Distance &&
                exerciseDto.DistanceInMeters is null)
            {
                errors.Add(ExerciseErrors.MissingDistance);

                continue;
            }

            if (exerciseDto.TargetType == TargetType.Time &&
                exerciseDto.DurationInSeconds is null)
            {
                errors.Add(ExerciseErrors.MissingDuration);

                continue;
            }

            var exercise = new Exercise(
                Guid.NewGuid(),
                workout.Id,
                exerciseDto.ExerciseType,
                exerciseDto.TargetType,
                exerciseDto.DistanceInMeters,
                exerciseDto.DurationInSeconds);

            workouts.Exercises.Add(exercise);

            if (workouts.Exercise.Count > 10)
            {
                return Result.Failure(
                    WorkoutErrors.MaxExercisesReached(workout.Id));
            }
        }

        if (errors.Count != 0)
        {
            return Result.Failure(new ValidationError(errors.ToArray()));
        }

        await unitOfWork.SaveChangesAsync(cancellationToken);

        return Result.Success();
    }
}
```

We can continue adding more business logic to the transaction script.
For example, we can introduce exercise type restrictions or enforce a specific exercise order.
You can imagine how the complexity will keep increasing over time.

Another concern is code duplication between transaction scripts.
This could happen if we need similar logic in multiple transaction scripts.
You may be tempted to solve this by calling one transaction script from the other, but this will introduce a different set of problems.

So, how can we solve these problems?

## Refactoring to Domain Model

What is a domain model?

> An object model of the domain that incorporates both behavior and data.

_&mdash; [Domain Model](https://martinfowler.com/eaaCatalog/domainModel.html), Patterns of Enterprise Application Architecture_

A domain model lets you encapsulate domain logic (behavior) and state changes (data) inside an object.
In **Domain-Driven Design** terminology, we would call this an aggregate.

An aggregate in DDD is a cluster of objects treated as a single unit for data changes.
The aggregate represents a consistency boundary.
It helps maintain consistency by ensuring that certain invariants always hold true for the entire aggregate.
In our workout example, the `Workout` class can be treated as an **aggregate root** that encompasses all the exercises within it.

What does this have to do with a transaction script?

We can move the domain logic and state changes from the transaction script into the aggregate.
This is often called "pushing logic down" into the domain.

Here's what the domain model will look like when we extract the domain logic:

```csharp
public sealed class Workout
{
    private readonly List<Exercise> _exercises = [];

    // Omitting the constructor and other propreties for brevity.

    public Result AddExercises(ExerciseModel[] exercises)
    {
        List<Error> errors = [];
        foreach (var exerciseModel in exercises)
        {
            if (exerciseModel.TargetType == TargetType.Distance &&
                exerciseModel.DistanceInMeters is null)
            {
                errors.Add(ExerciseErrors.MissingDistance);

                continue;
            }

            if (exerciseModel.TargetType == TargetType.Time &&
                exerciseModel.DurationInSeconds is null)
            {
                errors.Add(ExerciseErrors.MissingDuration);

                continue;
            }

            var exercise = new Exercise(
                Guid.NewGuid(),
                workout.Id,
                exerciseDto.ExerciseType,
                exerciseDto.TargetType,
                exerciseDto.DistanceInMeters,
                exerciseDto.DurationInSeconds);

            workouts.Exercises.Add(exercise);

            if (workouts.Exercise.Count > 10)
            {
                return Result.Failure(
                    WorkoutErrors.MaxExercisesReached(workout.Id));
            }
        }

        if (errors.Count != 0)
        {
            return Result.Failure(new ValidationError(errors.ToArray()));
        }

        return Result.Success();
    }
}
```

With the domain logic inside of the domain model, we can easily share it between transaction scripts.
Testing the domain model is simpler than testing the transaction script.
With a transaction script, we must provide any dependencies (possibly as mocks) for testing.
However, we can test the domain model in isolation.

The updated transaction script becomes much more straightforward and focused on its primary task:

```csharp
internal sealed class AddExercisesCommandHandler(
    IWorkoutRepository workoutRepository,
    IUnitOfWork unitOfWork)
    : ICommandHandler<AddExercisesCommand>
{
    public async Task<Result> Handle(
        AddExercisesCommand request,
        CancellationToken cancellationToken)
    {
        Workout? workout = await workoutRepository.GetByIdAsync(
            request.WorkoutId,
            cancellationToken);

        if (workout is null)
        {
            return Result.Failure(WorkoutErrors.NotFound(request.WorkoutId));
        }

        var exercises = request.Exercises.Select(e => e.ToModel()).ToArray();

        var result = workout.AddExercises(exercises);

        if (result.IsFailure)
        {
            return result;
        }

        await unitOfWork.SaveChangesAsync(cancellationToken);

        return Result.Success();
    }
}
```

## Takeaway

Transaction Scripts are a practical starting point for simple applications.
They offer a straightforward approach to implementing use cases.
Transaction scripts are the recommended approach to start building [vertical slices](https://milanjovanovic.tech/blog/vertical-slice-architecture-structuring-vertical-slices).
However, transaction scripts can become difficult to maintain as the application grows.

[Refactoring toward a Domain Model](https://milanjovanovic.tech/blog/refactoring-from-an-anemic-domain-model-to-a-rich-domain-model) allows you to encapsulate business logic in domain objects.
This promotes code reusability and makes your application more adaptable to changes.
Pushing logic down also improves testability and maintainability.

Should you use a **Transaction Script or a Domain Model**?

Here's a pragmatic approach you should consider.
Start with a transaction script, but pay attention to growing complexity.
When you notice a transaction script has too many concerns, consider adding a domain model.
Remember, the domain model should encapsulate some of the complexity of the domain logic.

Thanks for reading, and I'll see you next week!

---

## Frequently asked questions

### What is the Transaction Script pattern?

A Transaction Script organizes business logic by procedures, where each procedure handles a single request from the presentation layer. All the steps, from data access to business rules, live in one self-contained procedure. It is a simple, effective approach for early-stage applications.

### What is a Domain Model?

A Domain Model is an object model of the domain that incorporates both behavior and data. It encapsulates domain logic and state changes inside domain objects, which makes the code more expressive, easier to reason about, and less prone to errors.

### When should you refactor from a Transaction Script to a Domain Model?

Start with a Transaction Script and pay attention to growing complexity. When a script accumulates too many concerns, business rules intertwine with existing logic, or similar logic gets duplicated across scripts, refactor by pushing the domain logic down into a Domain Model.

### What is an aggregate in Domain-Driven Design?

An aggregate is a cluster of objects treated as a single unit for data changes. It represents a consistency boundary that ensures certain invariants always hold true for the entire aggregate. For example, a Workout can be an aggregate root that encompasses all its exercises.

### Why is a Domain Model easier to test than a Transaction Script?

Testing a Transaction Script requires providing its dependencies, often as mocks. A Domain Model can be tested in isolation because the business logic lives in plain domain objects, with no repositories or infrastructure involved.
