This document captures the discussion about validating <sources> configuration in Maven 4.x.

Scope

This document focuses exclusively on the Maven 4.x core implementation (master branch of apache/maven). Specifically:

  • The <sources> element introduced in POM model version 4.1.0
  • The source/resource handling logic in DefaultProjectBuilder.java
  • Validation gaps in the core that should be addressed

Compiler Plugin Compensation

Some validation gaps in the core are currently compensated by the maven-compiler-plugin, which performs its own validation at compile time, e.g.,

Mixed modular/non-modular sources → compiler plugin fails with "Mix of modular and non-modular sources", cf. ToolExecutor.java#L595-L608

However, relying on plugin-level validation is suboptimal:

  1. Later feedback: Errors appear during compilation, not during POM processing
  2. No line numbers: Plugin errors cannot reference the exact POM location
  3. Plugin-specific: Other language plugins (Kotlin, Groovy, Scala) may not have equivalent validation
  4. Incomplete coverage: Some invalid configurations are not caught at all (e.g., test-only modular projects)

The goal should be to implement proper validation in the Maven core, providing early, consistent feedback with precise error locations via ModelProblem.

Origin of This Discussion

This discussion was triggered by investigating how to best implement resource handling in the new modular source directory hierarchy. Maven 4.x introduces a unified <sources> element that supports modular layouts like:

src/
├── org.foo.moduleA/
│   ├── main/
│   │   ├── java/
│   │   └── resources/    ← Module-specific resources
│   └── test/
│       ├── java/
│       └── resources/
└── org.foo.moduleB/
    ├── main/
    │   ├── java/
    │   └── resources/    ← Module-specific resources
    └── test/

However, the current implementation does not automatically pick up resources from the modular paths (src/<module>/main/resources). Instead, it always uses the legacy <resources> element which defaults to src/main/resources.

Solution: See Phase 1 for the implementation that automatically injects module-aware resources.

Current Workaround: Users can configure the maven-resources-plugin with explicit executions for each module:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-resources-plugin</artifactId>
  <executions>
    <execution>
      <id>copy-resources-moduleA</id>
      <phase>process-resources</phase>
      <goals><goal>copy-resources</goal></goals>
      <configuration>
        <resources>
          <resource>
            <directory>src/org.foo.moduleA/main/resources</directory>
          </resource>
        </resources>
        <outputDirectory>${project.build.outputDirectory}</outputDirectory>
      </configuration>
    </execution>
    <execution>
      <id>copy-resources-moduleB</id>
      <phase>process-resources</phase>
      <goals><goal>copy-resources</goal></goals>
      <configuration>
        <resources>
          <resource>
            <directory>src/org.foo.moduleB/main/resources</directory>
          </resource>
        </resources>
        <outputDirectory>${project.build.outputDirectory}</outputDirectory>
      </configuration>
    </execution>
  </executions>
</plugin>

This workaround is verbose and error-prone. The investigation into implementing this properly in the core led to the Phase 1 implementation and revealed the broader set of validation and configuration handling issues documented here.


Problem Statement

Let's investigate some of the problems with the current implementation of <sources> handling in Maven 4.x. Maven 4.x introduces a new <sources> element that supports modular project layouts (src/<module>/<scope>/<lang>). However, the current implementation has several issues when handling the interaction between the new <sources> element and legacy configuration elements (<sourceDirectory>, <resources>, etc.).

This is not a comprehensive list, but highlights key problems that should be addressed in the core. A systematic Analysis and Proposed Solutions follow in subsequent sections.

Problem 1: Mixed Modular/Non-Modular Sources Accepted Silently

Mixing modular and non-modular sources in <sources> is an invalid configuration that will fail at compile time, but DefaultProjectBuilder accepts it silently without early validation.

Example - Mixed configuration:

<build>
  <sources>
    <source>
      <scope>main</scope>
      <lang>java</lang>
      <module>org.foo.moduleA</module>  <!-- Modular -->
    </source>
    <source>
      <scope>main</scope>
      <lang>java</lang>
      <!-- No module - classic style -->
    </source>
  </sources>
</build>
Expected BehaviorActual Behavior
Early warning/error about inconsistent configurationSilent acceptance by DefaultProjectBuilder
Clear feedback during project buildDelayed failure at compile time

Current Workaround: The maven-compiler-plugin validates this at compile time and fails with:

"Mix of modular and non-modular sources."

See above (Compiler Plugin Compensation) for drawbacks of this approach.

Problem 2: Test-Only Modular Projects Get Classic Main Sources Injected

When a project configures only test sources in <sources> (no main sources), DefaultProjectBuilder silently injects the classic src/main/java as a main source directory.

Example - Integration test module with only test sources:

<build>
  <sources>
    <source>
      <scope>test</scope>
      <lang>java</lang>
      <module>org.foo.integrationTests</module>
    </source>
  </sources>
</build>
Expected BehaviorActual Behavior
No main sources (project is test-only)src/main/java silently added as main source
Test sources from src/org.foo.integrationTests/test/javaTest sources work correctly

Root cause: The hasMain boolean remains false when no <source> has scope=main + lang=java. Line 697-698 then adds build.getSourceDirectory() (defaults to src/main/java from Super POM).

See: DefaultProjectBuilder.java#L697-L698

Impact:

  • If src/main/java doesn't exist → probably harmless (empty directory)
  • If src/main/java exists with old/unrelated code → it gets compiled!
  • Modular test project silently becomes a mixed modular-test + classic-main project

Use cases affected:

  • Integration test modules (only contain tests)
  • Test fixtures modules (shared test utilities)
  • BOM/parent projects with test verification but no main code

Note: Unlike Problem 1, this is not caught by the compiler plugin - it silently succeeds but with potentially wrong behavior.

Problem 3: Resources Are Not Module-Aware

When a project uses modular sources, resources should follow the same modular layout. Currently, they don't.

Example - Modular Java sources without explicit resources:

<build>
  <sources>
    <source>
      <scope>main</scope>
      <lang>java</lang>
      <module>org.foo.moduleA</module>
    </source>
    <source>
      <scope>main</scope>
      <lang>java</lang>
      <module>org.foo.moduleB</module>
    </source>
  </sources>
</build>
Expected BehaviorActual Behavior
Resources from src/org.foo.moduleA/main/resourcesResources from src/main/resources only
Resources from src/org.foo.moduleB/main/resources(legacy path, not module-aware)

Impact: Module-specific resources (like module-specific configs) cannot be organized per-module.

Solution: See Phase 1 for the implementation.

Workaround (no longer needed): See Origin of This Discussion for how to configure the maven-resources-plugin with explicit executions for each module.

Problem 4: Legacy Configuration Silently Ignored

When <sources> is present, legacy elements like <sourceDirectory> are silently ignored without warning.

Example - Explicit sourceDirectory with sources:

<build>
  <sourceDirectory>src/custom/java</sourceDirectory>  <!-- User expects this to be used -->
  <sources>
    <source>
      <scope>main</scope>
      <lang>java</lang>
      <module>org.foo.bar</module>
    </source>
  </sources>
</build>
Expected BehaviorActual Behavior
Warning: "sourceDirectory is ignored because sources are configured"Silent - user doesn't know their config is ignored
Clear feedback to userConfusion when src/custom/java isn't used

Analysis

Core Design Question

How should the source reading and validation logic work?

The fundamental question is about priority and conflict resolution when both new (<sources>) and legacy (<sourceDirectory>, <resources>) configuration elements are present.

Key Design Principle: Modular Sources Have Priority

When a project uses the new <sources> element with modular configuration (<module> element), the modular approach should take precedence:

  1. Modular sources (<sources> with <module>) demand proper modular layout and behavior
  2. Non-modular sources (<sources> without <module>) should not be used (error or warning) in a modular project
  3. Legacy configuration (<sourceDirectory>, <resources>) is used only as fallback when no <sources> are configured

This principle ensures:

  • Clear, predictable behavior
  • No silent mixing of old and new approaches
  • Users explicitly opt-in to the new model

Path Resolution Logic

From DefaultSourceRoot.fromModel()abbreviated pseudo code of the current implementation:

if (module specified) {
    // Modular mode
    path = directory specified ? baseDir.resolve(directory)
                               : baseDir/src/<module>/<scope>/<lang>
} else {
    // Classic mode
    path = directory specified ? baseDir.resolve(directory)
                               : baseDir/src/<scope>/<lang>
}
ModuleDirectoryModeResult
NoNoClassicsrc/<scope>/<lang> (default)
NoYesClassic<directory> (override)
YesNoModularsrc/<module>/<scope>/<lang> (default)
YesYesModular<directory> (override, module = metadata)

Strict vs Lenient Approach

Two approaches are possible for handling configuration conflicts:

Approach A: Strict (Fail Fast)

Configuration conflicts result in errors that fail the build.

RuleConditionSeverityMessage
R1<sources> present AND explicit <sourceDirectory>ERROR"Cannot combine <sources> with explicit <sourceDirectory>. Use one or the other."
R2<sources> with mixed module/no-module elementsERROR"Cannot mix modular and non-modular sources. All <source> elements must either have a module or none."
R3Duplicate <source> (same scope/lang/module)ERROR"Duplicate source definition for scope='X', lang='Y', module='Z'"
R4<sources> configured but classic directory exists on filesystemWARN"Directory 'src/main/java' exists but will be ignored because <sources> is configured"

Approach B: Lenient (Warn and Continue)

Configuration conflicts result in warnings but the build continues.

RuleConditionSeverityMessage
R1'<sources> present AND explicit <sourceDirectory>WARN"Explicit <sourceDirectory> will be ignored because <sources> is configured."
R2'<sources> with mixed module/no-module elementsWARN"Mixing modular and non-modular sources may lead to unexpected behavior."
R3Duplicate <source> (same scope/lang/module)ERROR"Duplicate source definition for scope='X', lang='Y', module='Z'"
R4<sources> configured but classic directory exists on filesystemWARN"Directory 'src/main/java' exists but will be ignored because <sources> is configured"

Comparison

AspectStrict (A)Lenient (B)
<sources> + explicit SD/TSDERRORWARN
Mixed module/no-moduleERRORWARN
Duplicate sourcesERRORERROR
Filesystem mismatchWARNWARN
Migration friendlinessLowerHigher
User confusion riskLowerHigher
Fail-fast principleYesNo

Recommendation

Approach A (Strict) is recommended for new projects because:

  1. Configuration conflicts indicate user error/misunderstanding
  2. Silent ignoring of explicit configuration is confusing
  3. Better to fail early than have unexpected behavior at compile time
  4. Users can easily fix by removing the conflicting element

Approach B (Lenient) could be considered if:

  1. Migration from Maven 3.x to 4.x needs to be smoother
  2. Tooling (IDEs) may generate both elements during transition
  3. A deprecation period is desired before enforcing strict rules

Unified Permutation Matrix

This matrix shows all configuration combinations and their expected behavior under each approach.

Legend

AbbreviationMeaning
SD<sourceDirectory> - classic Maven 3.x element for main Java sources
TSD<testSourceDirectory> - classic Maven 3.x element for test Java sources
S<source> element within <sources> - Maven 4.x way to define sources
R<resources> / <resource> - classic Maven 3.x element for resources
M<module> element within <source> - specifies JPMS module name

Java Sources

#ConfigurationCurrentLenientStrictCompiler Plugin

No <sources> (classic mode)



1SD=implicit (Super POM)src/main/javaOKOK-
2SD=explicituser's pathOKOK-

<sources> present (new mode)



3S(no M), SD=implicitsrc/main/javaOKOK-
4S(no M), SD=explicitSilent ignoreWARNERRORNot caught
5S(M=X), SD=implicitsrc/X/main/javaOKOK-
6S(M=X), SD=explicitSilent ignoreWARNERRORNot caught
7S(M=X) + S(M=Y)Both pathsOKOK-
8S(M=X) + S(no M)Silent acceptWARNERRORCaught
9S(dir=custom, no M)custom pathOKOK-
10S(dir=custom, M=X)custom (M=meta)OKOK-
11Duplicate SSilent acceptERRORERRORNot caught
12S(M=X) + src/main/java existsSilent ignoreWARNWARNNot caught
13S(scope=test, M=X) onlyInjects src/main/javaNo injectNo injectNot caught ⚠️

Resources

#S ConfigR ConfigCurrentProposed (Phase 1)LenientStrict
1S(lang=resources)R=anyDuplicates!Use S, WARN if R explicitUse S, WARNUse S, WARN
2S(java, no M)R=implicitsrc/main/resourcessrc/main/resourcesOKOK
3S(java, no M)R=explicitUser's pathUser's pathOKOK
4S(java, M=X)R=implicitsrc/main/resourcesInject modularInject modularInject modular
5S(java, M=X)R=explicitUser's pathInject modular, WARNWARNERROR

Current Implementation Status

As of commit 25c80d8

Implementation Location

The source/resource handling logic is in DefaultProjectBuilder.initProject():

// Lines 669-686: Iterate over <sources> and track what's present
boolean hasScript = false;
boolean hasMain = false;   // true if ANY <source> has lang=java, scope=main
boolean hasTest = false;   // true if ANY <source> has lang=java, scope=test
for (var source : sources) {
    var src = DefaultSourceRoot.fromModel(session, baseDir, outputDirectory, source);
    project.addSourceRoot(src);
    // ... tracking logic
}

// Lines 694-701: Decide whether to use legacy sourceDirectory/testSourceDirectory
// (Lines 687-692 contain a comment explaining this behavior)
if (!hasScript) {
    project.addScriptSourceRoot(build.getScriptSourceDirectory());
}
if (!hasMain) {
    project.addCompileSourceRoot(build.getSourceDirectory());  // Silent fallback
}
if (!hasTest) {
    project.addTestCompileSourceRoot(build.getTestSourceDirectory());  // Silent fallback
}

// Lines 703-708: Resources are ALWAYS added from legacy elements (no checks!)
for (Resource resource : project.getBuild().getDelegate().getResources()) {
    project.addSourceRoot(new DefaultSourceRoot(baseDir, ProjectScope.MAIN, resource));
}
for (Resource resource : project.getBuild().getDelegate().getTestResources()) {
    project.addSourceRoot(new DefaultSourceRoot(baseDir, ProjectScope.TEST, resource));
}

Gap Analysis

IssueDescriptionCore StatusCompiler Plugin
No warningsExplicit <sourceDirectory> silently ignored when <sources> presentNot implementedNot caught
No mixed validationMixing S(M=X) with S(no M) silently acceptedNot implementedCaught
Test-only injectionsrc/main/java injected for test-only modular projectsNot implementedNot caught ⚠️
No duplicates checkSame source can be defined multiple timesNot implementedNot caught
No modular resourcesResources always from legacy <resources> element✅ Implemented (Phase 1)N/A

Key insight: Only the mixed modular/non-modular validation (#8) is currently caught by the compiler plugin. All other issues require core implementation.

What Works

  • Processing <source> elements and adding them as source roots
  • Fallback to legacy <sourceDirectory> when no Java sources in <sources>
  • Path resolution for both modular and classic layouts

What's Missing

  • Warnings when explicit configuration is ignored
  • Validation for mixed modular/non-modular sources (currently deferred to compiler plugin)
  • Correct handling of test-only modular projects (no main injection)
  • Duplicate detection
  • Modular resource handling ✅ Implemented in Phase 1

Implementation Approaches

Phase 1: Module-Aware Resource Handling (Implemented)

Goal: Enable modular resource handling without requiring explicit maven-resources-plugin configuration.

Status: ✅ Implemented in PR 11505

Implementation:

  1. Resource tracking via <sources>: (Lines 672-673)

    boolean hasMainResources = false;
    boolean hasTestResources = false;
    for (var source : sources) {
        if (Language.RESOURCES.equals(language)) {
            if (ProjectScope.MAIN.equals(scope)) {
                hasMainResources = true;
            } else {
                hasTestResources |= ProjectScope.TEST.equals(scope);
            }
        }
    }
  2. Module extraction to detect modular projects: (Lines 713-714, extractModules() at L1256)

    Set<String> modules = extractModules(sources);
    boolean isModularProject = !modules.isEmpty();
  3. Module-aware resource injection for modular projects: (Lines 729-786, createModularResourceRoot() at L1275)

    • If resources configured via <sources>: use them (already added during iteration)
    • If no resources in <sources>: inject module-aware defaults for each module
    • Warn (as ModelProblem) if legacy <resources> is present but ignored
    if (isModularProject) {
        if (hasMainResources) {
            // Already added via <sources>, warn if legacy <resources> present
        } else {
            // Inject module-aware defaults: src/<module>/main/resources
            for (String module : modules) {
                project.addSourceRoot(createModularResourceRoot(baseDir, module, ProjectScope.MAIN, ...));
            }
        }
    }
  4. Super POM default detection: (hasOnlySuperPomDefaults() at L1303)

    • hasOnlySuperPomDefaults() checks if <resources> contains only inherited defaults
    • Warnings are only issued for explicitly configured legacy resources, not Super POM defaults

Priority Hierarchy (Proposed):

PriorityConditionBehavior
1Modular project + resources in <sources>Use <sources> resources, warn if legacy present
2Modular project + no resources in <sources>Inject src/<module>/<scope>/resources for each module
3Classic project + resources in <sources>Use <sources> resources, warn if legacy present
4Classic project + no resources in <sources>Use legacy <resources> element

Example - Would Work Without Explicit Plugin Configuration:

<build>
  <sources>
    <source>
      <scope>main</scope>
      <lang>java</lang>
      <module>org.foo.moduleA</module>
    </source>
    <source>
      <scope>main</scope>
      <lang>java</lang>
      <module>org.foo.moduleB</module>
    </source>
  </sources>
</build>

Resources would be automatically picked up from:

  • src/org.foo.moduleA/main/resources
  • src/org.foo.moduleB/main/resources

What's NOT Addressed in This Proposal:

  • Problem 1: Mixed modular/non-modular sources validation (still deferred to compiler plugin)
  • Problem 2: Test-only modular project main injection
  • Problem 4: Warning when explicit <sourceDirectory> is ignored
  • Duplicate source detection
  • Per-module tracking (project-level booleans still used)

Phase 2: Comprehensive Refactoring

Goal: Full implementation of the design principles from Section 2 with proper validation.

Scope:

  1. Per-module tracking instead of project-level booleans:

    record ModuleConfig(boolean hasMain, boolean hasTest, boolean hasMainResources, boolean hasTestResources) {}
    Map<String, ModuleConfig> moduleConfigs = new HashMap<>();
  2. Validation in ModelValidator (not DefaultProjectBuilder):

    • Move conflict detection to validateEffectiveModel()
    • Report ModelProblem with line numbers and severity
    • Early feedback during POM processing
  3. Strict validation rules (from Section 2.2):

    RuleConditionSeverityMessage
    R1<sources> + explicit <sourceDirectory>ERROR"Cannot combine <sources> with explicit <sourceDirectory>"
    R2Mixed modular/non-modular sourcesERROR"Cannot mix modular and non-modular sources"
    R3Duplicate <source> definitionERROR"Duplicate source for scope='X', lang='Y', module='Z'"
    R4<sources> configured but classic dir existsWARN"Directory 'src/main/java' exists but will be ignored"
  4. Fix test-only modular project injection (Problem 2):

    // Don't inject legacy main sources for modular projects
    if (!hasMain && !isModularProject) {
        project.addCompileSourceRoot(build.getSourceDirectory());
    }
  5. Warning when explicit configuration is ignored (Problem 4):

    if (hasMain && isExplicitSourceDirectory(build)) {
        // Report ModelProblem with line number
    }
  6. InputLocation-based detection for explicit vs inherited configuration:

    InputLocation location = build.getLocation("sourceDirectory");
    boolean isExplicit = location != null && !isSuperPomLocation(location);

Implementation Location:

ComponentResponsibility
DefaultModelValidatorValidation rules R1-R4, early feedback with line numbers
DefaultProjectBuilderSource root creation, fallback logic
DefaultSourceRootPath resolution (already implemented)

Implementation Timeline

PhaseScopeStatus
Phase 1Module-aware resource handling✅ Implemented (PR 11505)
Phase 2Full validation + per-module tracking + ModelValidator integrationPending

Reference Material

Model Version vs Maven Version

Model VersionMaven VersionNotes
4.0.0Maven 3.xClassic POM model, no <sources> element
4.1.0Maven 4.xIntroduces <sources> element
4.2.0FutureReserved for future use

The <sources> element requires model version 4.1.0:

<project xmlns="http://maven.apache.org/POM/4.1.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.1.0 https://maven.apache.org/xsd/maven-4.1.0.xsd">
    <modelVersion>4.1.0</modelVersion>
    ...
</project>

The Maven 4.x Unified Source Model

In Maven 4.x, all source types can be configured via <sources>:

<sources>
  <!-- Java sources -->
  <source>
    <scope>main</scope>
    <lang>java</lang>
    <module>org.foo.bar</module>
  </source>
  <!-- Resources -->
  <source>
    <scope>main</scope>
    <lang>resources</lang>
    <module>org.foo.bar</module>
  </source>
</sources>

The <lang> element accepts:

  • java - compiled by compiler plugin (default if omitted)
  • resources - processed by resources plugin
  • script - deprecated, use resources instead

Extensible via LanguageProvider SPI for other languages (Kotlin, Groovy, etc.).

Processing Flow

┌─────────────────────────────────────────────────────────────────┐
│                         POM Model                               │
│  <sources>                                                      │
│    <source><lang>java</lang><module>X</module></source>         │
│    <source><lang>resources</lang><module>X</module></source>    │
│  </sources>                                                     │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    DefaultProjectBuilder                        │
│  Creates SourceRoot objects from <source> elements              │
│  Adds to project.addSourceRoot(...)                             │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                      MavenProject                               │
│  getEnabledSourceRoots(ProjectScope scope, Language language)   │
│  - Plugins query this to find relevant sources                  │
└─────────────────────────────────────────────────────────────────┘
                              │
              ┌───────────────┴───────────────┐
              ▼                               ▼
┌─────────────────────────┐     ┌─────────────────────────┐
│    Compiler Plugin      │     │   Resources Plugin      │
│  Queries: lang=java     │     │ Queries: lang=resources │
└─────────────────────────┘     └─────────────────────────┘

Detecting Explicit vs Implicit Configuration

To detect whether <sourceDirectory> was explicitly configured (vs inherited from Super POM):

// Option 1: Compare to known Super POM default
String superPomDefault = "${project.basedir}/src/main/java";
String resolvedDefault = baseDir.resolve("src/main/java").toString();
boolean isExplicit = !build.getSourceDirectory().equals(resolvedDefault);

// Option 2: Use InputLocation tracking
InputLocation location = build.getLocation("sourceDirectory");
boolean isExplicit = location != null && !isSuperPomLocation(location);

Where to Implement Validation

OptionLocationProsCons
ModelValidator (Recommended)validateEffectiveModel()Line numbers, early, standardValues not fully resolved
DefaultProjectBuilderinitProject()All values resolvedLater, less standard
Lifecycle phasevalidate-Too late

Related Code Locations

  • impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelValidator.java
  • impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java
  • impl/maven-impl/src/main/java/org/apache/maven/impl/DefaultSourceRoot.java
  • impl/maven-impl/src/main/resources/org/apache/maven/model/pom-4.0.0.xml (Super POM 4.0)
  • impl/maven-impl/src/main/resources/org/apache/maven/model/pom-4.1.0.xml (Super POM 4.1)

Open Questions

  1. Should we validate at validateRawModel() or validateEffectiveModel()?
  2. How to reliably detect "explicit" configuration (InputLocation vs value comparison)?
  3. Should the filesystem warning (R4) be optional/configurable?
  • No labels