gradle/gradle · error · InvalidUserDataException

Cannot declare module replacement %s->%s because it introduc

Error message

Cannot declare module replacement %s->%s because it introduces a cycle: %s

What it means

Whenever a new module replacement module(a){ replacedBy(b) } is declared, detectCycles() walks the existing replacement chain starting from the new target. If the walk ever revisits a module already on the path, resolution would loop forever, so Gradle throws InvalidUserDataException and prints the exact cycle (source->target->...->source).

Source

Thrown at platforms/software/dependency-management/src/main/java/org/gradle/api/internal/artifacts/dsl/ComponentModuleMetadataContainer.java:96

    private static void detectCycles(Map<ModuleIdentifier, ImmutableModuleReplacements.Replacement> replacements, ModuleIdentifier source, ModuleIdentifier target) {
        if (source.equals(target)) {
            throw new InvalidUserDataException(String.format("Cannot declare module replacement that replaces self: %s->%s", source, target));
        }

        ModuleIdentifier m = unwrap(replacements.get(target));
        if (m == null) {
            //target does not exist in the map, there's no cycle for sure
            return;
        }
        Set<ModuleIdentifier> visited = new LinkedHashSet<>();
        visited.add(source);
        visited.add(target);

        while(m != null) {
            if (!visited.add(m)) {
                //module was already visited, there is a cycle
                throw new InvalidUserDataException(
                        format("Cannot declare module replacement %s->%s because it introduces a cycle: %s",
                                source, target, Joiner.on("->").join(visited) + "->" + source));
            }
            m = unwrap(replacements.get(m));
        }
    }

    private static ModuleIdentifier unwrap(ImmutableModuleReplacements.Replacement replacement) {
        return replacement == null ? null : replacement.getTarget();
    }

    private static NotationParser<Object, ModuleIdentifier> parser(ImmutableModuleIdentifierFactory moduleIdentifierFactory) {
        return NotationParserBuilder
                .toType(ModuleIdentifier.class)
                .fromCharSequence(new ModuleIdentifierNotationConverter(moduleIdentifierFactory))
                .toComposite();
    }
}

View on GitHub (pinned to 534f27719b)

Solutions

  1. Pick one canonical module and make every other module point to it (a chain, never a ring) — e.g. keep g:a->g:b and change or delete g:b->g:a.
  2. Audit all components.modules / componentModule declarations across every applied script and plugin and remove the redundant reverse mapping that closes the loop.
  3. Rerun the build: the message includes the full cycle path (source->...->source), which names exactly which declarations to fix.

Example fix

// before
components {
    modules {
        module("com.old:lib")  { replacedBy("com.new:lib") }
        module("com.new:lib")  { replacedBy("com.old:lib") } // closes the cycle
    }
}

// after
components {
    modules {
        module("com.old:lib") { replacedBy("com.new:lib") } // single direction only
    }
}
Defensive patterns

Strategy: validation

Validate before calling

def declareReplacements(Map<String, String> replacements) {
    def seen = new HashSet<String>()
    replacements.each { src, tgt ->
        def cur = tgt
        while (cur != null) {
            assert cur != src : "Replacement cycle detected involving $src"
            cur = replacements[cur]
        }
    }
    components.modules { replacements.each { s, t -> module(s).replacedBy(t) } }
}

Prevention

When it happens

Trigger: module("g:a"){ replacedBy("g:b") } combined with module("g:b"){ replacedBy("g:a") }; longer rings such as g:a->g:b->g:c->g:a; any replacement whose target transitively resolves back to the source.

Common situations: Replacement declarations spread across multiple build scripts or plugins that individually look fine but together close a ring; two teams aliasing modules toward each other during a migration/merge; incrementally adding reverse-mappings after a rename.

Related errors


AI-assisted analysis of gradle/gradle@534f27719b (2026-08-22). Data as JSON: /api/errors/56c3d82874d1fe6e. Report an issue: GitHub.