Spring Configuration & Profiles

Project: Multi-Environment Configuration

18 min Lesson 10 of 13

Project: Multi-Environment Configuration

Throughout this tutorial you have studied every building block of Spring configuration in isolation: profiles, property sources, @Value, @Conditional, and the Environment abstraction. This final lesson brings all of them together in a realistic Spring Boot 3 project. The goal is a codebase that works identically on a developer laptop, a CI test runner, and a production server — with no manual file edits, no commented-out blocks, and no environment-specific code paths embedded in business logic.

The Target Architecture

The application exposes a REST API backed by a relational database. It uses asynchronous email sending and integrates with an external payment gateway. The configuration strategy must satisfy four requirements:

  1. Dev — fast startup, H2 in-memory database, email printed to the console, payment gateway stubbed out.
  2. Test — deterministic, no network calls, H2 or Testcontainers, all external clients replaced with fakes.
  3. Staging — mirrors production topology but uses non-critical credentials and a sandboxed payment gateway.
  4. Prod — PostgreSQL, real SMTP, live payment gateway, strict connection limits, no debug logging.

Property File Layout

Spring Boot resolves a well-defined property source hierarchy. For a profile named prod, it loads (in priority order, highest first):

  1. OS environment variables and JVM system properties (-Dkey=value)
  2. application-prod.properties (or .yml)
  3. application.properties (base defaults)

Keep only values that are genuinely environment-agnostic in the base file. Every environment-specific secret or URL belongs in the profile file or — better — in an environment variable on the target machine.

# src/main/resources/application.properties (shared defaults) spring.application.name=payments-api spring.jpa.open-in-view=false spring.jpa.hibernate.ddl-auto=validate # Default profile: dev spring.profiles.default=dev # Logging baseline logging.level.root=INFO logging.level.com.example=DEBUG
# application-dev.properties spring.datasource.url=jdbc:h2:mem:devdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE spring.datasource.driver-class-name=org.h2.Driver spring.datasource.username=sa spring.datasource.password= spring.jpa.hibernate.ddl-auto=create-drop spring.h2.console.enabled=true mail.simulate=true payment.gateway.sandbox=true payment.gateway.base-url=https://sandbox.pay.example.com
# application-prod.properties # Sensitive values read from environment variables — never hard-code them here spring.datasource.url=${DB_URL} spring.datasource.username=${DB_USER} spring.datasource.password=${DB_PASS} spring.datasource.hikari.maximum-pool-size=20 spring.datasource.hikari.minimum-idle=5 spring.datasource.hikari.connection-timeout=30000 spring.mail.host=${SMTP_HOST} spring.mail.port=587 spring.mail.username=${SMTP_USER} spring.mail.password=${SMTP_PASS} spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true mail.simulate=false payment.gateway.sandbox=false payment.gateway.base-url=${PAYMENT_GATEWAY_URL} logging.level.root=WARN logging.level.com.example=INFO
Never commit secrets. The ${ENV_VAR} syntax in application-prod.properties tells Spring to resolve the value from the real environment at startup. The file itself can safely be committed to version control because it contains no actual credentials.

Profile-Driven Bean Selection

Beans that require real infrastructure belong behind profiles. Create a dedicated configuration class for each external integration:

package com.example.config; import com.example.mail.ConsoleMailSender; import com.example.mail.SmtpMailSender; import com.example.mail.AppMailSender; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; @Configuration public class MailConfig { /** Real SMTP sender — active in staging and prod only */ @Bean @Profile({"staging", "prod"}) public AppMailSender smtpMailSender( org.springframework.mail.javamail.JavaMailSender javaMailSender) { return new SmtpMailSender(javaMailSender); } /** Console stub — active in dev and test; no network needed */ @Bean @Profile({"dev", "test"}) public AppMailSender consoleMailSender() { return new ConsoleMailSender(); } }

Business services depend on AppMailSender — the interface — not the concrete implementation. Spring injects the correct bean automatically based on the active profile. No if/else in business code, no boolean flags checked at runtime.

Binding Configuration Properties to a Typed Class

Scattering @Value injections across many classes makes the configuration surface hard to see and hard to validate. Group related properties into a single @ConfigurationProperties record:

package com.example.config; import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "payment.gateway") public record PaymentGatewayProperties( String baseUrl, boolean sandbox, int connectTimeoutMs, int readTimeoutMs ) {}
// Enable binding — typically in your main @SpringBootApplication class @SpringBootApplication @EnableConfigurationProperties(PaymentGatewayProperties.class) public class PaymentsApiApplication { public static void main(String[] args) { SpringApplication.run(PaymentsApiApplication.class, args); } }

Spring Boot validates the properties at startup. If payment.gateway.base-url is missing in a profile, the application fails immediately with a clear error rather than throwing a NullPointerException deep inside a request handler.

Add spring-boot-configuration-processor to your build. It generates IDE metadata so that application.properties gets autocompletion and documentation for every @ConfigurationProperties class. It is a compile-only annotation processor — zero runtime cost.

Conditional Infrastructure Beans

Some beans should not even be created if a feature flag is off. Use @ConditionalOnProperty for this:

package com.example.config; import com.example.payment.LivePaymentClient; import com.example.payment.SandboxPaymentClient; import com.example.payment.PaymentClient; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class PaymentConfig { @Bean @ConditionalOnProperty(name = "payment.gateway.sandbox", havingValue = "false") public PaymentClient livePaymentClient(PaymentGatewayProperties props) { return new LivePaymentClient(props.baseUrl(), props.connectTimeoutMs(), props.readTimeoutMs()); } @Bean @ConditionalOnProperty(name = "payment.gateway.sandbox", havingValue = "true", matchIfMissing = true) public PaymentClient sandboxPaymentClient(PaymentGatewayProperties props) { return new SandboxPaymentClient(props.baseUrl()); } }

The matchIfMissing = true on the sandbox bean ensures that if the property is absent (for example in a unit test that does not load any profile-specific file), the safe stub is used — not the live client.

Activating Profiles

There are three standard ways to activate a profile:

  • JVM argument (CI / Docker): -Dspring.profiles.active=prod
  • Environment variable (12-factor / Kubernetes): SPRING_PROFILES_ACTIVE=prod
  • Test annotation: @ActiveProfiles("test") on a JUnit test class

In a containerised deployment, setting SPRING_PROFILES_ACTIVE as an environment variable in the pod spec or Docker run command is the preferred approach: it keeps the JAR identical across environments and the environment decides its own role.

Test Configuration

Integration tests need their own slice of configuration. Spring Boot provides @SpringBootTest combined with a dedicated application-test.properties:

# src/test/resources/application-test.properties spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 spring.datasource.driver-class-name=org.h2.Driver spring.jpa.hibernate.ddl-auto=create-drop mail.simulate=true payment.gateway.sandbox=true payment.gateway.base-url=http://localhost:9999/stub payment.gateway.connect-timeout-ms=500 payment.gateway.read-timeout-ms=500
package com.example; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; @SpringBootTest @ActiveProfiles("test") class PaymentFlowIntegrationTest { @Test void completePurchase_chargesCorrectAmount() { // All beans are real except PaymentClient (sandbox) and AppMailSender (console) // Fast, deterministic, no external network calls } }
Do not let test configuration leak into production. Place test application-test.properties under src/test/resources, not src/main/resources. Spring Boot will find it automatically when the test profile is active, and it will never be bundled into the production JAR.

Summary: The Rules That Make It Work

A multi-environment Spring Boot configuration that holds up in practice follows a small set of rules:

  • One base file for defaults that are truly shared; one profile file per environment for overrides.
  • Secrets in environment variables only — never in files that are committed to version control.
  • Profile-driven beans instead of runtime if/else checks in business logic.
  • Typed @ConfigurationProperties for all custom properties — validated at startup, autocompleted in the IDE.
  • Test profile in src/test/resources — fast, deterministic, fully isolated from real infrastructure.

When these rules are followed, you can hand the same JAR to any environment and trust that it will configure itself correctly from the surrounding context — which is exactly what the twelve-factor app methodology demands and what modern deployment pipelines expect.