집계를 복제했습니다. 읽기 전용 작업이 여전히 상태를 변경하는 이유는 무엇입니까?

작성자

카테고리:

← 피드로
DEV Community · Ritesh Totlani · 2026-09-23 개발(SW)

Imagine you have a domain operation that answers a very common question:

“What would happen if we continued this trip from here?”

You don’t want to actually continue the trip.

You only want to preview the result.

Maybe you’re calculating:

  • the estimated arrival time,
  • the remaining driving time,
  • the stops that would be reached,
  • or the driver’s remaining compliance time.

A natural solution is to clone the aggregate and run the real domain logic against the clone.

That sounds like a very good design.

And it usually is.

But there is a subtle problem:

What if the domain logic changes something that isn’t part of the aggregate?

That’s the problem we’re going to walk through in this article.

The requirement: “Run the real logic, but don’t change anything”

Let’s say we have a Trip aggregate:

public class Trip
{
    public Guid Id { get; }
    public Guid TruckId { get; }

    private readonly List<Stop> _stops = new();

    public IReadOnlyCollection<Stop> Stops => _stops;

    public double DistanceTravelledSoFar { get; private set; }
    public TimeSpan TimeElapsedSoFar { get; private set; }

    // ...
}

Enter fullscreen mode Exit fullscreen mode

The trip contains its stops and its own progress.

Now imagine we already have a domain service that knows how to move the trip forward:

public void AdvanceRoute(
    Trip trip,
    DriverComplianceLedger ledger)
{
    foreach (var stop in trip.Stops)
    {
        // business rules...

        stop.MarkReached();

        trip.AddDistance(...);

        ledger.AddDrivingTime(...);
    }
}

Enter fullscreen mode Exit fullscreen mode

Notice something important.

The operation receives two objects:

AdvanceRoute(
    Trip,
    DriverComplianceLedger
)

Enter fullscreen mode Exit fullscreen mode

And both can change.

The trip changes.

The driver’s compliance record changes.

Now let’s build our preview.

First attempt: just clone the Trip

The first implementation almost writes itself:

public TripPreview Preview(
    Trip trip,
    DriverComplianceLedger ledger)
{
    var clone = trip.Clone();

    AdvanceRoute(clone, ledger);

    return CreatePreview(clone);
}

Enter fullscreen mode Exit fullscreen mode

At first glance, this looks perfectly reasonable.

We’re not passing the real Trip.

We’re passing a clone.

Therefore:

The real trip cannot be changed.

Right?

Not quite.

Let’s look at what actually happens

Suppose our production state is:

Trip #123
    ├── Stop A
    ├── Stop B
    └── Stop C

Driver Ledger
    └── 6 hours driving time remaining

Enter fullscreen mode Exit fullscreen mode

We clone the trip:

Original state

Trip #123
    ├── Stop A
    ├── Stop B
    └── Stop C


Simulation state

Trip #123'
    ├── Stop A'
    ├── Stop B'
    └── Stop C'

Enter fullscreen mode Exit fullscreen mode

So far, everything looks safe.

But the ledger hasn’t been cloned:

Original state

Trip #123
    ├── Stop A
    ├── Stop B
    └── Stop C

Driver Ledger  ← REAL OBJECT
    └── 6 hours remaining


Simulation state

Trip #123'
    ├── Stop A'
    ├── Stop B'
    └── Stop C'

Enter fullscreen mode Exit fullscreen mode

Now the engine starts executing.

It does:

stop.MarkReached();
trip.AddDistance(...);
ledger.AddDrivingTime(...);

Enter fullscreen mode Exit fullscreen mode

The first two operations modify the clone.

The third modifies the real ledger.

Our “read-only” preview just changed production state.

But isn’t Trip.Clone() a deep clone?

Yes.

And this is where the problem becomes interesting.

Here’s a perfectly good implementation:

public Trip Clone()
{
    var clone = new Trip(
        Id,
        TruckId,
        TruckingCompanyId,
        StartedAt)
    {
        CompletedAt = CompletedAt,
        DistanceTravelledSoFar = DistanceTravelledSoFar,
        TimeElapsedSoFar = TimeElapsedSoFar,
    };

    foreach (var stop in _stops)
    {
        clone._stops.Add(stop.Clone());
    }

    return clone;
}

Enter fullscreen mode Exit fullscreen mode

This is a proper deep clone of the Trip.

Every stop is cloned.

Every relevant piece of trip state is copied.

So what’s wrong?

Nothing is wrong with the clone.

The mistake is assuming that cloning Trip automatically isolates the entire operation.

It doesn’t.

The important distinction

This is the key idea of the whole article:

The object you clone and the state your operation can mutate are not necessarily the same thing.

When we look at Trip.Clone(), we’re asking:

What belongs to the Trip object graph?

But when we build a simulation, we need to ask:

What can this operation change?

Those questions happen to have the same answer in simple cases.

They don’t always have the same answer.

Object graph vs. mutation boundary

Consider the object graph:

Trip
 ├── Stop
 │    └── ...
 ├── Stop
 │    └── ...
 └── Stop
      └── ...

Enter fullscreen mode Exit fullscreen mode

A deep clone correctly isolates that graph.

But our operation actually works with:

AdvanceRoute()
    │
    ├── Trip
    │    └── Stops
    │
    └── DriverComplianceLedger

Enter fullscreen mode Exit fullscreen mode

The ledger isn’t part of Trip.

There is no reference like:

public DriverComplianceLedger Ledger { get; }

Enter fullscreen mode Exit fullscreen mode

inside Trip.

So you won’t discover this dependency by inspecting the aggregate.

You discover it by inspecting the behavior.

The method signature gives us the first clue

Whenever I see a simulation or preview implementation, I start with the method being executed.

For example:

public void AdvanceRoute(
    Trip trip,
    DriverComplianceLedger ledger)

Enter fullscreen mode Exit fullscreen mode

Immediately, I have two potential sources of mutation.

Then I look inside the method.

If I find:

trip.AddDistance(...);

Enter fullscreen mode Exit fullscreen mode

and:

ledger.AddDrivingTime(...);

Enter fullscreen mode Exit fullscreen mode

the mutation boundary becomes obvious:

                AdvanceRoute
                     │
          ┌──────────┴──────────┐
          │                     │
        Trip                 Ledger
          │                     │
       mutable              mutable

Enter fullscreen mode Exit fullscreen mode

Both objects participate in the state transition.

Therefore both need to be isolated for a true simulation.

The fix

The fix is actually very small.

Clone both objects:

public TripPreview Preview(
    Trip trip,
    DriverComplianceLedger ledger)
{
    var simulatedTrip = trip.Clone();
    var simulatedLedger = ledger.Clone();

    AdvanceRoute(
        simulatedTrip,
        simulatedLedger);

    return CreatePreview(simulatedTrip);
}

Enter fullscreen mode Exit fullscreen mode

Now the execution looks like this:

REAL STATE
─────────────────────────
Trip
Ledger


        │
        │ Clone
        ▼


SIMULATION STATE
─────────────────────────
Trip Clone
Ledger Clone


        │
        │ AdvanceRoute()
        ▼


Simulation Result

Enter fullscreen mode Exit fullscreen mode

The real state is never given to the mutating operation.

That is what actually makes the preview read-only.

Why I prefer this over writing another calculator

At this point, you might ask:

Why not just create a separate method that calculates the result without changing anything?

For example:

public TripPreview EstimateTrip(Trip trip)
{
    // duplicate the route calculation
    // duplicate compliance rules
    // duplicate stop logic
}

Enter fullscreen mode Exit fullscreen mode

This looks cleaner.

But now we have two implementations of the business rules:

Production Engine
       │
       └── Business rules


Preview Engine
       │
       └── Business rules

Enter fullscreen mode Exit fullscreen mode

Imagine someone changes the production rule next month:

if (driver.HasExceededDailyLimit())
{
    // new business rule
}

Enter fullscreen mode Exit fullscreen mode

The production engine gets updated.

Someone now has to remember to update the preview engine too.

If they don’t, the system can produce:

Production:  08:42 arrival

Preview:     09:15 arrival

Enter fullscreen mode Exit fullscreen mode

Neither implementation necessarily has a technical bug.

The problem is architectural:

The same business rule now exists in two places.

Clone-and-replay avoids that duplication.

We execute the real domain behavior.

We just execute it against isolated state.

So what exactly should we clone?

This is where the original rule:

“Clone the aggregate.”

isn’t quite strong enough.

A better rule is:

Clone every mutable state that belongs to the execution boundary of the operation.

That might be one object.

It might be two.

It might be considerably more.

For example:

Simple operation

Calculate(Trip)
     │
     └── Trip

Enter fullscreen mode Exit fullscreen mode

Here:

var simulatedTrip = trip.Clone();

Enter fullscreen mode Exit fullscreen mode

may be enough.

But:

More complex operation

Calculate(
    Trip,
    DriverLedger,
    MutableContext)
       │
       ├── Trip
       ├── DriverLedger
       └── MutableContext

Enter fullscreen mode Exit fullscreen mode

requires a larger isolation boundary.

A practical way to find the boundary

When you’re implementing a preview or simulation, don’t start by asking:

“What should I clone?”

Start with:

“What does the operation write to?”

Then work through the operation systematically.

1. Look at the parameters

Calculate(
    Trip trip,
    DriverLedger ledger)

Enter fullscreen mode Exit fullscreen mode

You already have two candidates.

2. Look for mutations

Search for operations such as:

trip.Update(...);
ledger.Add(...);
state.Set(...);
collection.Add(...);

Enter fullscreen mode Exit fullscreen mode

3. Follow mutable references

A parameter might itself contain mutable objects:

Context
 ├── Rules
 ├── State
 │    └── MutableState
 └── Cache

Enter fullscreen mode Exit fullscreen mode

Don’t stop at the top-level parameter.

4. Check services involved in the operation

This is particularly important if a domain service or application service maintains mutable state.

5. Define the simulation boundary

Only after understanding the writes should you decide what must be copied or isolated.

There is an even deeper lesson here

This isn’t really a Clone() problem.

It’s a boundary problem.

DDD gives us useful boundaries such as aggregates and bounded contexts.

But an aggregate boundary answers a domain question:

Which state belongs together for consistency?

A simulation asks a different question:

Which state must be isolated while this behavior executes?

Those boundaries can be identical.

But they don’t have to be.

That’s why this assumption is dangerous:

Aggregate boundary
        =
Mutation boundary

Enter fullscreen mode Exit fullscreen mode

Sometimes it does.

Sometimes it doesn’t.

You have to examine the behavior.

What about external dependencies?

There is another interesting consequence.

Suppose your operation eventually does this:

AdvanceRoute(
    trip,
    ledger,
    weatherService);

Enter fullscreen mode Exit fullscreen mode

Now cloning the first two objects isn’t necessarily enough.

What if weatherService maintains mutable state?

Or:

AdvanceRoute(
    trip,
    ledger,
    repository);

Enter fullscreen mode Exit fullscreen mode

What if the operation writes something through the repository?

For example:

repository.Save(ledger);

Enter fullscreen mode Exit fullscreen mode

Now our “simulation” has escaped its isolation boundary entirely.

This is why a truly read-only execution mode often requires thinking about more than object cloning.

You need to understand the side effects of the whole execution path.

For a pure in-memory domain operation, cloning mutable state may be sufficient.

For an operation involving persistence, messaging, external services, or caches, the isolation strategy may need to be different.

For example:

                  Simulation
                      │
       ┌──────────────┼──────────────┐
       │              │              │
   Domain state    Persistence    Messaging
       │              │              │
      clone       suppress/mock   suppress

Enter fullscreen mode Exit fullscreen mode

The important point is that clone-and-replay is a technique, not a guarantee.

The rule I now use in code reviews

When I see something like:

var clone = aggregate.Clone();

RunBusinessLogic(clone);

Enter fullscreen mode Exit fullscreen mode

I don’t immediately ask:

“Is this clone deep enough?”

I ask:

“Is this clone the complete mutation boundary of RunBusinessLogic?”

Then I inspect the method.

That small change in the question is surprisingly powerful.

Because the bug isn’t necessarily:

Shallow clone ❌

Enter fullscreen mode Exit fullscreen mode

It can be:

Perfect deep clone
        +
Incomplete isolation boundary
        =
Unsafe simulation

Enter fullscreen mode Exit fullscreen mode

A small checklist

If you’re implementing a preview, simulation, dry run, or what-if operation, check:

□ What operation am I executing?
□ What parameters does it receive?
□ Which parameters can be mutated?
□ What nested objects can be mutated?
□ Are there mutable fields or collections?
□ Do called services maintain mutable state?
□ Can the operation write to a repository?
□ Can it publish messages or events?
□ Can it modify shared caches/state?
□ Have all required mutable participants been isolated?

Enter fullscreen mode Exit fullscreen mode

The first few questions are usually enough for a simple domain operation.

The later questions become important as the operation crosses architectural boundaries.

The takeaway

The original idea behind clone-and-replay is still a good one.

If you want a preview to behave exactly like the real operation, executing the same business logic is often preferable to maintaining a second implementation.

But don’t confuse:

"I cloned the aggregate."

Enter fullscreen mode Exit fullscreen mode

with:

"I isolated the operation."

Enter fullscreen mode Exit fullscreen mode

Those are different statements.

A deep clone tells you that the aggregate’s object graph is isolated.

It doesn’t tell you that the execution is isolated.

For that, you need to understand the operation’s mutation boundary.

So the next time you implement a “read-only” simulation, don’t ask:

What object should I clone?

Ask:

What can this operation change?

Then make that entire state part of your simulation boundary.

That’s the difference between a preview that merely looks read-only and one that is actually read-only.

The example comes from a freight-marketplace side project https://github.com/RTO-The-Coder/freight-marketplace. The projection walk is implemented in RouteEtaCalculator, with the aggregate and related domain code under Freight.Domain/Fleet.

원문에서 계속 ↗