CicloUrbana is full of hard-coded numbers: 0.50 to unlock, 0.12 per minute, 15 free minutes for students, a minimum of 8 docks per station. Changing any of them today demands a recompile and a redeployment. That is not acceptable: Ribalta city council changes its fares every season, and the server port is not the same on your laptop as on the production server. The answer is to externalise the configuration, and Spring Boot has one of the most complete —and most misunderstood— mechanisms for that in the Java ecosystem. In this lesson we will look at what the Environment is and where it gets its values from, the exact order of precedence between sources, the real differences between .properties and .yaml, how Spring relaxes property names so that CICLOURBANA_BASEFARE and ciclourbana.base-fare are the same thing, how to read values with @Value, and why a credential must never live in the repository.

Contents

  1. The Environment and the PropertySources
  2. The order of precedence of the sources
  3. Demonstrating precedence in practice
  4. .properties versus .yaml
  5. Relaxed binding
  6. Reading properties with @Value
  7. SpEL and default values
  8. The properties CicloUrbana uses
  9. External configuration: spring.config.import and spring.config.location
  10. Secrets and credentials
  11. Common Mistakes and Tips
  12. Exercises

  1. The Environment and the PropertySources

In lesson 01-05 we saw that one of the first startup phases is "preparing the Environment". Now we can pin down what that means.

The Environment is a Spring bean that answers two questions: what is the value of this property? and which profiles are active? (profiles are covered in lesson 07-02). Internally it stores no values at all: it keeps an ordered list of PropertySources and, when you ask it for a key, it walks them in order and returns the first one that holds it.

flowchart TD
    E["Environment.getProperty('server.port')"] --> PS["MutablePropertySources<br/>(ORDERED list)"]
    PS --> P1["1. commandLineArgs<br/>--server.port=9090"]
    P1 -->|does not hold it| P2["2. systemEnvironment<br/>SERVER_PORT"]
    P2 -->|does not hold it| P3["3. systemProperties<br/>-Dserver.port"]
    P3 -->|does not hold it| P4["4. applicationConfig:<br/>application.properties"]
    P4 -->|holds it: 8080| R["Returns 8080<br/>and stops searching"]

The consequence is fundamental: the first one to answer wins. All of Spring Boot's precedence logic boils down to the order in which the PropertySources are placed in that list.

You can inspect the full list in your own application:

package com.ciclourbana.common;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.stereotype.Component;

/**
 * Diagnostic tool: dumps the property sources in their real order
 * of precedence and resolves a few key properties.
 */
@Component
public class ConfigurationInspector implements CommandLineRunner {

    private static final Logger log = LoggerFactory.getLogger(ConfigurationInspector.class);

    private final ConfigurableEnvironment environment;

    public ConfigurationInspector(ConfigurableEnvironment environment) {
        this.environment = environment;
    }

    @Override
    public void run(String... args) {
        log.info("=== Property sources, from highest to lowest priority ===");
        int position = 1;
        for (var source : environment.getPropertySources()) {
            log.info("  {}. {}", position++, source.getName());
        }

        log.info("server.port resolved to: {}", environment.getProperty("server.port"));
        log.info("spring.application.name resolved to: {}",
                environment.getProperty("spring.application.name"));
    }
}

Typical output in CicloUrbana:

=== Property sources, from highest to lowest priority ===
  1. configurationProperties
  2. commandLineArgs
  3. servletConfigInitParams
  4. servletContextInitParams
  5. systemProperties
  6. systemEnvironment
  7. random
  8. Config resource 'class path resource [application.properties]'
server.port resolved to: 8080
spring.application.name resolved to: ciclourbana

This ConfigurationInspector is a tool worth keeping to hand: when a property "is not being read", the first thing to do is see which source is winning it.

  1. The order of precedence of the sources

Spring Boot defines a complete, documented order. From highest to lowest priority, keeping to what matters in practice:

# Source Example Typical use
1 DevTools properties (~/.config/spring-boot) — Local development
2 @TestPropertySource and properties on @SpringBootTest @SpringBootTest(properties = "server.port=0") Tests (module 6)
3 Command-line arguments --server.port=9090 One-off startups, containers
4 SPRING_APPLICATION_JSON SPRING_APPLICATION_JSON='{"server":{"port":9090}}' Cloud platforms
5 ServletContext/ServletConfig parameters — WAR deployments
6 JNDI attributes — Classic application servers
7 Java system properties -Dserver.port=9090 Startup scripts
8 Environment variables SERVER_PORT=9090 Docker, Kubernetes, CI
9 application-{profile}.properties outside the jar ./config/application-prod.properties Per-environment configuration
10 application-{profile}.properties inside the jar application-dev.properties Profiles (lesson 07-02)
11 application.properties outside the jar ./config/application.properties Operator settings
12 application.properties inside the jar src/main/resources/application.properties Project defaults
13 @PropertySource on @Configuration classes @PropertySource("classpath:fares.properties") Additional files
14 Default values (SpringApplication.setDefaultProperties) — Last resort

Three rules that sum the table up and are worth memorising:

  1. The most external wins. The closer to the moment of startup a value is specified, the higher its priority. A --server.port=9090 on the command line beats any file.
  2. Outside the jar beats inside the jar. That is what lets you package sensible defaults and have the operator adjust them without rebuilding anything.
  3. With a profile beats without one. application-prod.properties overrides application.properties.

The three rows you will use 95% of the time are 3 (arguments), 8 (environment variables) and 12 (the project's application.properties). The rest are worth knowing so you are never taken by surprise.

  1. Demonstrating precedence in practice

Nothing convinces like seeing it work. Let us start with the project file:

# src/main/resources/application.properties
spring.application.name=ciclourbana
server.port=8080
ciclourbana.city=Ribalta

Start it normally:

./mvnw spring-boot:run
Tomcat started on port 8080 (http) with context path '/'

Now override it with an environment variable (row 8):

SERVER_PORT=8081 java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
Tomcat started on port 8081 (http) with context path '/'

Now with a system property (row 7, which beats the environment variable):

SERVER_PORT=8081 java -Dserver.port=8082 -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
Tomcat started on port 8082 (http) with context path '/'

And finally with a command-line argument (row 3, the one that beats them all):

SERVER_PORT=8081 java -Dserver.port=8082 -jar target/ciclourbana-0.0.1-SNAPSHOT.jar --server.port=8083
Tomcat started on port 8083 (http) with context path '/'

Note the syntactic difference, which confuses a lot of people:

Syntax What it is Position
-Dkey=value A JVM system property Before -jar
--key=value An application argument After the jar
KEY=value (in front of the command) Environment variable Before everything

Putting --server.port=9090 before -jar does not work: the JVM would read it as one of its own options and fail. And -Dserver.port after the jar arrives as just another application argument, which Spring does not recognise as a property.

With Maven, passing arguments requires the plugin's own property:

./mvnw spring-boot:run -Dspring-boot.run.arguments=--server.port=8083

  1. .properties versus .yaml

Spring Boot accepts both formats, with the same capabilities. The choice is a matter of style... until the structure grows.

The same CicloUrbana configuration in both formats:

# src/main/resources/application.properties
spring.application.name=ciclourbana
server.port=8080
server.servlet.context-path=/

ciclourbana.city=Ribalta
ciclourbana.fare.unlock=0.50
ciclourbana.fare.price-per-minute=0.12
ciclourbana.network.minimum-capacity=8
ciclourbana.network.battery-threshold=20

ciclourbana.featured-stations[0]=Main Square
ciclourbana.featured-stations[1]=University

ciclourbana.fares-by-user.standard=0.12
ciclourbana.fares-by-user.student=0.08
ciclourbana.fares-by-user.senior=0.05

logging.level.com.ciclourbana=DEBUG
logging.level.org.springframework.web=INFO
# src/main/resources/application.yaml
spring:
  application:
    name: ciclourbana

server:
  port: 8080
  servlet:
    context-path: /

ciclourbana:
  city: Ribalta
  fare:
    unlock: 0.50
    price-per-minute: 0.12
  network:
    minimum-capacity: 8
    battery-threshold: 20
  featured-stations:
    - Main Square
    - University
  fares-by-user:
    standard: 0.12
    student: 0.08
    senior: 0.05

logging:
  level:
    com.ciclourbana: DEBUG
    org.springframework.web: INFO

Side by side:

Criterion .properties .yaml
Hierarchy Repetitive: every line spelled out in full Nested, with no repetition
Lists key[0], key[1]... - item (natural)
Maps key.subkey=value Nested (natural)
Indentation-sensitive No Yes, and that is its big problem
Tabs Irrelevant Forbidden: they break the file
Comments # #
Finding a key with grep Trivial: the whole key is on the line Hard: the key is spread out
Several documents in one file No (#--- is used) Yes, with ---
Multi-line values With a trailing \ Yes, with | and >
Precedence if both exist .properties wins —

Practical recommendation: pick one and be consistent. If your configuration is flat and short, .properties is harder to break. As soon as lists, maps and three levels of nesting appear —CicloUrbana's case as soon as we reach lesson 02-05— YAML is clearly more readable. From here on this course uses YAML.

Never keep both files at once: application.properties wins, and you will spend an entire afternoon wondering why your YAML is not being read.

YAML indentation errors

They are the price of the format, and every one of them is silent: the file is read, but the properties end up somewhere else.

# WRONG: 'port' hangs off the root, not off 'server'
server:
port: 8080

# WRONG: a tab instead of spaces (invisible here, but it breaks the startup)
server:
	port: 8080

# WRONG: 'context-path' with less indentation than it needs
server:
  servlet:
   context-path: /api      # 3 spaces where the block uses 2 or 4: inconsistent

# RIGHT
server:
  port: 8080
  servlet:
    context-path: /

The first case produces a clear startup error (server does not accept a scalar value), but subtler variants simply leave the property orphaned and the application starts with the default value. Rule: two spaces per level, never tabs, and turn on invisible-character display in your IDE.

  1. Relaxed binding

Spring Boot does not require the property name to be spelled exactly the same everywhere. It applies a relaxation algorithm that treats several forms as equivalent:

Form Example Where it is used
kebab-case ciclourbana.base-fare Recommended in files
camelCase ciclourbana.baseFare Accepted in files
snake_case ciclourbana.base_fare Accepted
UPPERCASE with underscores CICLOURBANA_BASEFARE Environment variables

All four resolve to the same property. This is what lets you write, in a docker-compose.yml or in a Kubernetes Deployment:

environment:
  CICLOURBANA_FARE_UNLOCK: "0.60"
  CICLOURBANA_NETWORK_MINIMUMCAPACITY: "10"
  SERVER_PORT: "8080"

and have those values reach ciclourbana.fare.unlock, ciclourbana.network.minimumCapacity and server.port.

The rules for turning a property into an environment variable are three:

  1. Dots (.) become underscores (_).
  2. Hyphens (-) are removed.
  3. Everything in uppercase.

So ciclourbana.network.minimum-capacity → CICLOURBANA_NETWORK_MINIMUMCAPACITY. That second point is the one that causes the most headaches: many people write CICLOURBANA_NETWORK_MINIMUM_CAPACITY and it does not work, because that name would correspond to ciclourbana.network.minimum.capacity, with one dot too many.

Two important limitations of relaxation:

  • It only applies to @ConfigurationProperties binding (lesson 02-05) and to Spring Boot's own properties. With @Value the match is exact: @Value("${ciclourbana.base-fare}") will not find a property written ciclourbana.baseFare.
  • Map keys are not relaxed: if you define ciclourbana.fares-by-user.grant-student, the map key will be literally grant-student.

Recommended convention: always write kebab-case in files. It is the canonical form, the one that appears in the Spring Boot documentation and the one that avoids ambiguity.

  1. Reading properties with @Value

@Value injects a property's value into a field or a parameter. Let us finally pull StandardFare's prices out of its constants:

package com.ciclourbana.rentals;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;

@Component
@Primary
public class StandardFare implements FareCalculator {

    private final BigDecimal unlock;
    private final BigDecimal perMinute;

    // @Value on constructor parameters: keeps the fields final
    public StandardFare(
            @Value("${ciclourbana.fare.unlock}") BigDecimal unlock,
            @Value("${ciclourbana.fare.price-per-minute}") BigDecimal perMinute) {
        this.unlock = unlock;
        this.perMinute = perMinute;
    }

    @Override
    public BigDecimal calculate(Duration duration) {
        BigDecimal minutes = BigDecimal.valueOf(Math.max(1, duration.toMinutes()));
        return unlock
                .add(perMinute.multiply(minutes))
                .setScale(2, RoundingMode.HALF_UP);
    }

    @Override
    public String name() {
        return "standard";
    }
}

Notice that @Value goes on the constructor parameters, not on the fields. That way the fields stay final and the class remains buildable in a test with new StandardFare(new BigDecimal("0.50"), new BigDecimal("0.12")), with no Spring in the way. It is the same reasoning as lesson 02-02, applied to configuration.

Spring converts the text to the parameter's type automatically:

@Value("${server.port}")                       int port;                 // 8080
@Value("${ciclourbana.city}")                  String city;              // "Ribalta"
@Value("${ciclourbana.fare.unlock}")           BigDecimal unlock;        // 0.50
@Value("${ciclourbana.network.maintenance}")   boolean underMaintenance; // true/false
@Value("${ciclourbana.featured-stations}")     List<String> featured;    // comma-separated
@Value("${ciclourbana.session.duration}")      Duration duration;        // "30m" -> PT30M

For List<String> to work with @Value, the value must be a comma-separated string, not a YAML list:

ciclourbana:
  featured-stations: Main Square,University          # works with @Value
ciclourbana:
  featured-stations:                                 # does NOT work with @Value
    - Main Square                                    # does work with @ConfigurationProperties
    - University

This is already the first crack in @Value, and it will not be the last.

  1. SpEL and default values

Default values

If a property does not exist, @Value fails at startup:

Could not resolve placeholder 'ciclourbana.fare.unlock' in value
"${ciclourbana.fare.unlock}"

You avoid that with the ${key:defaultValue} syntax:

@Value("${ciclourbana.fare.unlock:0.50}")            BigDecimal unlock;    // 0.50 if missing
@Value("${ciclourbana.network.battery-threshold:20}") int batteryThreshold;
@Value("${ciclourbana.maintenance-message:}")        String message;       // empty string
@Value("${ciclourbana.contact:#{null}}")             String contact;       // explicit null

Watch out for one special case: if the default value contains a : (a URL, for instance), bear in mind that only the first : counts as the separator, so @Value("${url:http://localhost:8080}") works correctly and the default value is the complete URL.

SpEL: Spring's expression language

@Value also accepts SpEL expressions with the #{...} syntax (hash, not dollar):

// Arithmetic on a property
@Value("#{${ciclourbana.fare.price-per-minute} * 60}")
BigDecimal pricePerHour;

// Calling a method on another bean
@Value("#{fareSelector.available().size()}")
int fareCount;

// Reading from the Environment with some logic
@Value("#{environment['ciclourbana.city'] ?: 'unknown'}")
String city;

// Turning a comma-separated string into a list (genuinely useful)
@Value("#{'${ciclourbana.featured-stations}'.split(',')}")
List<String> featured;

// System properties
@Value("#{systemProperties['user.timezone']}")
String timeZone;

And one that really is practical: random values, for ports or identifiers in tests.

@Value("${random.int(1000,9999)}")   int sessionCode;
@Value("${random.uuid}")             String startupId;
Syntax Name What it does
${...} Property placeholder Substitutes the property's value
#{...} SpEL expression Evaluates an expression (which may contain ${...} inside)

The limitations of @Value

@Value is convenient for one or two loose values, but it falls short as soon as the configuration grows. Its problems:

Limitation Consequence
No relaxed binding The key must be written exactly the same way.
No validation An absurd value (a capacity of -5) is accepted without complaint.
No structures It binds neither YAML lists nor maps naturally.
No metadata The IDE neither autocompletes nor documents the properties.
Scattered errors If five properties are missing, the startup fails on the first one, one at a time.
Configuration spread thin The keys turn up in twenty classes: nobody knows what configures the application.
Hard to group and reuse There is no object representing "the fare configuration".

@ConfigurationProperties solves all of them, and it is the subject of the next lesson. The rule we will apply in CicloUrbana: @Value for isolated, occasional values; @ConfigurationProperties for everything else.

  1. The properties CicloUrbana uses

This is the project's base configuration, with each block explained:

# src/main/resources/application.yaml

spring:
  application:
    name: ciclourbana          # appears in the logs, in Actuator and in traces (module 9)

server:
  port: 8080                   # 0 = a random free port (very useful in tests)
  servlet:
    context-path: /            # prefix for ALL routes
  shutdown: graceful           # orderly shutdown, seen in lesson 01-05

logging:
  level:
    root: INFO
    com.ciclourbana: DEBUG                    # our code, in detail
    org.springframework.web: INFO
    org.springframework.beans.factory: INFO   # set to DEBUG to debug the lifecycle
  pattern:
    console: "%d{HH:mm:ss.SSS} %-5level [%logger{20}] - %msg%n"

ciclourbana:
  city: Ribalta
  fare:
    unlock: 0.50
    price-per-minute: 0.12
  network:
    minimum-capacity: 8
    battery-threshold: 20

The most useful server and application properties:

Property Typical value What it is for
server.port 8080, 0 HTTP port. 0 assigns a free one.
server.servlet.context-path /, /ciclourbana Prefix for every route.
server.shutdown graceful Waits for in-flight requests to finish.
server.error.include-message always Includes the error message in the response (module 3).
server.compression.enabled true Compresses large responses.
spring.application.name ciclourbana Identifies the app in logs, metrics and traces.
spring.main.banner-mode off, console Controls the banner (lesson 01-05).
spring.main.web-application-type servlet, none Forces the application type.
logging.level.<package> DEBUG Log level per package. Covered in depth in 09-05.
logging.file.name logs/ciclourbana.log Writes the log to a file as well.

A warning about server.servlet.context-path: if you set it to /ciclourbana, our endpoint's URL becomes http://localhost:8080/ciclourbana/api/v1/stations. That change breaks every client and every .http file from lesson 01-02, so in CicloUrbana we will leave it at /.

Check that the configuration is applied:

./mvnw spring-boot:run
curl -s http://localhost:8080/api/v1/stations | head -3

  1. External configuration: spring.config.import and spring.config.location

Everything above lives inside the jar. A real deployment needs configuration that is not packaged.

External files by convention

Spring Boot looks for application.yaml automatically, in this order of priority, in:

  1. ./config/ (a config subdirectory next to the jar)
  2. ./ (the current directory)
  3. classpath:/config/
  4. classpath:/ (inside the jar)

In other words, leaving a file next to the jar is enough to override the packaged values:

target/
├── ciclourbana-0.0.1-SNAPSHOT.jar
└── config/
    └── application.yaml          # overrides whatever the jar carries
# target/config/application.yaml — the operator's configuration
server:
  port: 9090
ciclourbana:
  fare:
    unlock: 0.60      # the council raised the unlock charge
cd target && java -jar ciclourbana-0.0.1-SNAPSHOT.jar
Tomcat started on port 9090 (http) with context path '/'

spring.config.import

This lets you include other files from the main configuration. It is the modern, preferred alternative to @PropertySource:

# src/main/resources/application.yaml
spring:
  config:
    import:
      - optional:file:./config/ribalta-fares.yaml    # optional: no failure if absent
      - optional:file:/etc/ciclourbana/secrets.yaml  # server-side secrets

The optional: prefix is crucial: without it, if the file does not exist the startup fails. There is logic to that —you want to know when an indispensable file is missing— but in development it is inconvenient, which is why files belonging to one specific environment are almost always marked optional.

spring.config.import accepts other origins too:

spring:
  config:
    import:
      - optional:file:./config/                       # a whole directory
      - optional:configtree:/run/secrets/             # Docker/Kubernetes secrets
      - optional:classpath:default-fares.yaml         # another file from the jar

The configtree: format deserves a note: in Kubernetes and Docker Swarm, secrets are mounted as files, one per key, inside a directory. With configtree: Spring Boot reads that tree and turns each file into a property. It is the idiomatic mechanism for secrets in containers, and we will come back to it in lesson 07-04.

spring.config.location

This replaces the default locations entirely, rather than adding to them:

java -jar ciclourbana.jar --spring.config.location=file:/etc/ciclourbana/production.yaml

And spring.config.additional-location adds locations while keeping the defaults, which is usually what you actually want:

java -jar ciclourbana.jar \
  --spring.config.additional-location=file:/etc/ciclourbana/
Option Effect
spring.config.location Replaces the default locations. Total control, risk of losing values.
spring.config.additional-location Adds locations with higher priority than the defaults. Safer.
spring.config.import Includes specific files from the configuration itself. Declarative.

  1. Secrets and credentials

This section is not optional. It is the part of the lesson with the most serious consequences if ignored.

Never, under any circumstances, write passwords, API keys, tokens or certificates in a file that goes into the code repository.

What you must not do, even though you will see it in tutorials:

# WRONG: this will end up in Git and in the history forever
spring:
  datasource:
    url: jdbc:postgresql://db.ribalta.example:5432/ciclourbana
    username: ciclourbana_app
    password: Sup3rS3cr3t!           # ← catastrophe
ciclourbana:
  gateway:
    api-key: sk_live_9f3a2b1c8d7e    # ← catastrophe

Why it is so serious, beyond the obvious:

  • Git does not forget. Deleting the line in a later commit does not remove the secret: it is still in the history, reachable with git log -p. Rotating the credential is mandatory, not optional.
  • Repositories get copied. Forks, clones on laptops, backups, CI integrations, an intern's computer. A secret in Git is, in practice, in many more places than you think.
  • Repositories change visibility. A private repository that is made public leaks its entire history in one go. It is a surprisingly frequent accident.
  • Bots crawl. There are bots scanning GitHub for key patterns; a cloud provider's key published by mistake is exploited within minutes.

What to do instead:

1. Environment variables with a placeholder and no default value. The versioned file declares what it needs, not what it is worth:

# src/main/resources/application.yaml — this does go into the repository
spring:
  datasource:
    url: ${CICLOURBANA_DB_URL:jdbc:h2:mem:ribalta}
    username: ${CICLOURBANA_DB_USER:sa}
    password: ${CICLOURBANA_DB_PASSWORD:}      # empty locally, mandatory elsewhere
ciclourbana:
  gateway:
    api-key: ${CICLOURBANA_GATEWAY_API_KEY}    # no default: fails if absent

Note the detail: the gateway key has no default value. If someone deploys without defining it, the startup fails immediately with a clear message. That is far better than starting up and failing on the first payment.

export CICLOURBANA_DB_PASSWORD='the-real-one'
export CICLOURBANA_GATEWAY_API_KEY='sk_live_...'
java -jar ciclourbana.jar

2. An external file outside the project tree, with restricted permissions:

sudo mkdir -p /etc/ciclourbana
sudo tee /etc/ciclourbana/secrets.yaml > /dev/null <<'EOF'
spring:
  datasource:
    password: the-real-one
EOF
sudo chmod 600 /etc/ciclourbana/secrets.yaml
sudo chown ciclourbana:ciclourbana /etc/ciclourbana/secrets.yaml
spring:
  config:
    import: optional:file:/etc/ciclourbana/secrets.yaml

3. A secrets manager, which is the right answer in serious production: HashiCorp Vault, AWS Secrets Manager, Azure Key Vault or Kubernetes Secrets mounted as configtree:. They are covered in module 8.

4. Protect the repository. Add any local secrets file to .gitignore and consider a pre-commit hook that detects suspicious patterns:

# .gitignore
application-local.yaml
application-secrets.yaml
*.env
/config/secrets.yaml

5. If a secret leaks, rotate it. Do not just delete it from the file and carry on. Invalidate the credential and issue a new one. It is the only action that genuinely closes the hole.

One final note: in lesson 02-05 we will also see how to stop a secret ending up accidentally in the logs, and in module 7, how Actuator hides sensitive values in the /actuator/env endpoint.

Common Mistakes and Tips

Having application.properties and application.yaml at the same time. The .properties wins and the YAML looks ignored. Delete one.

Spelling the environment variable's name wrongly. CICLOURBANA_NETWORK_MINIMUM_CAPACITY is not ciclourbana.network.minimum-capacity: hyphens are removed, not turned into underscores. The correct name is CICLOURBANA_NETWORK_MINIMUMCAPACITY.

Using tabs in YAML. The file is rejected with a parse error that does not always point at the right line. Configure your editor to insert spaces.

Putting --server.port before the -jar. The JVM does not understand it. The -- ones go after the jar; the -D ones go before.

Expecting relaxed binding with @Value. There is none. With @Value the key must match character for character.

Confusing ${...} with #{...}. The first resolves properties; the second evaluates SpEL. @Value("#{ciclourbana.city}") tries to evaluate ciclourbana.city as an expression and fails; the correct form is ${ciclourbana.city}.

Putting a password in application.yaml. Repeated on purpose: it is the most expensive mistake in this lesson.

Tip: name your properties with a prefix of your own. Everything in CicloUrbana starts with ciclourbana.. That way you never clash with a Spring Boot or library property, and grep -r "ciclourbana\." src/ tells you at a glance what configures the application.

Tip: use server.port=0 in tests. It assigns a free port and avoids failures when two tests run at the same time. We will come back to it in module 6.

Tip: document each property with a comment. Whoever deploys your application a year from now will thank you, and they will not need to read the code to find out whether battery-threshold is a percentage or a voltage.

Tip: in development, do not depend on the command line. An application-dev.yaml with a profile (lesson 07-02) is more reproducible than a long command that only exists in your memory.

Exercises

Exercise 1: demonstrate precedence

Define ciclourbana.city=Ribalta in application.yaml. Write a CityNotice component that logs its value at startup. Then start the application four times —plain, with an environment variable, with a system property and with a command-line argument— giving a different value each time, and note which one wins. Add to the component a dump of the source that supplied the value.

Exercise 2: externalise the student fare

StudentFare still has its constants hard-coded. Externalise them to ciclourbana.fare.student.price-per-minute and ciclourbana.fare.student.free-minutes, with default values in the @Value itself so that the application starts even if they are missing. Verify that the amount of a 45-minute rental changes when you override the properties from the command line.

Exercise 3: operator configuration with an external file

Prepare a realistic deployment: package the jar, create a config/ directory next to it with an application.yaml that changes the port to 9090, raises the unlock charge to €0.60 and reads the database password from an environment variable with no default value. Check that the application fails to start if the variable is undefined, and that it starts correctly when it is defined.


Solutions

Solution 1

# src/main/resources/application.yaml
ciclourbana:
  city: Ribalta
package com.ciclourbana.common;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.stereotype.Component;

@Component
public class CityNotice implements CommandLineRunner {

    private static final Logger log = LoggerFactory.getLogger(CityNotice.class);

    private static final String KEY = "ciclourbana.city";

    private final String city;
    private final ConfigurableEnvironment environment;

    public CityNotice(@Value("${ciclourbana.city}") String city,
                      ConfigurableEnvironment environment) {
        this.city = city;
        this.environment = environment;
    }

    @Override
    public void run(String... args) {
        log.info("Network city: {}", city);
        log.info("Supplied by source: {}", winningSource());
    }

    /** Walks the sources in order and returns the first one holding the key. */
    private String winningSource() {
        for (var source : environment.getPropertySources()) {
            if (source instanceof EnumerablePropertySource<?> enumerable
                    && enumerable.containsProperty(KEY)) {
                return source.getName() + " -> " + enumerable.getProperty(KEY);
            }
        }
        return "none (default value)";
    }
}

The four runs:

# 1. The file only
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
# Network city: Ribalta
# Supplied by source: Config resource 'class path resource [application.yaml]' -> Ribalta

# 2. Environment variable
CICLOURBANA_CITY=Ribalta-North java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
# Network city: Ribalta-North
# Supplied by source: systemEnvironment -> Ribalta-North

# 3. System property (beats the environment variable)
CICLOURBANA_CITY=Ribalta-North \
  java -Dciclourbana.city=Ribalta-South -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
# Network city: Ribalta-South
# Supplied by source: systemProperties -> Ribalta-South

# 4. Command-line argument (beats them all)
CICLOURBANA_CITY=Ribalta-North \
  java -Dciclourbana.city=Ribalta-South -jar target/ciclourbana-0.0.1-SNAPSHOT.jar \
  --ciclourbana.city=Ribalta-Centre
# Network city: Ribalta-Centre
# Supplied by source: commandLineArgs -> Ribalta-Centre

Comment: the winningSource() method implements the Environment algorithm described in section 1 literally —walk the ordered list and take the first match— and that is why its result always agrees with the injected value. It is a good diagnostic to keep to hand.

A note on the configurationProperties source that appears first in the listing: it is a synthetic source Spring Boot uses internally for binding; it supplies no values of its own.

Solution 2

# src/main/resources/application.yaml
ciclourbana:
  fare:
    unlock: 0.50
    price-per-minute: 0.12
    student:
      price-per-minute: 0.08
      free-minutes: 15
package com.ciclourbana.rentals;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;

@Component("studentFare")
public class StudentFare implements FareCalculator {

    private final BigDecimal perMinute;
    private final long freeMinutes;

    public StudentFare(
            // Default value after the colon: the app starts even if they are missing
            @Value("${ciclourbana.fare.student.price-per-minute:0.08}") BigDecimal perMinute,
            @Value("${ciclourbana.fare.student.free-minutes:15}") long freeMinutes) {
        this.perMinute = perMinute;
        this.freeMinutes = freeMinutes;
    }

    @Override
    public BigDecimal calculate(Duration duration) {
        long billable = Math.max(0, duration.toMinutes() - freeMinutes);
        return perMinute
                .multiply(BigDecimal.valueOf(billable))
                .setScale(2, RoundingMode.HALF_UP);
    }

    @Override
    public String name() {
        return "student";
    }
}

Verification with the FareDemo from lesson 02-02:

# With the file's values: (45-15) * 0.08 = €2.40
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar
# 45-minute rental on the 'student' fare: €2.40

# Extended agreement: 30 free minutes and €0.06/min -> (45-30) * 0.06 = €0.90
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar \
  --ciclourbana.fare.student.free-minutes=30 \
  --ciclourbana.fare.student.price-per-minute=0.06
# 45-minute rental on the 'student' fare: €0.90

Comment and a frequent mistake: if you write @Value("${ciclourbana.fare.student.freeMinutes:15}") —in camelCase— it will not bind to the file's free-minutes property: it will always take the default value of 15, silently and with no error at all. That is exactly the absence of relaxed binding from section 5, and it is one of the weighty reasons to move to @ConfigurationProperties in the next lesson.

Tip: notice that the prices are declared as BigDecimal, not as double. With money, double produces unacceptable rounding errors (0.1 + 0.2 is not 0.3). It is a rule that must never be broken in a domain that handles amounts.

Solution 3

# src/main/resources/application.yaml — versioned, with no secrets
spring:
  application:
    name: ciclourbana
  datasource:
    url: ${CICLOURBANA_DB_URL:jdbc:h2:mem:ribalta}
    username: ${CICLOURBANA_DB_USER:sa}
    # No default value: if the variable is missing, the startup fails
    password: ${CICLOURBANA_DB_PASSWORD}

server:
  port: 8080

ciclourbana:
  city: Ribalta
  fare:
    unlock: 0.50
    price-per-minute: 0.12
# Package
./mvnw clean package -DskipTests

# Prepare the operator's configuration next to the jar
mkdir -p target/config
cat > target/config/application.yaml <<'EOF'
# Production configuration for the Ribalta network.
# This file is NOT in the repository: the operator manages it.
server:
  port: 9090

ciclourbana:
  fare:
    unlock: 0.60      # high-season fare approved by the council
EOF

First attempt, without the environment variable:

cd target && java -jar ciclourbana-0.0.1-SNAPSHOT.jar
***************************
APPLICATION FAILED TO START
***************************

Description:

Could not resolve placeholder 'CICLOURBANA_DB_PASSWORD' in value
"${CICLOURBANA_DB_PASSWORD}"

Second attempt, with it defined:

cd target && CICLOURBANA_DB_PASSWORD='production-password' java -jar ciclourbana-0.0.1-SNAPSHOT.jar
Tomcat started on port 9090 (http) with context path '/'
curl -s http://localhost:9090/api/v1/stations | head -3

Comment: the failure on the first attempt is desirable. A placeholder with no default value turns a forgotten deployment step into an immediate, obvious error, instead of a silent failure at three in the morning when somebody tries to pay. It is the same "fail early and loudly" philosophy we saw with the eager creation of singletons in lesson 02-03.

A deployment tip: on a real server the password is not written on the command line —it would be visible in ps aux and in the shell history— but in the systemd unit file, in the corresponding EnvironmentFile with 600 permissions, or in the platform's secrets manager. In Docker it is passed as a container environment variable or, better, as a secret mounted as a file and read with configtree: (lesson 07-04).

Conclusion

CicloUrbana's configuration has left the code. You know that the Environment stores no values but an ordered list of PropertySources, and that all the precedence logic boils down to the order of that list: the most external wins, outside the jar beats inside, and with a profile beats without one. You have checked it yourself by starting the same application with a file, an environment variable, a system property and a command-line argument, and you can tell the three syntaxes apart. You know the real differences between .properties and YAML —and why the course uses YAML from here on— along with the indentation trap. You understand relaxed binding and the three rules that turn ciclourbana.network.minimum-capacity into CICLOURBANA_NETWORK_MINIMUMCAPACITY, including the removal of hyphens that breaks so many deployments. You know how to read properties with @Value, give them default values, use SpEL when it earns its place... and you also know the seven limitations that make @Value a tool for occasional use. You know how to import external configuration with spring.config.import and spring.config.additional-location. And, above all, you know that a credential never goes into the repository, why the damage is permanent and what the real alternatives are.

The Ribalta network's prices can now be changed without recompiling. But the solution has obvious cracks: the keys are scattered across several classes, a mistyped camelCase fails silently, nobody validates that the price per minute is positive, and the IDE gives no help writing the properties. The next lesson, Spring Boot Properties, fixes all four with @ConfigurationProperties: typed configuration, grouped into immutable objects, validated with Bean Validation, with automatic conversion of Duration and DataSize, and with autocompletion in the IDE. We will convert the whole fare and Ribalta network configuration to that form, which is the one the project will keep until the end of the course.

Spring Boot Course

Module 1: Introduction to Spring Boot

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved