ApexBlueprint internals: topological sort and alias resolution

Apex Stem Docs
Apex StemApexBlueprintInternalsTopological Sort
A deep dive into the SOrchestrator internals. Walks through the six phases of .create() (collection / dependency graph / topological sort / alias resolution / per-parent realize / batch DML), and explains how circular-reference detection, automatic aliases, and the {P0}/{P1} hierarchical resolution are unified into one pipeline.

Who this article is for: Developers curious about ApexBlueprint's internal implementation. We trace "what happens behind the simple API" in phase-by-phase pseudocode, with references to the actual implementation files (SOrchestrator.cls / SBlueprintAnalyzer.cls / SBlueprintRealizer.cls). For a sister article focused on the design rationale and philosophy, see Declarative Data Specification: Why the Blueprint Form?.

ApexBlueprint's API is just eight types of methods: of / set / template / alias / use / times / withChildren / parentIdField. And yet, with these alone, you can handle all the common difficulties of integration test data — parent-child nesting, bulk generation, sibling references, hierarchical resolution of "the true parent for me", and disambiguating multiple lookups.

This gap comes from the fact that several independent problems are being solved at once inside SOrchestrator.create(). This page digs into what happens behind the user's view, phase by phase.

The User's View vs the Internal Processing

From the user's perspective, .create() looks like a single operation: "insert every blueprint I added into the database in the correct order".

But internally, four independent problems are being solved to make this happen:

ProblemCore of the Solution
1. In what order to insert so that lookups resolveDependency analysis + topological sort
2. How to distinguish "child 1 under parent 1" from "child 1 under parent 2"Alias resolution + auto-alias issuance
3. How to hierarchically connect records bulked via .times(...)Per-parent iteration + automatic parent-Id copying
4. How to identify "the true parent for me" via {P0} / {P1}Tracking the hierarchy stack + parent-reference resolution

These are normally separate headaches, but ApexBlueprint solves them continuously, within the same pipeline.

The Phases of Internal Processing

When .create() is called, the flow proceeds roughly as follows.

Phase 1: Collecting the Blueprints

The SBlueprints passed via SOrchestrator.start().add(...).add(...) are held inside SOrchestrator's list in their addition order, first of all. No validation or execution happens at this stage.

Child blueprints nested via withChildren are held as a tree structure inside the parent blueprint. Following the root blueprint lets you extract all children, grandchildren, and great-grandchildren below it.

Phase 2: Building the Dependency Graph

The moment .create() is called, SOrchestrator first builds a dependency graph. Each blueprint becomes a node, and the following relationships are registered as directed edges:

  • The withChildren parent-child relationship: an edge from parent → child (the child depends on the parent's Id)
  • The .use(alias, ...) sibling reference: an edge from the alias source blueprint → this blueprint (this one depends on the source's value)
  • The .after(alias) ordering constraint: the same alias source → this blueprint edge, except it carries no value and expresses ordering alone
  • Parent references like {P0} / {P1}: an edge from the corresponding ancestor → this blueprint (the corresponding ancestor is identified during construction)

Note that detection of duplicate aliases and references to non-existent aliases happens later — they are lazily detected during Phase 5 (realize) based on the dependency information built here. From the user's perspective, these are still "failures at .create() time", but as an implementation detail, "graph construction" and "integrity validation" are kept separate.

Phase 3: Topological Sort

Once the dependency graph is complete, SOrchestrator runs a topological sort, rearranging blueprints so that the depended-upon side (parents) come first.

  • If a cycle is found in the dependencies, it fails with "Circular or invalid reference detected"
  • Once sorting succeeds, the downstream insertion processing is guaranteed to not have to worry about insertion order

The reason you can write .add(...) calls in "the most readable order" is that the order gets reset here.

Phase 4: Alias Resolution and Auto-Alias Issuance

As the sorted blueprints are processed in order, each gets an alias assigned.

  • Aliases explicitly set via .alias(...) are used as-is ({#} placeholders are finalized after expansion)
  • Blueprints without .alias(...) get an auto-generated alias

The auto-generated alias format is internally __{SObjectName}_{globalCounter}_{#}__ (e.g. __Account_0_1__ / __Contact_1_2__). The middle number is a global counter assigned across all blueprints, not the hierarchy depth (the 1 in __Contact_1_1__ seen in test assertions is not the depth but the analysis ordering). The hierarchy is represented via the parent prefix explained next.

Blueprints nested via withChildren get the parent's alias prefixed onto theirs, yielding compound aliases like __Account_0_1____Contact_1_1__. This gives "child 1 under parent 1" and "child 1 under parent 2" distinct namespaces as separate records.

Phase 5: Hierarchical Realize and Parent-Id Copying

Now the Realizer takes over. It walks the sorted blueprints in order and realizes each (= converts it into an SObject instance).

When .times(...) is combined with nesting, realize behaves like this:

CODE
Loop the parent blueprint times(N) times
  For each parent instance:
    Realize the parent as an SObject
    Loop the child blueprint times(M) times
      For each child instance:
        Realize the child as an SObject
        Copy the parent's Id into the child's lookup field
        ※ The Id is still a placeholder at this point (pre-insert)
        Recurse if there are grandchildren

What's important here is the property that a fresh, full set of children is regenerated per parent instance. This is the source of the "Multiplication: upper-tier times propagates downward" behavior (see Relations, Bulk Generation, and Reference Patterns).

Where {P0} / {P1} Resolution Happens

Resolution of {Pn} happens right inside this "realize children per parent" loop. The Realizer dynamically assembles a parent-position map (a Map named parentPositionToAlias) as it recurses, and a child blueprint's declaration like .use('{P1}', 'LastName', 'Subject') is resolved as "the alias of the blueprint currently occupying tier 1 (= the true parent for me) in the parent-position map at this moment".

So {Pn} is not a static alias string, but a reference to a dynamically constructed parent-position map. As nesting deepens, more entries are added to the map, which is why the numbers {P0} / {P1} / {P2} can be specified consistently as absolute depths from the root.

You could achieve something similar by embedding the parent's alias into the alias yourself ({P0}_child_{#}) — that's also implementation-supported. But the user has to assemble the alias string in their head on the .use(...) side, which is why {Pn} is the more structurally writable design.

The realize described here, combined with Phase 6 below, is executed one layer at a time. It is not "realize all blueprints first, then bulk-insert at the end" (details in Phase 6).

Phase 6: Per-Layer Bulk DML Insert (Alternating Loop with Phase 5)

Implementation-wise, Phase 5 (realize) and Phase 6 (DML insert) are not run end-to-end as one shot. They alternate per "layer", as derived by Phase 3. A "layer" here is the set of blueprints at the same depth, as determined by dependency analysis — the blueprint closest to the root is layer 0, its children are layer 1, and so on (SOrchestrator.BuildLayers).

The pseudocode for .create() execution looks like this:

CODE
for each layer from 0 to maxLayer:
  // Phase 5: realize every blueprint in this layer
  //   The upper layers are already inserted, so .use(parentAlias, 'Id', ...) resolves with the parent's real Id
  layerSObjects = realize all blueprints in this layer
 
  // Phase 6: bulk-insert this layer's SObjects
  dmlOperator.doInsert(layerSObjects)
 
  // Store this layer's results in the master map aliasToSObject
  // — referenced when realizing the next layer
  aliasToSObject.putAll(thisLayerResults)

The reason for synchronizing between layers is that the real Id is only fixed after the parent layer has been inserted, and that's what the next layer's children need to obtain via .use(parentAlias, 'Id', 'AccountId'). If you realized everything first, parent Ids would still be placeholders when copied into children, and post-insert parent-child relationships would be broken.

It's this inter-layer synchronization that lets the user enjoy the experience: "just specify the parent Id with .use(...), and the real Id from the actual DML insert ends up copied into the child".

Where IDmlOperator Plugs In

The dmlOperator at the end of each layer's dmlOperator.doInsert(layerSObjects) is the swap-out point for production / mock:

  • Production: DmlOperator (runs real insert)
  • Tests: MockDmlOperator (no real DML; just issues placeholder Ids)

ApexBlueprint's own tests (SOrchestratorTest) call SOrchestrator.start(new MockDmlOperator()) to swap here and verify behavior without firing real DML.

Timing of Duplicate-Alias Detection

In the implementation, duplicate aliases are detected at two moments:

  • Within the same layer: when populating layerAliasToSObject inside realizeLayer
  • Across layers: when populating the master map aliasToSObject after insert

Either way, a Duplicate alias detected exception is thrown at runtime. This is the concrete locus of the "duplicate aliases are detected lazily" claim from Phase 2.

Why All of This Can Be Solved at Once

Looking back at ApexBlueprint's internal processing, the independent problems — "dependency graph", "topological sort", "alias resolution", "per-parent recursive realize", "parent-reference resolution", "bulk DML" — are solved continuously within the same pipeline.

The reason these collapse, from the user's perspective, into a single operation ("just declare dependencies, and it works") is that ApexBlueprint routes every operation through one abstraction: "the declaration of the data's final shape". The user writes only "what records I want to ultimately exist", and the framework takes care of "the mechanical procedure to get there".

Two points stand out as marks of good design:

  1. The API surface fits into eleven methods, so the learning cost grows linearly. It is not the kind of API where method counts grow exponentially with each new feature
  2. The solutions to each underlying problem (topological sort / auto-alias / hierarchy-stack resolution for {Pn}) are independent and individually replaceable. This keeps room for future improvements to the internal implementation

ApexBlueprint's character — "simple on the surface, powerful inside" — is built on this kind of stacked separation of solutions.

In practice: the two v2.0.0 features added no new machinery

Point 1 above reads like an abstraction, but the v2.0.0 additions are a concrete demonstration of it. Neither after nor sharedWith added anything to dependency resolution.

Added APIWhat it actually does inside
.after(alias)Pushes a dependency with no fromField / toField onto the same list .use() uses. To the topological sort, one more edge appeared that happens to carry no value
.sharedWith(user, level)Assembles a __Share sibling blueprint and wires it back with .use(ownAlias, 'Id', 'ParentId'). One ordinary child node appeared

sharedWith lands exactly one layer after its parent not because sharing has bespoke ordering logic, but because the use() edge makes the Phase 3 sort arrange it that way. For the same reason it follows times bulk generation and {Pn} resolution automatically — no part of the bulk machinery was rewritten to accommodate sharing.

As long as a new feature reduces to "what edge does Phase 2 draw?", Phase 3 onward never has to change. That structure is why the API surface grows only linearly.