Variants and Variant Sources
Overview
This document describes a two-layer model for build variants:
variantsdefines the core domain modelvariantSourcesdefines source materialization semantics for that model
The main goal is to keep the core model small, explicit, and stable, while allowing source-related behavior to remain flexible and adapter-friendly.
The model is intentionally split into:
- a closed, finalized domain model
- an open, runtime-oriented source materialization model
This separation is important because compilation, source aggregation, publication, and adapter-specific behavior do not belong to the same abstraction layer.
Core idea
The variants model is based on three independent domains:
LayerRoleVariant
A finalized VariantsView contains the normalized relation:
(variant, role, layer)
This relation is the source of truth.
Everything else is derived from it.
variants: the core domain model
Purpose
variants describes:
- what layers exist
- what roles exist
- what variants exist
- which
(variant, role, layer)combinations are valid
It does not describe:
- source directories
- source roots
- source set materialization
- compilation tasks
- publication mechanics
- source set inheritance
- layer merge behavior for a concrete toolchain
Those concerns are intentionally outside the core model.
Core DSL example
variants { layers { main() test() generated() rjs() cjs() } roles { production() test() tool() } variant("browser") { role("production") { layers("main", "generated", "rjs") } role("test") { layers("main", "test", "generated", "rjs") } } variant("nodejs") { role("production") { layers("main", "generated", "cjs") } role("test") { layers("main", "test", "generated", "cjs") } role("tool") { layers("main", "generated", "cjs") } } }
Interpretation
This example means:
browserproduction usesmain,generated,rjsbrowsertest usesmain,test,generated,rjsnodejsproduction usesmain,generated,cjsnodejstest usesmain,test,generated,cjsnodejstool usesmain,generated,cjs
The model is purely declarative.
Identity and references
Layer, Role, and Variant are identity objects.
They exist as declared domain values.
References between model elements are symbolic:
- layers are referenced by layer name
- roles are referenced by role name
- variants are referenced by variant name
This is intentional.
The core model is declarative, not navigation-oriented.
It is acceptable for aggregates to hold symbolic references to foreign domain values, as long as identity is clearly defined and validated later.
Finalization
The variants model is finalized once.
Finalization is an internal lifecycle transition. It is typically triggered privately, for example from afterEvaluate, but that mechanism is not part of the public API contract.
The public contract is:
variants.whenFinalized(...)
This callback is replayable:
- if called before finalization, the action is queued
- if called after finalization, the action is invoked immediately
The callback receives a finalized, read-only view of the model.
Example:
variants.whenFinalized(view -> { // use finalized VariantsView here });
VariantsView
VariantsView is the finalized representation of the core model.
It contains:
- all declared
Layer - all declared
Role - all declared
Variant - all normalized entries
(variant, role, layer)
Conceptually:
interface VariantsView { Set<Layer> getLayers(); Set<Role> getRoles(); Set<Variant> getVariants(); Set<VariantRoleLayer> getEntries(); }
Where:
record VariantRoleLayer(Variant variant, Role role, Layer layer) {}
This view is:
- immutable
- normalized
- validated
- independent from DSL internals
Derived views
Two important views can be derived from VariantsView:
CompileUnitsViewRoleProjectionsView
These views are not part of the raw core model itself, but they are naturally derived from it.
CompileUnitsView
Purpose
A compile unit is defined as:
(variant, layer)
This is based on the following rationale:
variantdefines compilation semanticslayerpartitions a variant into separate compilation unitsroleis not a compilation boundary
This is especially useful for toolchains such as TypeScript, where compilation is often more practical or more correct per layer than for the whole variant at once.
Example
From:
(browser, production, main)(browser, production, rjs)(browser, test, main)(browser, test, test)(browser, test, rjs)
we derive compile units:
(browser, main)(browser, rjs)(browser, test)
Conceptual API
interface CompileUnitsView { Set<CompileUnit> getUnits(); Set<CompileUnit> getUnitsForVariant(Variant variant); boolean contains(Variant variant, Layer layer); Set<Role> getRoles(CompileUnit unit); } record CompileUnit(Variant variant, Layer layer) {}
Meaning
CompileUnitsView answers:
- what can be compiled
- how a variant is partitioned into compile units
- which logical roles include a given compile unit
RoleProjectionsView
Purpose
A role projection is defined as:
(variant, role)
This is based on the following rationale:
roleis not about compilationrolegroups compile units by purpose- roles are more closely related to publication, aggregation, assembly, or result grouping
Example
For browser:
-
productionincludes compile units: -
(browser, main) -
(browser, rjs) -
testincludes compile units: -
(browser, main) (browser, test)(browser, rjs)
Conceptual API
interface RoleProjectionsView { Set<RoleProjection> getProjections(); Set<RoleProjection> getProjectionsForVariant(Variant variant); Set<RoleProjection> getProjectionsForRole(Role role); Set<CompileUnit> getUnits(RoleProjection projection); } record RoleProjection(Variant variant, Role role) {}
Meaning
RoleProjectionsView answers:
- how compile units are grouped by purpose
- what belongs to
production,test,tool, etc. - what should be aggregated or published together
Why CompileUnitsView and RoleProjectionsView are not part of VariantsView
VariantsView is intentionally minimal.
It expresses the domain relation:
(variant, role, layer)
CompileUnitsView and RoleProjectionsView are derived interpretations of that relation.
They are natural and useful, but they are still interpretations:
CompileUnit = (variant, layer)RoleProjection = (variant, role)
This is why they are better treated as derived views rather than direct core model primitives.
variantSources: source semantics for layers
Purpose
variantSources does not define variants.
It defines how a declared Layer contributes sources.
In other words:
variantsdefines what existsvariantSourcesdefines how layers become source inputs
This distinction is important.
variantSources does not own the variant model. It interprets it.
Main idea
A layer source rule describes the source contribution of a layer.
This is independent of any concrete variant or role.
Conceptually:
Layer -> source contribution rule
Examples of source contribution semantics:
- base directory
- source directories
- logical source kinds (
ts,js,resources) - declared outputs (
js,dts,resources)
variantSources DSL example
variantSources { layerRule("main") { from("src/main") set("ts") { srcDir("ts") } set("js") { srcDir("js") } set("resources") { srcDir("resources") } outputs("js", "dts", "resources") } layerRule("test") { from("src/test") set("ts") { srcDir("ts") } set("resources") { srcDir("resources") } outputs("js", "dts", "resources") } layerRule("rjs") { from("src/rjs") set("ts") { srcDir("ts") } outputs("js", "dts") } layerRule("cjs") { from("src/cjs") set("ts") { srcDir("ts") } outputs("js", "dts") } }
Interpretation
This means:
maincontributests,js, andresourcestestcontributestsandresourcesrjscontributestscjscontributests
These are layer rules only.
They do not yet say which variant consumes them.
Why variantSources remains open
Unlike variants, variantSources does not need to be closed in the same way.
Reasons:
- the DSL is internal to source materialization
- the source of truth for unit existence is already finalized in
VariantsView SourceSetMaterializerreturnsNamedDomainObjectProvider<GenericSourceSet>- adapters may need to refine source-related behavior after
variantsis finalized
Therefore:
variantsis finalizedvariantSourcesmay remain open
This is not a contradiction.
It reflects the difference between:
- a closed domain model
- an open infrastructure/materialization model
This openness is still constrained by explicit policy fixation points:
- late-configuration policy is fixed when the first selector rule is registered
- naming policy is fixed when the finalized
VariantSourcesContextis created
Late configuration policy
Openness of variantSources does not mean that late configuration is
semantically neutral.
Selector rules may be added after the finalized context becomes available, but
their behavior against already materialized GenericSourceSet objects must be
controlled explicitly.
Conceptually, variantSources exposes a policy choice such as:
variantSources { lateConfigurationPolicy { failOnLateConfiguration() } }
Available modes are:
failOnLateConfiguration()warnOnLateConfiguration()allowLateConfiguration()
Meaning:
failrejects selector rules that target already materialized source setswarnallows them but emits a warningallowallows them silently
This policy is intentionally modeled as an imperative choice, not as a mutable property:
- it must be chosen before the first selector rule is added
- selector rules here mean
variant(...),layer(...), andunit(...) - once chosen, it cannot be changed later
- it controls runtime behavior, not just a stored value
- the enforcement point is the first selector registration itself, not variants finalization in isolation
For source sets configured before materialization, selector precedence remains:
variant < layer < unit
For already materialized source sets in warn and allow modes:
- the late action is applied as an imperative follow-up step
- selector precedence is not reconstructed retroactively
- actual observation order is the order in which late actions are registered
Compile-unit naming policy
Source-set naming is treated as a separate policy concern from selector registration.
Conceptually, variantSources exposes:
variantSources { namingPolicy { failOnNameCollision() } }
The base projected name of a compile unit is:
variantName + capitalize(layerName)
Examples:
(browser, main)->browserMain(browser, rjs)->browserRjs
Available modes are:
failOnNameCollision()- reject finalized compile-unit models that project the same source-set name for different compile unitsresolveNameCollision()- resolve such conflicts deterministically
resolveNameCollision() semantics
Conflicting compile units are ordered canonically by:
(variant.name, layer.name)
Within one conflicting group:
- the first compile unit keeps the base name
- the second gets suffix
2 - the third gets suffix
3 - and so on
For example, if:
(foo, variantBar)projects tofooVariantBar(fooVariant, bar)also projects tofooVariantBar
then canonical ordering yields:
(foo, variantBar)->fooVariantBar(fooVariant, bar)->fooVariantBar2
Fixation point
Naming policy is fixed when the finalized VariantSourcesContext is created.
Operationally this means:
- naming policy must be selected before
variantSources.whenFinalized(...)becomes observable - compile-unit names are projected and validated before queued
whenFinalized(...)callbacks are replayed - changing naming policy from inside a
whenFinalized(...)callback is too late
This differs intentionally from late-configuration policy:
- late-configuration policy is fixed by the first selector rule
- naming policy is fixed by finalized-context creation
VariantSourcesContext
variantSources.whenFinalized(...) remains useful, but not because variantSources itself is frozen.
Its purpose is to provide access to a finalized context derived from variants.
This context contains:
CompileUnitsViewRoleProjectionsViewSourceSetMaterializer
By the time the context becomes observable:
- compile-unit naming policy is already fixed
- symbolic source-set names for finalized compile units are already determined
Conceptually:
interface VariantSourcesContext { CompileUnitsView getCompileUnits(); RoleProjectionsView getRoleProjections(); SourceSetMaterializer getSourceSets(); }
This callback is also replayable.
Example:
variantSources.whenFinalized(ctx -> { var units = ctx.getCompileUnits(); var roles = ctx.getRoleProjections(); var sourceSets = ctx.getSourceSets(); });
SourceSetMaterializer
Purpose
SourceSetMaterializer is the official source of truth for materialized source sets.
It is responsible for:
- lazy creation of
GenericSourceSet - projecting finalized compile units to symbolic source-set names
- validating or resolving name collisions according to naming policy
- applying
layerRule - connecting a compile unit to a source set provider
- exposing source sets to adapters
This is the correct place to apply layerRule.
Adapters should not apply layer rules themselves.
Conceptual API
interface SourceSetMaterializer { NamedDomainObjectProvider<GenericSourceSet> getSourceSet(CompileUnit unit); }
Why SourceSetMaterializer should own layerRule application
If adapters applied layerRule directly, responsibility would leak across multiple layers:
- one component would know compile units
- another would know source semantics
- another would know how to configure
GenericSourceSet
This would make the model harder to reason about.
Instead:
layerRuleis DSL/spec-levelSourceSetMaterializeris execution/materialization-level- adapters are consumption-level
This gives a much cleaner separation.
GenericSourceSet as materialization target
GenericSourceSet is the materialized source aggregation object.
It is a good fit because it can represent:
- multiple logical source sets
- aggregated source directories
- declared outputs
- lazy registration through providers
The materializer is therefore the owner of:
- creating
GenericSourceSet - populating its source sets
- declaring outputs
- returning a provider for later use
How an adapter should use the model
Example:
variantSources.whenFinalized(ctx -> { for (CompileUnit unit : ctx.getCompileUnits().getUnits()) { var sourceSetProvider = ctx.getSourceSets().getSourceSet(unit); var variant = unit.variant(); var layer = unit.layer(); // create compile task for this compile unit // configure compiler options from variant semantics // use sourceSetProvider as task input } for (RoleProjection projection : ctx.getRoleProjections().getProjections()) { var units = ctx.getRoleProjections().getUnits(projection); // aggregate outputs of included compile units // use for publication or assembly } });
Why compile unit is (variant, layer) and not (variant, role) or (variant, role, layer)
Not (variant, role)
Because role is not a compilation boundary.
Role is a logical grouping of results.
Not (variant, role, layer)
Because role does not define the compile unit itself.
A compile unit is a unit of compilation, not a unit of publication grouping.
Correct interpretation
(variant, layer)= compile unit(variant, role)= logical result group(variant, role, layer)= membership relation between them
This is the most coherent separation.
Model boundaries
What belongs to variants
- declared domains:
Layer,Role,Variant - normalized relation
(variant, role, layer) - finalization lifecycle
- finalized
VariantsView
What belongs to derived views
- compile units:
(variant, layer) - role projections:
(variant, role)
What belongs to variantSources
- source semantics of layers
- source materialization rules
- lazy
GenericSourceSetprovisioning - source adapter integration
What does not belong to variants
- source directories
- base paths
- output declarations
- source set layout
- task registration
- compiler-specific assumptions
Design principles
1. Keep the core model small
The core model should only contain domain facts.
2. Separate domain truth from materialization
The existence of compile units comes from VariantsView, not from source rules.
3. Treat source materialization as infrastructure
variantSources is an interpretation layer, not the source of truth.
4. Prefer replayable finalized hooks
Adapters should not depend on raw Gradle lifecycle callbacks such as afterEvaluate.
5. Make late behavior explicit
Late configuration after materialization is a policy decision, not an implicit guarantee.
6. Keep heavy runtime objects behind providers
Materialized GenericSourceSet objects should remain behind a lazy API.
7. Make name-collision behavior explicit
Compile-unit naming must be governed by an explicit policy, not by incidental materialization order.
Summary
The model is intentionally split into two layers.
variants
A closed, finalized domain model:
LayerRoleVariant(variant, role, layer)
variantSources
An open, source-materialization layer:
- layer source rules
- compile-unit source set materialization
- compile-unit naming policy
- adapter-facing
GenericSourceSetproviders
Derived views
From the finalized variant model:
CompileUnitsView:(variant, layer)RoleProjectionsView:(variant, role)
Operational interpretation
variantdefines compilation semanticslayerpartitions compilationrolegroups results by purpose
This keeps the core model stable and minimal, while allowing source handling and adapter integration to remain flexible.
