Skip to content

Add support for source set and Spring profile specific application properties - #1807

Open
denisfalqueto wants to merge 2 commits into
spring-io:mainfrom
denisfalqueto:main
Open

Add support for source set and Spring profile specific application properties#1807
denisfalqueto wants to merge 2 commits into
spring-io:mainfrom
denisfalqueto:main

Conversation

@denisfalqueto

@denisfalqueto denisfalqueto commented Jul 2, 2026

Copy link
Copy Markdown

Add support for source set and Spring profile specific application properties

Disclaimer

These commits were made with aid of Claude Code's models. But they were supervised by me, in each step. I assume responsibility for the reasoning and approval of these changes, as well as providing addition information.

Motivation

The ApplicationProperties abstraction introduced in initializr-generator-spring allows contributors to add properties to the generated project's configuration file. However, it is currently a single flat key-value container, and both ApplicationPropertiesContributor and ApplicationYamlPropertiesContributor write it to a hardcoded location: src/main/resources/application.properties (or .yaml).

This means an ApplicationPropertiesCustomizer has no way to express two very common needs of generated projects:

  1. Test-only configuration. Properties that should only apply when running tests (for example, an in-memory datasource URL, or disabling a cloud integration during tests) belong in src/test/resources/application.properties, not in the main configuration packaged with the application.

  2. Profile-specific configuration. Spring Boot's convention of application-{profile}.properties / application-{profile}.yaml files is the idiomatic way to separate configuration per environment, yet the generation model cannot produce these files today.

As a result, custom Initializr instances that need either behavior must bypass the ApplicationProperties abstraction entirely and write raw files through a dedicated ProjectContributor, duplicating the properties/YAML rendering logic that already exists in this module.

This PR generalizes the model so that application properties can be targeted at a (source set, Spring profile) pair, while keeping the existing single-file behavior and public API fully intact.

What this change does

ApplicationProperties becomes a composite of itself. The root instance — the bean that customizers already receive — represents the main source set with the default profile, exactly as before. A new section(SourceSet, String profile) method returns the ApplicationProperties for any other (source set, profile) combination, creating it on demand:

// unchanged: main source set, default profile
properties.add("spring.application.name", "demo");

// test source set, default profile -> src/test/resources/application.properties
properties.section(SourceSet.TEST)
        .add("spring.datasource.url", "jdbc:h2:mem:test");

// main source set, 'dev' profile -> src/main/resources/application-dev.properties
properties.section(SourceSet.MAIN, "dev")
        .add("logging.level.root", "DEBUG");

// test source set, 'integration' profile -> src/test/resources/application-integration.properties
properties.section(SourceSet.TEST, "integration")
        .add("spring.application.name", "it");

The two file contributors now share a new base class, AbstractApplicationPropertiesContributor, which iterates the root and its sections and resolves the output path following the standard convention:

src/{sourceSet}/resources/application[-{profile}].{properties|yaml}

The file for the main source set and default profile is always written, even when empty, preserving today's behavior. Files for other sections are only written when the section actually contains properties.

The reference documentation now covers the ApplicationPropertiesCustomizer extension point, including how to target sections, with a runnable example in ProjectCustomizationExamples.

Design decisions

No breaking change to the public API. The signatures of ApplicationProperties (all add/get/contains/remove overloads), ApplicationPropertiesCustomizer, and both contributors' constructors are untouched. Existing customizers — including the lambda-based registrations that are the documented idiom for this extension point — keep working unmodified and keep targeting the main/default file. The feature is purely additive: one new public method (section, plus a convenience overload) and one new public enum (SourceSet).

Dimensions are fixed by navigation, not by method parameters. The (source set, profile) pair is fixed once when obtaining the section, and the section then exposes the exact same API as the root, keeping every typed add overload untouched. This mirrors the idiom already established in this code base by MavenBuild, where build.profiles().id("profile-id") navigates to a MavenProfile that exposes the regular build-configuration API scoped to that profile.

Default profile is represented by null, not the string "default". The literal "default" has its own semantics in Spring (spring.profiles.default) and using it as a sentinel could suggest a application-default.properties file should be generated. A null profile unambiguously means "the profile-less application.{properties|yaml} file".

Sections cannot be nested. Calling section(...) on a section throws an IllegalStateException, and section(SourceSet.MAIN, null) on the root returns the root itself, so there is exactly one canonical instance per (source set, profile) pair.

Testing

Unit tests were added for the section semantics (root identity for main/default, idempotent section retrieval, isolation between sections, rejection of nesting and of blank profile names) and for the contributor output paths (test source set, profile-specific files, combination of both, empty sections producing no file, and the main/default file still always being written).

ApplicationPropertiesComplianceTests was extended with the source set/profile pair as an additional parameterized axis: for both the properties and YAML formats, a customizer targeting a section is verified end-to-end to produce the expected file at the expected location, while the main/default file keeps being generated.

All existing tests pass unchanged, which follows from the API being untouched.

Why SourceSet lives in io.spring.initializr.generator.buildsystem

Although this PR only consumes SourceSet from the properties support in initializr-generator-spring, the enum was deliberately placed in the io.spring.initializr.generator.buildsystem package of initializr-generator, for three reasons.

First, the concept genuinely belongs to the build-system domain, not to the properties feature. The main/test split is defined by the build systems this project abstracts over: it is Maven's Standard Directory Layout (src/main, src/test, mirrored lifecycle phases and output directories) and it is literally Gradle's source set concept, from which the name is borrowed. A type that answers "which tree of the standard directory layout does this artifact belong to" is build-system vocabulary regardless of which feature happens to ask the question.

Second, the package already contains the direct sibling of this concept: DependencyScope. That enum models the build-system-agnostic classification of dependencies (compile, runtime, test-compile, ...) in a neutral form that each BuildSystem implementation translates to its own representation. SourceSet does exactly the same for the classification of sources and resources. Placing the two classification axes side by side keeps the build abstraction's vocabulary coherent and discoverable.

Third, it enables reuse without churn. Other contributors in the code base hardcode the same layout knowledge today — for example WebFoldersContributor resolves src/main/resources/templates and src/main/resources/static as string literals — and language source-code contributors are inherently organized around the main/test split. Hosting the enum in the shared buildsystem package lets those spots adopt it incrementally as further consumers appear.

No module dependencies change: initializr-generator-spring already depends on initializr-generator.

Closes #1806

@mhalbritter

Copy link
Copy Markdown
Contributor

Thanks for the PR, and thanks for the very thorough write-up — the motivation and the design rationale made this much easier to review.

I like the feature and I agree with the problem statement: targeting a (source set, profile) pair is the right model, and navigating to a section rather than adding parameters to every add overload is the right shape for the API. I have two changes to ask for at the design level, plus a handful of smaller points.

Resolve the output paths through BuildSystem instead of adding SourceSet

I'd rather not add the new SourceSet enum to io.spring.initializr.generator.buildsystem. The layout knowledge it carries already lives on BuildSystem:

default SourceStructure getMainSource(Path projectRoot, Language language) { ... }
default SourceStructure getTestSource(Path projectRoot, Language language) { ... }

Those are default methods precisely so a build system can relocate its trees, and SourceStructure already exposes getResourcesDirectory(). With the enum plus "src/%s/resources/application%s.%s".formatted(...) we'd have a second source of truth for the same thing, and a BuildSystem that overrides getTestSource would be silently ignored by the new contributor.

So AbstractApplicationPropertiesContributor should take the ProjectDescription and resolve along the lines of:

SourceStructure source = (sourceSet == TEST)
        ? description.getBuildSystem().getTestSource(projectRoot, description.getLanguage())
        : description.getBuildSystem().getMainSource(projectRoot, description.getLanguage());
Path output = source.getResourcesDirectory().resolve("application" + profileSuffix + "." + this.extension);

The main/test dimension itself can stay — just model it locally in io.spring.initializr.generator.spring.properties (package-private, or a simple boolean/enum in that package) rather than committing to new public API in initializr-generator before there's a second consumer. Your point about WebFoldersContributor and friends hardcoding the layout is fair, but I'd rather those move to BuildSystem/SourceStructure than to a parallel enum.

I realise this breaks the "no API change" goal: it changes the ApplicationPropertiesContributor(ApplicationProperties) and ApplicationYamlPropertiesContributor(ApplicationProperties) constructors. That's fine by me — they're only instantiated from ApplicationPropertiesProjectGenerationConfiguration, and correctness of the resolved path is worth the break.

Drop the root flag

The boolean root field plus Assert.state(this.root, "Sections cannot be nested") gives us a method whose contract depends on which instance you happen to be holding, enforced only at runtime. Please give a section a reference to its root instead and have section(...) delegate upward:

public ApplicationProperties section(SourceSet sourceSet, @Nullable String profile) {
    return (this.root != null) ? this.root.section(sourceSet, profile) : ...;
}

That keeps the canonical-instance-per-pair guarantee and the section(MAIN, null) == this identity, while removing both the flag and the exception. sectionOfSectionThrows then turns into a test that nesting resolves to the same section instance.

The profile name needs validating

section(...) only checks Assert.hasText(profile, ...), and the profile is then interpolated straight into a file path. So:

properties.section(SourceSet.MAIN, "../../../../etc/cron.d/x"); // writes outside the project root
properties.section(SourceSet.MAIN, "a/b");                      // silently creates a subdirectory

Customizers are trusted code, but they commonly derive values from the incoming request, so this is reachable from user input in a hosted instance. Please reject anything that isn't a plain profile name (no path separators, no leading .) in section(...), and add test cases for "a/b" and "..".

Smaller points

  • AbstractApplicationPropertiesContributor shouldn't be public. Both subclasses are in the same package, so package-private keeps the new public surface minimal. It isn't really designed as an extension point either — the constructor takes an unvalidated raw String extension and has no Javadoc (we want Javadoc on protected members too). If you'd prefer no inheritance at all, a package-private helper taking a Consumer<PrintWriter> gives the same reuse; with two implementations either is fine.
  • isEmpty() is now misleading. It inspects only this.properties, so a root holding several non-empty sections reports empty. Rename to something like hasProperties(), or document it.
  • ProjectCustomizationExamples: the bean method is named applicationPropertiesContributor() but returns an ApplicationPropertiesCustomizer. Worth renaming since it shows up in the rendered docs.
  • configuration-guide.adoc: the path template src/{sourceSet}/resources/application[-{profile}] is missing the extension — should be application[-{profile}].{properties|yaml}.

Tests

Coverage is good — section identity, idempotence, isolation, all four path shapes, empty-section suppression, and the main/default file still always being written. A few remarks:

  • ApplicationPropertiesComplianceTests.sectionParameters() reaches into parameters() by index (arguments[0], arguments[1]). That's fragile and hard to read; an explicit Arguments.arguments(...) stream would be clearer.
  • The same test only asserts that the main/default file exists, not that it's empty. The unit test covers it, so minor.
  • The section assertion reuses the application.properties.gen fixture, which works only because the customizer adds the same property as the main-file fixture. Works, but the coupling isn't obvious to a later reader.
  • sectionWithEmptyProfileThrows doesn't assert the message, while sectionOfSectionThrows does. Let's be consistent.

@mhalbritter mhalbritter added the status: waiting-for-feedback We need additional information before we can continue label Aug 14, 2026
@denisfalqueto

Copy link
Copy Markdown
Author

Hi @mhalbritter , thank you so much for your review.

I've instructed Claude Code to implement your suggestions (didn't use auto mode, I like to review each step). I've rebased my branch and resent it, so you can review it again, when you're ready. Please, feel free to provide any further comments and I'll do it as soon as I can.

@spring-projects-issues spring-projects-issues added status: feedback-provided Feedback has been provided and removed status: waiting-for-feedback We need additional information before we can continue labels Aug 14, 2026
@mhalbritter mhalbritter self-assigned this Aug 14, 2026
denisfalqueto and others added 2 commits August 14, 2026 12:17
ApplicationProperties becomes a composite: the root instance keeps
representing the main source set with the default profile, and a new
section(SourceSet, String) method returns the properties of any other
(source set, profile) pair, creating it on demand. Sections expose the
exact same API as the root and calling section(...) on a section
resolves through to the same canonical instance as calling it on the
root, so nesting is transparent rather than rejected. Profile names are
validated to reject path separators and leading dots, since they are
interpolated into a file path and customizers commonly derive them from
incoming requests.

The properties and YAML contributors now share a new base class,
AbstractApplicationPropertiesContributor, which writes one file per
non-empty section following the application[-{profile}] convention. The
output directory is resolved through the project's BuildSystem (main or
test SourceStructure) rather than a hardcoded src/{sourceSet}/resources
template, so a build system that relocates its source trees is honored
instead of silently ignored. The file for the main source set and
default profile is still always written, preserving existing behavior.

SourceSet is introduced as a small, generator-spring-local enum rather
than new public API in initializr-generator, since main/test is a
concern of this contributor rather than of the build system model.

Compliance tests gain the (source set, profile) pair as an additional
parameterized axis for both configuration file formats.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Denis A. Altoé Falqueto <denisfalqueto@gmail.com>
Describe in the configuration guide how application properties can be
contributed to the generated project, including how to target another
source set or Spring profile through sections, with a runnable example.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Denis A. Altoé Falqueto <denisfalqueto@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: feedback-provided Feedback has been provided status: waiting-for-triage An issue we've not yet triaged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Provide mechanism to customize the destination of ApplicationProperties

3 participants