Variants and Variant Artifacts
Overview
This document describes the artifact model built on top of variants and
variantSources.
The goal is to define:
- a stable artifact-facing model for outgoing contracts;
- a DSL for declaring slots and their inputs;
- a resolver bridge between
variantArtifactsandvariantSources; - extension points for deduplication and similar policies;
- a model that remains live during configuration and does not require an artificial freeze of slot content.
The design follows the same split already used elsewhere:
variantsdefines the closed domain topology;variantSourcesdefines source materialization semantics over that topology;variantArtifactsdefines outgoing artifact contracts over that topology.
Core idea
variantArtifacts is not the owner of the variant model and not the owner of
source-set materialization.
Its purpose is narrower:
- decide which variants participate in outgoing publication;
- define artifact slots for those outgoing variants;
- declare how each slot gathers its inputs.
This makes variantArtifacts an outgoing-contract layer, not a compilation or
source-materialization layer.
Model boundaries
What belongs to variants
- identities:
Variant,Role,Layer; - normalized topology relation
(variant, role, layer); - finalization of the core domain model;
VariantsViewand derived topology views.
What belongs to variantSources
- compile units and role projections derived from finalized
variants; - source-set naming policy;
- source-set materialization;
- lazy access to
GenericSourceSetproviders and their named outputs.
What belongs to variantArtifacts
- selection of outgoing variants;
- slot identities inside an outgoing variant;
- slot assembly declarations;
- root outgoing configurations for outgoing variants;
- slot-level assembly bodies;
- publication-facing hooks.
What does not belong to variantArtifacts
- ownership of variant existence;
- ownership of source-set naming;
- direct mutation of source materialization internals;
- compiler- or toolchain-specific logic;
- eager flattening of source inputs into files during DSL declaration.
Outgoing subset
Not every declared Variant must become outgoing.
variantArtifacts defines an outgoing subset of variants.
For each outgoing variant there is one root outgoing aggregate:
- one root consumable
Configuration; - one or more artifact slots;
- one primary slot;
- optional secondary slots.
This is why OutgoingConfiguration is a real model object and not merely a
publication event payload.
It represents a live build-facing aggregate:
- it has its own attributes;
- it is visible to other build logic as soon as registered;
- it contains lazy handles rather than eagerly materialized state;
- it is distinct from slot assembly state.
Identity-first split
The artifact model should preserve the same identity-first principle used for the rest of the project.
Identity objects
VariantSlotArtifactSlot = (variant, slot)
These objects are:
- cheap;
- replayable;
- suitable for discovery and selection.
Stateful objects
OutgoingConfigurationArtifactAssembly
These objects are:
- build-facing;
- allowed to contain Gradle lazy handles;
- obtained through dedicated APIs rather than embedded into identity objects.
Rule of thumb
- slot identity is cheap and replayable;
- inside one
OutgoingConfiguration,Slotis the natural local identity; ArtifactSlotis useful when slot identity must be referenced outside the parent outgoing configuration;- slot body is stateful and resolved separately;
- outgoing configuration is a live aggregate, not a mere snapshot.
Artifact model
Conceptually:
interface VariantArtifactsContext { VariantsView getVariants(); void all(Action<? super OutgoingConfiguration> action); Optional<OutgoingConfiguration> findArtifacts(Variant variant); OutgoingConfiguration requireArtifacts(Variant variant); ArtifactAssemblies getAssemblies(); }
interface OutgoingConfiguration { Variant getVariant(); NamedDomainObjectProvider<Configuration> getOutgoingConfiguration(); NamedDomainObjectContainer<Slot> getSlots(); Property<Slot> getPrimarySlot(); }
interface ArtifactAssemblies { ArtifactAssembly resolveSlot(ArtifactSlot slot); }
This intentionally uses a Gradle-style local slot container rather than a
separate ArtifactSlotsView.
The reason is simple:
- inside one
OutgoingConfiguration, slot identity is local and naturally expressed asSlot; - a dedicated
ArtifactSlotsViewwould suggest a detached readonly projection without clearly owning mutation/configuration semantics; NamedDomainObjectContainer<Slot>better matches the live configuration model and keeps the parent aggregate as the owner of slot structure.
ArtifactSlot remains useful, but only as a fully qualified identity outside
the parent aggregate:
- resolver APIs;
- global payloads;
- cross-variant references.
Important distinction:
OutgoingConfigurationdescribes outgoing publication structure;ArtifactAssemblydescribes how one slot artifact is assembled.
An ArtifactAssembly does not create the root outgoing configuration. It serves
one already declared slot inside that configuration.
Primary slot ownership
Primary status belongs to the parent outgoing configuration, not to the slot itself.
This is why the preferred container-level contract is:
Property<Slot> getPrimarySlot();
and not a slot-local boolean or a derived ArtifactSlot reference.
Reasons:
- the primary role is assigned by the parent aggregate;
- within one
OutgoingConfiguration, the variant identity is already known, soSlotis sufficient and avoids redundant(variant, slot)duplication; Propertymatches the rest of the Gradle-facing live model better than a customfindPrimarySlot()style API;Propertygives useful write/configure/finalize semantics without inventing a separate special lifecycle abstraction.
Expected usage:
- DSL or adapters may assign the primary slot through
set(...); - adapters that want to provide a default may use
convention(...); - the property may remain unset while the model is still incomplete;
- at materialization time the property may be finalized;
- if more than one slot exists and
primarySlotis still unset at the materialization point, that is a model error.
The model should still enforce that the selected primary slot belongs to the
same OutgoingConfiguration.
Proposed public shape
With these constraints, the preferred public structure is:
interface VariantArtifactsContext { VariantsView getVariants(); void all(Action<? super OutgoingConfiguration> action); Optional<OutgoingConfiguration> findArtifacts(Variant variant); OutgoingConfiguration requireArtifacts(Variant variant); ArtifactAssemblies getAssemblies(); } interface OutgoingConfiguration { Variant getVariant(); NamedDomainObjectProvider<Configuration> getOutgoingConfiguration(); NamedDomainObjectContainer<Slot> getSlots(); Property<Slot> getPrimarySlot(); } interface ArtifactAssemblies { ArtifactAssembly resolveSlot(ArtifactSlot slot); } record ArtifactSlot(Variant variant, Slot slot) {}
This gives:
- one global registry of outgoing variants;
- one local slot container per outgoing variant;
- one fully qualified slot identity for resolver and cross-aggregate use;
- one explicit service for slot assembly materialization.
Minimal internal shape
The preferred minimal internal structure is:
final class VariantArtifactsRegistry implements VariantArtifactsContext { OutgoingConfiguration outgoingConfiguration(Variant variant); ArtifactAssemblyRules slotRules(ArtifactSlot slot); ArtifactAssemblies assemblies(); } interface ArtifactAssemblyRules { void from(Object input); void fromVariant(Action<? super OutputSelectionSpec> action); void fromRole(String roleName, Action<? super OutputSelectionSpec> action); void fromLayer(String layerName, Action<? super OutputSelectionSpec> action); }
Responsibility split:
VariantArtifactsRegistryowns the whole artifact model;OutgoingConfigurationowns only the structural variant-local aggregate;ArtifactAssemblyRulesowns the content declaration of one slot;ArtifactAssembliesmaterializesArtifactAssemblyfromArtifactSlot -> ArtifactAssemblyRules.
This keeps OutgoingConfiguration focused on structure and avoids overloading it
with slot-content APIs such as rules(slot).
DSL binding
The DSL should be connected through the registry, not by making
OutgoingConfiguration responsible for content rules.
Conceptually:
variantArtifacts.variant("browser", spec -> { spec.slot("runtime", assembly -> { assembly.fromRole("production", out -> out.output("js")); }); });
Operationally this means:
- registry creates or returns
OutgoingConfigurationfor the variant; - slot declaration creates or returns
Slotinside that outgoing configuration; - registry forms
ArtifactSlot(variant, slot); - registry resolves
slotRules(artifactSlot); - bound
ArtifactAssemblySpecwrites into those rules.
So the DSL writes:
- structure into
OutgoingConfiguration; - slot content into registry-owned
ArtifactAssemblyRules.
This is the intended bridge point between the public DSL and the internal resolver/materialization model.
Live model and monotonic structure
variantArtifacts should be treated as a live configuration model during the
whole configuration phase.
This means:
- slot inputs remain live;
from(...),fromVariant(...),fromRole(...),fromLayer(...)may keep contributing inputs until task execution;ArtifactAssemblymay expose liveFileCollection,Provider, and task wiring;- external task outputs remain outside the control of this model and must be accepted as live inputs.
The model should therefore avoid a mandatory freeze phase for slot content.
Instead, it should follow a monotonic rule:
- outgoing variant existence may grow;
- slot existence may grow;
- slot content may grow;
- publication-visible identity should not be retroactively redefined.
In practice this means:
- slot names are stable once declared;
- primary slot designation is structural;
- slot input content remains live.
This also means that the model does not need a dedicated freeze phase for slot content merely because the root outgoing configuration was registered earlier.
Early registration of the root Configuration and live evolution of slot input
content are compatible concerns.
DSL principles
The DSL should remain declarative and symbolic.
It should describe:
- which variant is outgoing;
- which slots exist;
- which slot is primary;
- which selectors contribute inputs to each slot.
It should not directly expose:
GenericSourceSet;FileCollection;- concrete resolved files from
variantSources; - internal resolver state.
DSL shape
Conceptually:
variantArtifacts { variant("browser") { primarySlot("runtime") { fromRole("production") { output("js") output("resources") } } slot("types") { fromVariant { output("dts") } } slot("sources") { fromLayer("main") { output("sources") } } slot("bundleMetadata") { from(someTask) from(layout.buildDirectory.file("generated/meta.json")) } } }
Meaning of contribution forms
from(Object)adds a direct input independent fromvariantSources;fromVariant { output(...) }selects named outputs from all compile units of the current variant;fromRole(role) { output(...) }selects named outputs from compile units that belong to the given role projection;fromLayer(layer) { output(...) }selects named outputs from the compile unit of the current variant and the given layer, if such unit exists.
The DSL stores declarations, not resolved file collections.
Contribution model
Internally the DSL should compile to slot contributions.
Conceptually:
DirectContributionVariantOutputContributionRoleOutputContributionLayerOutputContribution
These contributions should remain symbolic for as long as possible.
They should not resolve source sets or files at declaration time.
Each contribution is expected to provide:
- its selection scope;
- the requested output names;
- enough symbolic identity for later validation and resolver policies.
Resolver bridge between variantSources and variantArtifacts
This is the central integration point.
variantArtifacts should not access mutable internals of variantSources.
Instead, it should resolve slot inputs through the public finalized
VariantSourcesContext.
Bridge responsibilities
The bridge:
- takes slot contribution declarations;
- expands them against finalized variant topology;
- maps logical selectors to compile units and role projections;
- obtains source sets lazily through
VariantSourcesContext; - resolves named outputs from those source sets;
- builds the live input model for an
ArtifactAssembly.
Bridge input
- current outgoing variant identity;
- slot contribution declarations;
VariantSourcesContext.
Bridge output
- a live collection of logical slot inputs;
- later adapted to
FileCollectionor other assembly-facing input models.
Resolution semantics
For one outgoing variant:
fromVariant { output(x) }- expands to all
CompileUnitof that variant; fromRole(role) { output(x) }- expands to
RoleProjection(variant, role)and then to its compile units; fromLayer(layer) { output(x) }- expands to one compile unit
(variant, layer)when it exists; from(Object)- bypasses
variantSourcescompletely.
After compile units are known, the bridge asks
ctx.getSourceSets().getSourceSet(unit) for each selected unit and resolves the
requested named output.
This keeps variantArtifacts independent from source-set naming internals and
other materialization details.
Validation
Validation should be structural and symbolic.
It should validate:
- outgoing variant refers to an existing
Variant; - referenced
Roleexists in that variant projection space; - referenced
Layerexists in that variant compile-unit space; - primary slot is defined when needed;
- primary slot refers to a slot declared in the same outgoing configuration.
Validation should not require eager materialization of source sets or eager resolution of files.
Deduplication and policy extension points
Deduplication is important, but it should not be baked into the DSL itself.
The correct place for it is the resolver bridge, after symbolic contributions have been expanded to logical inputs but before they are finally adapted to assembly-facing file collections.
Why not in the DSL
At declaration time it is still unknown whether selectors overlap:
fromVariantfromRolefromLayer
may all describe the same logical source output.
Why not rely only on FileCollection
FileCollection may still provide useful physical deduplication, but it is too
late and too file-oriented to serve as the only semantic mechanism.
The artifact model should first deduplicate logical inputs, then let Gradle perform any additional physical deduplication.
Default expectation
The default resolver should support:
- deduplication of topology-aware inputs by logical identity;
- no implicit deduplication of direct
from(Object)inputs.
Logical identity should be based on domain meaning, for example:
(CompileUnit, outputName)
and not on projected source-set names.
This is important because source-set naming policy belongs to variantSources
and must not silently redefine artifact semantics.
Extension points
The model should provide explicit internal extension points for:
- deduplication policy;
- logical input identity;
- adaptation of resolved logical inputs to assembly-facing objects.
Conceptually:
interface SlotInputDedupPolicy { ... } interface LogicalSlotInputIdentity { ... } interface SlotInputAdapter { ... }
The default implementation may remain simple, but these seams should exist from the start.
Publication hooks
Publication hooks remain useful, but they should observe the live structural model rather than define it.
Examples:
whenOutgoingVariant(...)whenOutgoingSlot(...)
These hooks are adapter-facing customization points over already declared outgoing structure:
- root configuration attributes;
- slot artifact attributes;
- assembly task tweaks.
The recommended way to connect publication-facing Spec objects to the
structural model is by backlink, not by moving slot rules into publication
types.
Conceptually:
interface OutgoingConfigurationSpec { OutgoingConfiguration getOutgoingArtifacts(); Variant getVariant(); Configuration getConfiguration(); } interface OutgoingArtifactSlotSpec { ArtifactSlot getArtifactSlot(); ArtifactAssembly getAssembly(); boolean isPrimary(); }
In this arrangement:
OutgoingConfigurationSpecremains a publication-facing facade;OutgoingConfigurationSpecmay expose the structural aggregate when an adapter needs it;- slot rules still belong to
VariantArtifactsRegistry; - publication specs do not become owners of declaration or resolver state.
They should not become the primary structural API for the artifact model.
This is why a separate phase-oriented OutgoingPublicationsContext is not
required.
The live OutgoingConfiguration aggregate is sufficient.
Design principles
1. Keep topology ownership in variants
variantArtifacts selects from the topology model. It does not own it.
2. Keep source ownership in variantSources
variantArtifacts consumes source materialization through a resolver bridge. It
does not own source-set semantics.
3. Keep the DSL symbolic
The DSL declares intent and selection rules, not materialized files.
4. Keep slot content live
Do not introduce an artificial finalize phase for slot content unless a real semantic need appears.
5. Fix only structural identity
Slot name, primary designation, and outgoing shape are structural. Slot inputs remain live.
6. Resolve through dedicated bridges
Cross-model integration belongs in a resolver service, not in DSL classes.
7. Add policy seams early
Deduplication and similar concerns should have extension points from the start, even if the initial implementation is conservative.
Summary
variantArtifacts should be modeled as a live outgoing-contract layer over
variants, with source input resolution delegated to a dedicated bridge over
variantSources.
The resulting shape is:
variantsowns topology;variantSourcesowns source materialization;variantArtifactsowns outgoing contract structure;- a resolver bridge connects symbolic slot declarations to live source-derived inputs;
- deduplication and similar concerns are policies of that bridge, not of the DSL itself;
- slot content stays live during configuration;
- only publication-visible structure is treated as stable identity.
