Skip to content
Commitwise
Menu
Java Backend

Spring Boot Basics

Start a Spring Boot backend from scratch and understand what happens when it starts, from starters and auto-configuration to beans, dependency injection and configuration, in Java and Kotlin.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

You add one dependency, write a class with @RestController, run main, and there’s a web server on port 8080 answering JSON. Nobody wrote a server, a JSON converter or a thread pool. That’s Spring Boot’s promise, and its trap: when something goes wrong, you need to know what it set up for you and how to change it.

New to some of these? Open what you need, or skip ahead.

Basics:How do HTTP requests work?

Apps and servers talk with HTTP. A request has a method that says what to do (GET to read, POST to create, PUT to replace, DELETE to remove), a URL (https://api.example.com/books/42), headers (extra information, like who you are) and sometimes a body (the data, usually JSON).

The server sends back a response with a status code that says how it went (200 OK, 201 created, 404 not found, 500 server error), headers, and usually a body. Codes starting with 4 mean the client asked for something wrong; codes starting with 5 mean the server failed.

Basics:What do annotations like @Service mean?

An annotation (the @Something in Java and Kotlin) attaches extra information to a class, method, field or parameter. It doesn’t run code by itself; tools and frameworks read it and act on it.

@Service (Spring) or @ApplicationScoped (Jakarta EE) says “create one of these and manage it”, @GetMapping("/books") (Spring) or @GET @Path("/books") (Jakarta EE) says “call this method for GET requests to /books”, and @Entity tells Hibernate “this class is a database table”. Swift has a related idea in attributes and property wrappers, like @State and @MainActor.

Basics:What is an interface (or protocol)?

An interface (Kotlin, Java) or protocol (Swift) is a list of things a type promises it can do, without saying how. For example, “anything that is a PaymentMethod has a pay(amount) function”.

Different classes can implement the same interface in different ways (card, PayPal, cash), and code that only knows the interface can use any of them. That’s what lets you swap one implementation for another, for example a fake one in tests.

Basics:What are Maven and Gradle?

Maven and Gradle are build tools for Java and Kotlin. You list the libraries your project needs (its dependencies) in pom.xml (Maven) or build.gradle.kts (Gradle), and the tool downloads them, compiles your code, runs the tests and packages the app.

New projects come with a small wrapper script, ./mvnw or ./gradlew, that downloads the right version of the tool itself, so everyone on the team builds the same way.

Part Not started

What Spring Boot does for you

Think of moving into a furnished apartment. The kitchen already has a fridge and a stove, the bedroom a bed. You can replace any of them, but you can start living there on day one.

Spring is a large toolbox for Java and Kotlin backends: web, databases, security, messaging. Spring Boot is the furnished apartment built from it. It picks sensible defaults, wires the pieces together and includes a web server, so a working backend is a few lines of code. When a default doesn’t fit, you replace just that piece.

Spring Boot is an opinionated layer over the Spring Framework with three mechanisms:

Everything else is plain Spring: an ApplicationContext that creates and wires beans.

A complete backend

@SpringBootApplication
class ShopApplication

fun main(args: Array<String>) {
    runApplication<ShopApplication>(*args)
}

@RestController
class HelloController {
    @GetMapping("/hello")
    fun hello(@RequestParam(defaultValue = "world") name: String) =
        mapOf("message" to "Hello, $name!")
}
@SpringBootApplication
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

@RestController
class HelloController {
    @GetMapping("/hello")
    public Map<String, String> hello(@RequestParam(defaultValue = "world") String name) {
        return Map.of("message", "Hello, " + name + "!");
    }
}

Run it (./mvnw spring-boot:run or ./gradlew bootRun) and open http://localhost:8080/hello?name=Ana:

{ "message": "Hello, Ana!" }

What each piece does:

  • @SpringBootApplication marks the starting class. Spring looks for your other annotated classes in its package and the packages below it.
  • main starts Spring, which starts a web server on port 8080.
  • @RestController says “this class answers HTTP requests, and what its methods return is the response body”. @GetMapping("/hello") connects GET /hello to the method.
  • The Map you return is turned into JSON automatically, because the web starter includes Jackson, a JSON library, and Boot configured it.

The two lines that made all of this possible are in your build file: the starters.

Starters: features by dependency

// build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-actuator")
}
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

A starter is a bundle of libraries for one job, in versions that work together. spring-boot-starter-web brings the web framework, the Tomcat server and Jackson; spring-boot-starter-actuator adds health and metrics endpoints, like /actuator/health. You don’t write version numbers: Spring Boot picks them.

The easiest way to start a project is start.spring.io: pick Java or Kotlin, Maven or Gradle, tick the starters you need, and download a ready project.

Dev note
Spring Boot 4 also publishes the web starter as spring-boot-starter-webmvc; the old name still works.

How auto-configuration decides

@SpringBootApplication is three annotations: @SpringBootConfiguration (this is a configuration class), @ComponentScan (scan this package and below) and @EnableAutoConfiguration. The last one loads the classes listed in every jar’s META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports and evaluates their conditions:

@AutoConfiguration
@ConditionalOnClass(ObjectMapper::class)           // only if Jackson is on the classpath
class JacksonAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean                       // only if you didn't define one
    fun jacksonObjectMapper(builder: Jackson2ObjectMapperBuilder): ObjectMapper =
        builder.build()                              // spring.jackson.* properties applied here
}
@AutoConfiguration
@ConditionalOnClass(ObjectMapper.class)           // only if Jackson is on the classpath
public class JacksonAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean                       // only if you didn't define one
    ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
        return builder.build();                      // spring.jackson.* properties applied here
    }
}

That back-off rule is the whole design: your bean always wins. It’s also the most common surprise. Define your own ObjectMapper to change one setting, and Boot’s whole Jackson setup steps aside, including every spring.jackson.* property and the modules it registered. Prefer the extension points Boot offers (a Jackson2ObjectMapperBuilderCustomizer, a RestClientCustomizer, a property) over replacing a bean.

To see what was configured and why, start with --debug (or debug=true) for the conditions evaluation report, or call /actuator/conditions.

Dev note
Spring Boot 4 moved to Jackson 3, where the auto-configured type is JsonMapper and customizers are JsonMapperBuilderCustomizer. The back-off rule is the same.
  • Boot = starters (version-aligned dependency sets) + auto-configuration + embedded server + executable jar. Underneath, it’s a plain ApplicationContext.
  • @SpringBootApplication = @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan (its package and below).
  • Auto-configs are conditional (@ConditionalOnClass, @ConditionalOnMissingBean, @ConditionalOnProperty) and back off when you define the bean yourself, taking their properties with them. Prefer customizers.
  • Debug it with --debug (conditions report) or /actuator/conditions.

Check this part

  1. What does a starter like spring-boot-starter-web give you?

  2. What does @RestController with @GetMapping("/hello") do?

  3. You define your own ObjectMapper bean to change one setting. What happens to spring.jackson.* properties?

  4. How do you find out why an auto-configuration did or didn't apply?

Spring Boot = Spring plus starters (dependency bundles), auto-configuration (beans created for you when a library is present) and an embedded web server. @SpringBootApplication and one main function start it all.

Part Not started

Beans and dependency injection

A real backend has more than one class: a controller that handles HTTP, a service with the business rules, a repository that talks to the database. Each needs the next one.

You could create them yourself with new. Spring offers something better: mark your classes, and it creates one of each and hands each class what it asks for in its constructor. The objects Spring creates and manages are called beansSpring beanAn object that Spring creates, configures and hands to other objects, usually one shared instance per application.Open in glossary →, and handing them to each other is dependency injectionDependency injectionGiving an object the things it needs from outside, instead of letting it create them itself.Open in glossary →.

The ApplicationContext is an inversion-of-controlInversion of controlA design where a framework creates your objects and calls your code, instead of your code creating everything and calling the framework.Open in glossary → container: it reads bean definitions (from component scanning and @Bean methods), resolves the dependency graph, and eagerly creates singletons at startup, so a missing or ambiguous dependency fails the boot instead of the first request. Some beans are wrapped in proxies (@Transactional, @Cacheable, @Async): callers get the proxy, which is why calling such a method from the same class skips the behavior.

interface QuoteSource {
    fun all(): List<String>
}

@Service
class InMemoryQuoteSource : QuoteSource {
    override fun all() = listOf("Ship it.", "Make it work, then make it fast.")
}

@Service
class QuoteService(private val source: QuoteSource) {   // Spring passes the bean in
    fun random(): String = source.all().random()
}

@RestController
class QuoteController(private val quotes: QuoteService) {
    @GetMapping("/quote")
    fun quote() = mapOf("quote" to quotes.random())
}
public interface QuoteSource {
    List<String> all();
}

@Service
class InMemoryQuoteSource implements QuoteSource {
    public List<String> all() {
        return List.of("Ship it.", "Make it work, then make it fast.");
    }
}

@Service
class QuoteService {
    private final QuoteSource source;

    QuoteService(QuoteSource source) {              // Spring passes the bean in
        this.source = source;
    }

    String random() {
        var quotes = source.all();
        return quotes.get(ThreadLocalRandom.current().nextInt(quotes.size()));
    }
}

@RestController
class QuoteController {
    private final QuoteService quotes;

    QuoteController(QuoteService quotes) {
        this.quotes = quotes;
    }

    @GetMapping("/quote")
    Map<String, String> quote() {
        return Map.of("quote", quotes.random());
    }
}

What happens at startup:

  1. Spring scans the package of your @SpringBootApplication class and below, and finds the classes marked @Service, @RestController, @Repository or @Component.
  2. It sees that QuoteController needs a QuoteService, which needs a QuoteSource. The only QuoteSource bean is InMemoryQuoteSource.
  3. It creates them in that order, passing each one into the next constructor.

A class without one of those annotations isn’t a bean, and Spring can’t pass it to anyone: startup fails with “required a bean of type … that could not be found”. Notice also that QuoteService asks for the interface, QuoteSource. Later you can replace the in-memory list with a database version without touching QuoteService.

One of each, shared

By default, Spring creates one instance of each bean and gives the same one to everyone who asks, for every request, on every thread. That’s efficient, and it has one firm rule: a bean must not keep data about a single request or user in its fields. Keep that data in local variables and method parameters.

Your own objects as beans: @Bean

Classes you didn’t write, like a library’s HTTP client, can’t get a @Service annotation. Create them in a @Configuration class instead; every @Bean method’s result becomes a bean:

@Configuration
class ClockConfig {
    @Bean
    fun clock(): Clock = Clock.systemUTC()
}

@Service
class OrderService(private val clock: Clock) {      // gets the Clock bean
    fun placedAt(): Instant = clock.instant()       // a test can pass a fixed Clock
}
@Configuration
class ClockConfig {
    @Bean
    Clock clock() {
        return Clock.systemUTC();
    }
}

@Service
class OrderService {
    private final Clock clock;

    OrderService(Clock clock) {                     // gets the Clock bean
        this.clock = clock;
    }

    Instant placedAt() {
        return clock.instant();                     // a test can pass a fixed Clock
    }
}

Constructor injection, not field injection

You’ll see older code put @Autowired on fields. Prefer the constructor: the class can’t be created without its dependencies, the fields can be final (val), and a unit test can create it with new QuoteService(fakeSource) without starting Spring. With a single constructor, Spring uses it automatically; no @Autowired needed.

Choosing between candidates

SituationWhat Spring doesFix
No bean of the typeStartup fails: “required a bean … that could not be found”Annotate the class, check it’s under the scanned package, or add a @Bean
Two beans of the typeStartup fails: “expected single matching bean but found 2”@Primary on the default, @Qualifier("name") at the injection point, or inject List<T> / Map<String, T> to get all of them
Optional dependencyFails if missingObjectProvider<T> (getIfAvailable()), or a conditional bean
Circular dependencyFails by default since Boot 2.6Redesign: extract the shared part into a third bean

Scopes other than singleton exist (prototype, request, session), but a request-scoped bean injected into a singleton is a proxy that resolves per request: correct, and easy to misunderstand. Per-request data is usually clearer as a method parameter.

Kotlin note

Kotlin classes are final by default, but Spring creates subclasses (proxies) of configuration classes and of beans with @Transactional or @Cacheable. The kotlin("plugin.spring") compiler plugin, added by start.spring.io, opens classes with Spring annotations automatically. Without it, @Transactional on a final class silently does nothing or startup fails.

  • ApplicationContext = IoC container: bean definitions from component scan and @Bean, eager singleton creation at startup (fail fast).
  • Constructor injection, single constructor, no @Autowired. Field injection hides dependencies and breaks plain unit tests.
  • Two candidates: @Primary, @Qualifier, or inject List<T>. Circular references fail by default (Boot 2.6+).
  • Singletons are shared across threads: no per-request state in fields. Proxied beans (@Transactional, @Cacheable) skip the proxy on self-invocation.

Kotlin

  • kotlin("plugin.spring") opens annotated classes so proxies work.
@Service
class CheckoutService(
    @Qualifier("stripe") private val payments: PaymentGateway,
    private val rules: List<DiscountRule>,                // every DiscountRule bean
)
@Service
class CheckoutService {
    private final PaymentGateway payments;
    private final List<DiscountRule> rules;              // every DiscountRule bean

    CheckoutService(@Qualifier("stripe") PaymentGateway payments, List<DiscountRule> rules) {
        this.payments = payments;
        this.rules = rules;
    }
}

Check this part

  1. A controller needs a QuoteService. How does it get one in Spring?

  2. How many QuoteService objects does Spring create by default?

  3. Two beans implement PaymentGateway and a service injects one. What happens?

  4. Why prefer constructor injection over @Autowired fields?

Spring creates objects from your annotated classes (beans), one of each by default, and passes them to each other through constructors. Shared beans must not keep per-request data.

Part Not started

Configuration and running

Values that change between your laptop and production, like URLs, timeouts and feature switches, don’t belong in code. Spring Boot reads them from src/main/resources/application.yml (or application.properties):

server:
  port: 8080

app:
  greeting: "Hello"
  quotes:
    max-length: 120

Read a group of settings into a typed class:

@ConfigurationProperties("app.quotes")
data class QuoteProperties(val maxLength: Int)

@SpringBootApplication
@ConfigurationPropertiesScan                         // finds @ConfigurationProperties classes
class ShopApplication

@Service
class QuoteService(private val props: QuoteProperties) {
    fun fits(quote: String) = quote.length <= props.maxLength  // max-length in YAML → maxLength here
}
@ConfigurationProperties("app.quotes")
public record QuoteProperties(int maxLength) {}

@SpringBootApplication
@ConfigurationPropertiesScan                         // finds @ConfigurationProperties classes
public class ShopApplication { /* main as before */ }

@Service
class QuoteService {
    private final QuoteProperties props;

    QuoteService(QuoteProperties props) {
        this.props = props;
    }

    boolean fits(String quote) {
        return quote.length() <= props.maxLength();  // max-length in YAML → maxLength here
    }
}
  • @ConfigurationProperties("app.quotes") fills the class from everything under app.quotes in the YAML. Spring matches max-length to maxLength for you.
  • The properties class is a bean like any other, so services get it through their constructor.
  • For a single value, @Value("${app.greeting}") on a constructor parameter also works, but a typed class keeps related settings together and is checked at startup.

Overriding settings

The same app runs everywhere; only its settings change. Spring Boot looks in several places, and the more specific ones win:

  1. application.yml inside the app: the defaults.
  2. application-prod.yml, used when the profile prod is active (spring.profiles.active=prod).
  3. Environment variables: APP_QUOTES_MAXLENGTH=200 overrides app.quotes.max-length.
  4. Command-line arguments: java -jar shop.jar --server.port=9090.

Never put passwords or API keys in application.yml: it ends up in Git. Use environment variables (or a secrets manager) in production, and keep only harmless local defaults in the file.

Running and checking it

./mvnw package                    # or ./gradlew bootJar
java -jar target/shop-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod
curl localhost:8080/actuator/health
# {"status":"UP"}

The build produces one executable jar with your code, all libraries and the web server inside. Actuator’s /actuator/health answers UP when the app and the things it checks (database, disk space) are fine; load balancers and Kubernetes use it to decide whether to send traffic.

Practice

Create your first project, step by step

  1. Generate it. On start.spring.io choose Gradle (Kotlin) or Maven, your language, the latest stable Spring Boot and Java 21, and add Spring Web and Spring Boot Actuator.
  2. Open it in IntelliJ IDEA. Find the class with @SpringBootApplication and run its main.
  3. Add a controller next to it (same package or below) with a @GetMapping("/hello").
  4. Try it: open http://localhost:8080/hello and http://localhost:8080/actuator/health.
  5. Change the port in application.yml to 8081, restart, and check the log line “Tomcat started on port 8081”.

Predict the output

A client calls GET /home, then GET /about, then GET /home. What does the last response contain?

@Service
class VisitCounter {
    private var count = 0
    fun next() = ++count
}

@RestController
class HomeController(private val counter: VisitCounter) {
    @GetMapping("/home") fun home() = counter.next()
}

@RestController
class AboutController(private val counter: VisitCounter) {
    @GetMapping("/about") fun about() = counter.next()
}
@Service
class VisitCounter {
    private int count = 0;
    int next() { return ++count; }
}

@RestController
class HomeController {
    private final VisitCounter counter;
    HomeController(VisitCounter counter) { this.counter = counter; }

    @GetMapping("/home") int home() { return counter.next(); }
}

@RestController
class AboutController {
    private final VisitCounter counter;
    AboutController(VisitCounter counter) { this.counter = counter; }

    @GetMapping("/about") int about() { return counter.next(); }
}
Spot the bugThe app won't start

Tap the line with the bug.

class GreetingService {
    fun greet(name: String) = "Hello, $name"
}

@RestController
class GreetingController(private val service: GreetingService) {
    @GetMapping("/greet")
    fun greet(@RequestParam name: String) = service.greet(name)
}
public class GreetingService {
    public String greet(String name) {
        return "Hello, " + name;
    }
}

@RestController
public class GreetingController {
    private final GreetingService service;

    public GreetingController(GreetingService service) {
        this.service = service;
    }

    @GetMapping("/greet")
    public String greet(@RequestParam String name) {
        return service.greet(name);
    }
}

Exerciseeasy

A configurable quote API

Build GET /quote that returns a random quote as { "quote": "…" }, with two settings in application.yml:

  • app.quotes.prefix (for example "Quote of the day: "), added in front of every quote,
  • app.quotes.items, the list of quotes itself.

Use a controller, a service and a typed properties class.

›

YAML lists are written with - on each line. A List<String> items field (Kotlin: val items: List<String>) in the properties class receives them.

›

The controller depends on the service, the service depends on the properties class. Don’t forget @ConfigurationPropertiesScan on the application class.

SolutionTry it yourself first
@ConfigurationProperties("app.quotes")
data class QuoteProperties(val prefix: String, val items: List<String>)

@Service
class QuoteService(private val props: QuoteProperties) {
    fun random() = props.prefix + props.items.random()
}

@RestController
class QuoteController(private val quotes: QuoteService) {
    @GetMapping("/quote")
    fun quote() = mapOf("quote" to quotes.random())
}
@ConfigurationProperties("app.quotes")
public record QuoteProperties(String prefix, List<String> items) {}

@Service
class QuoteService {
    private final QuoteProperties props;

    QuoteService(QuoteProperties props) {
        this.props = props;
    }

    String random() {
        var items = props.items();
        return props.prefix() + items.get(ThreadLocalRandom.current().nextInt(items.size()));
    }
}

@RestController
class QuoteController {
    private final QuoteService quotes;

    QuoteController(QuoteService quotes) {
        this.quotes = quotes;
    }

    @GetMapping("/quote")
    Map<String, String> quote() {
        return Map.of("quote", quotes.random());
    }
}
app:
  quotes:
    prefix: "Quote of the day: "
    items:
      - "Ship it."
      - "Make it work, then make it fast."
      - "Naming things is hard."

Notice that no class creates another one, and no class reads the YAML file. Each one asks for what it needs, and Spring connects them. To try a different list in production, set it in application-prod.yml or through environment variables, without rebuilding the app.

Property sources and binding

The full order has more than a dozen sources; the practical ones, highest priority first: command-line args, SPRING_APPLICATION_JSON, OS environment variables, profile-specific files outside the jar, then inside the jar, then application.yml. spring.config.import pulls in more (a config tree mounted from Kubernetes secrets, Vault, a config server).

Relaxed binding maps app.quotes.max-length, APP_QUOTES_MAXLENGTH and app.quotes.maxLength to the same property, but only for @ConfigurationProperties; @Value is stricter. Add @Validated and Bean Validation annotations to fail startup on a bad value instead of at 3 a.m.

Production puzzles

Predict the output

app.vat-rate is 0.2 in application.yml. What happens when the app starts?

@Service
class PriceService {
    @Value("\${app.vat-rate}")
    lateinit var vatRate: BigDecimal

    private val multiplier = BigDecimal.ONE + vatRate

    fun gross(net: BigDecimal) = net * multiplier
}
@Service
public class PriceService {
    @Value("${app.vat-rate}")
    private BigDecimal vatRate;

    private final BigDecimal multiplier = BigDecimal.ONE.add(vatRate);

    public BigDecimal gross(BigDecimal net) {
        return net.multiply(multiplier);
    }
}
Spot the bugReceipts sent to the wrong customer

Tap the line with the bug.

@Service
class CheckoutService(
    private val pricing: PricingService,
    private val payments: PaymentGateway,
) {
    private lateinit var customer: Customer

    fun checkout(c: Customer, cart: Cart): Receipt {
        customer = c
        val total = pricing.total(cart)            // ~80 ms
        payments.charge(customer, total)           // ~300 ms
        return Receipt(customer.email, total)
    }
}
@Service
public class CheckoutService {
    private Customer customer;
    private final PricingService pricing;
    private final PaymentGateway payments;

    public CheckoutService(PricingService pricing, PaymentGateway payments) {
        this.pricing = pricing;
        this.payments = payments;
    }

    public Receipt checkout(Customer c, Cart cart) {
        this.customer = c;
        BigDecimal total = pricing.total(cart);   // ~80 ms
        payments.charge(customer, total);         // ~300 ms
        return new Receipt(customer.email(), total);
    }
}

Exercisemedium

Configure an external payments client

Your service calls a payments provider. Today the URL is hard-coded, the API key is read with System.getenv in three places, timeouts are the HTTP client’s defaults (none), and developers comment code in and out to use a fake locally. Design the configuration:

  • typed, validated settings with a sensible default timeout,
  • one place that builds the HTTP client,
  • the real client in every environment except local, a fake in local,
  • the secret never in the repository.
›

A @ConfigurationProperties record with @Validated and Bean Validation annotations fails startup on a missing key. Duration fields bind from strings like 5s.

›

Inject Boot’s auto-configured RestClient.Builder (it carries observability and message converters) and set a request factory with timeouts. Two @Bean methods with @Profile("local") and @Profile("!local") choose the implementation.

SolutionTry it yourself first
@Validated
@ConfigurationProperties("payments")
data class PaymentsProperties(
    @field:NotNull val baseUrl: URI?,
    @field:NotBlank val apiKey: String?,
    @DefaultValue("5s") val timeout: Duration,
)

@Configuration
@EnableConfigurationProperties(PaymentsProperties::class)
class PaymentsConfig {

    @Bean
    @Profile("!local")
    fun httpPaymentsClient(builder: RestClient.Builder, props: PaymentsProperties): PaymentsClient {
        val http = HttpClient.newBuilder().connectTimeout(props.timeout).build()
        val factory = JdkClientHttpRequestFactory(http).apply { setReadTimeout(props.timeout) }

        val rest = builder
            .baseUrl(props.baseUrl.toString())
            .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer ${props.apiKey}")
            .requestFactory(factory)
            .build()
        return HttpPaymentsClient(rest)
    }

    @Bean
    @Profile("local")
    fun fakePaymentsClient(): PaymentsClient = FakePaymentsClient()   // always approves, logs the call
}
@Validated
@ConfigurationProperties("payments")
public record PaymentsProperties(
        @NotNull URI baseUrl,
        @NotBlank String apiKey,
        @DefaultValue("5s") Duration timeout) {}

@Configuration
@EnableConfigurationProperties(PaymentsProperties.class)
class PaymentsConfig {

    @Bean
    @Profile("!local")
    PaymentsClient httpPaymentsClient(RestClient.Builder builder, PaymentsProperties props) {
        var http = HttpClient.newBuilder().connectTimeout(props.timeout()).build();
        var factory = new JdkClientHttpRequestFactory(http);
        factory.setReadTimeout(props.timeout());

        RestClient rest = builder
                .baseUrl(props.baseUrl().toString())
                .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + props.apiKey())
                .requestFactory(factory)
                .build();
        return new HttpPaymentsClient(rest);
    }

    @Bean
    @Profile("local")
    PaymentsClient fakePaymentsClient() {
        return new FakePaymentsClient();          // always approves, logs the call
    }
}
payments:
  base-url: https://api.payments.example.com
  timeout: 3s
  # api-key comes from the PAYMENTS_APIKEY environment variable or a mounted secret
  • Fail fast. A missing key or URL stops startup with a clear binding error, instead of a 401 on the first real payment. (In Kotlin the fields are nullable so validation, not a constructor exception, reports them.)
  • Timeouts are explicit. A provider that hangs can’t hold your request threads forever; tune the value per environment without a rebuild.
  • The secret is bound like any property (PAYMENTS_APIKEY via relaxed binding, or a Kubernetes secret with spring.config.import=configtree:/etc/secrets/), so code never reads the environment directly.
  • Callers depend on PaymentsClient, the interface. Profiles pick the implementation, and tests can supply their own without either.
  • Precedence (high → low): command-line args, SPRING_APPLICATION_JSON, env vars, profile files outside, inside the jar, application.yml. spring.config.import for config trees, Vault, config servers.
  • @ConfigurationProperties (records / data classes, relaxed binding, @Validated, @DefaultValue) over scattered @Value. Bind secrets like any property; never commit them.
  • @Profile picks beans per environment; avoid profile checks in business code.
  • Executable jar + Actuator health/readiness probes; /actuator/conditions and /actuator/configprops show what Boot did.
@Validated
@ConfigurationProperties("search")
data class SearchProperties(@field:NotNull val url: URI?, @DefaultValue("2s") val timeout: Duration)
@Validated
@ConfigurationProperties("search")
public record SearchProperties(@NotNull URI url, @DefaultValue("2s") Duration timeout) {}

Check this part

  1. application.yml sets server.port: 8080, and you start the jar with --server.port=9090. Which port is used?

  2. Where should a production API key go?

  3. Why prefer @ConfigurationProperties over many @Value fields?

  4. How should the code choose between a real and a fake payments client?

Settings live in application.yml and can be overridden by profiles, environment variables and command-line arguments. Read them into typed @ConfigurationProperties classes, and check the app with Actuator.

Key takeaways

  • Spring Boot gives you working defaults: starters bring libraries, auto-configuration sets them up, and an embedded server runs your app.
  • Classes marked @Service, @RestController and friends become beans; Spring passes them to each other through constructors.
  • There’s one instance of each bean, shared by every request: never store one user’s data in a field.
  • Settings live in application.yml, read through typed @ConfigurationProperties classes, and are overridden per environment without rebuilding.
  • Auto-configuration is conditional and backs off when you define the bean, taking its properties with it: prefer customizers.
  • Constructor injection only; singletons are shared across threads, so mutable fields are a review red flag.
  • Field-injected values aren’t available during construction; bind typed, validated properties instead.
  • Use --debug, /actuator/conditions and /actuator/configprops to see what Boot did, instead of guessing.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.