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.

A fork command produces many ForkAttempts; some succeed and some fail

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.