Bulk Forking
The bulk pattern
Most real fork operations span more than one entity: a dojo has many ninjas and many missions. A single bad row should not abort the whole carry-forward — but the caller still needs to know what failed.
ForkAttempt lets the loop be both unconditional and inspectable:
ForkContext ctx = ForkContext.of(forkFrom, cmd);
List<ForkAttempt<Ninja>> ninjaOutcomes = source.getNinjas().stream()
.map(ninja -> ForkAttempt.from(
() -> generator.generateNinjaCommand(ctx, ninja),
c -> ninjaService.create(c)
))
.toList();
Each iteration is independent.
The supplier lambda produces the command — that’s where validation problems surface.
The invoker lambda runs the service — that’s where service-level problems surface.
Either way, a ThrowableProblem becomes a Problem on the resulting ForkAttempt; the loop continues.
Returning a structured result
Wrap the per-entity outcomes in a result type so the caller can inspect them:
public record DojoForkResult(
Dojo dojo,
List<ForkAttempt<Ninja>> ninjas,
List<ForkAttempt<Mission>> missions
) { }
Callers can then count successes vs failures, render the failed Problem instances back as part of the response, or re-issue the failing commands after a fix without re-running the entire bulk operation.
When not to use ForkAttempt
If a step’s failure should abort the whole fork (e.g. you couldn’t even load the source), don’t wrap that step.
Throw a Problem directly so the caller gets a clean error and no half-built graph is persisted.
ForkAttempt is for the steps where partial success is valid behaviour — typically the leaf children of the root entity, where one bad row shouldn’t block the rest.