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