Java: Spring Boot & Web Services

Keywords

java, spring boot, spring, dependency injection, inversion of control, rest api, spring security, beans, auto-configuration, enterprise

Introduction

The order service started, as these things do, as a single class that did everything. It opened its own database connection, parsed its own configuration file, constructed its own HTTP server, and somewhere in the middle of all that, did the actual work of placing an order. When a second engineer joined, the class had grown a constructor that took eleven arguments, and every one of them was built with new somewhere up the call stack. To add a payment integration, you traced the object graph by hand: who creates the PaymentClient, who passes it the credentials, who hands it to the service that needs it. Half of every change was plumbing.

The real pain showed up in the tests, or rather in their absence. The team wanted to unit-test the order logic — does it reject an out-of-stock item, does it compute the total correctly — but the order class constructed its own database connection in its constructor. There was no seam. To test one method you had to stand up a real database, so the “unit” tests were slow integration tests that nobody ran. The logic was sound; it was simply welded to its dependencies, and welded things can’t be taken apart to examine in isolation.

This is the failure Spring exists to prevent. The object had taken responsibility for constructing its collaborators on top of using them — two different jobs — and the moment one class does both, you cannot substitute a collaborator (a fake database for a test, a different payment provider for staging) without editing the class. Spring’s answer is to take construction away from your objects entirely: a container builds the object graph, and your classes just declare what they need and receive it. Wiring that used to be hand-traced and untestable becomes invisible, and on top of that inversion, Spring Boot adds enough sensible defaults that a production-grade service — server, JSON, validation, metrics, security — is a few annotations rather than weeks of setup. Spring is the JVM’s enterprise standard for one reason above all: it makes the wiring disappear.

The Core Insight

The order class tangled two responsibilities into one. It decided how to build its dependencies (open this connection string, instantiate that client) and it used them to do work. That conflation is the root cause of every symptom above: the eleven-argument constructor, the hand-traced object graph, the untestable logic. As long as a class builds the things it depends on, you cannot give it different things — and a class you cannot give different things to is one you cannot test in isolation, configure per environment, or reuse.

Inversion of Control breaks the tangle by flipping who is in charge of construction. Instead of each object reaching out to build or look up its collaborators, an outside authority — the container — builds the whole graph and hands each object the collaborators it declared. The class no longer says “I need a database, so I’ll open one”; it says “I need a UserRepository, give me one,” and the container decides what to provide. This specific form of IoC — passing dependencies in rather than constructing them inside — is Dependency Injection. The payoff is threefold and concrete:

  1. Decoupling. A class depends on the interface it was handed, not on a particular concrete implementation it built. Swap the implementation — a real JpaRepository in production, an in-memory stub in a test — and the class doesn’t change a line.
  2. Testability. Because every collaborator arrives through the constructor, every collaborator can be replaced with a mock. The seam that the welded order class lacked is now structural: testing in isolation is the default, not a heroic effort.
  3. Centralized configuration. The graph is described in one place — the container’s wiring — instead of smeared across a dozen new expressions. Change a connection string or a bean’s scope once, and every consumer gets it.

Spring Boot layers a second insight on top of the first. The original Spring container solved wiring but still demanded mountains of configuration: XML files declaring every bean, explicit setup for the web server, the data source, the transaction manager. Boot replaces that with convention over configuration and auto-configuration — it inspects what is on your classpath and configures the obvious thing automatically. Put a JPA starter on the classpath and Boot configures a DataSource and an EntityManager; put spring-web on it and Boot starts an embedded Tomcat; put spring-security on it and Boot builds a security filter chain. You write the parts that are specific to your application and inherit the rest as working defaults you can override when you must.

A mental model

Think of the Spring container as a factory that owns construction. You hand it blueprints — your classes, each annotated to say “I am a component, and my constructor needs a UserRepository and a Clock.” The factory reads every blueprint, works out the dependency order (you can’t build the service before the repository it needs), constructs each component exactly once, and threads the finished parts into a complete machine. Your code never says how anything is built; it declares what it needs and the factory decides how to supply it. That is the whole game: components declare requirements, the container resolves them. To test one gear in isolation you don’t dismantle the factory — you build that one gear by hand, pass it a fake of each neighbour, and examine it on the bench.

Spring Boot is best understood as sensible defaults you can override. Where plain Spring made you specify everything, Boot assumes the common case: a default web server, JSON mapper, connection pool, and port, each a single property or @Bean away from being replaced. The mental shift is from “configure everything before anything works” to “everything works; configure only what differs.”

When Spring fits (and when it’s heavy)

Spring is the dominant choice for enterprise JVM work, and the reasons are mostly about ecosystem and longevity rather than raw performance. Figure 21.1 shows the shape it gives an application — a container wiring layered components with auto-configured infrastructure underneath; the decision here is whether you want that shape and that ecosystem at all.

Reach for Spring Boot when you are building long-lived server software on the JVM with a team and a calendar measured in years: enterprise CRUD systems with complex business rules, services that must integrate with the rich existing world of JVM libraries (messaging, LDAP, legacy databases, batch), and projects where a large team benefits from one well-trodden, heavily-documented way to do things. The ecosystem is the moat — fifteen-plus years of Spring Data, Spring Security, and Spring Cloud, and an answer on Stack Overflow for every question. If your team already knows Spring the case is nearly automatic; the cost of migrating away is high and the marginal benefit usually low.

Weigh the cost where Spring’s weight bites. A JVM Spring Boot service typically starts in two to five seconds and idles at a couple of hundred megabytes of heap — fine for a service that runs for months, a real tax for a serverless function billed by the cold start or an edge deployment with a tight memory ceiling. For those, a framework built for fast startup and low memory (Quarkus, Micronaut) or GraalVM native compilation of Spring itself fits better; and for a small service or a team centered on another language, a lighter stack in that language beats dragging in the full Spring machine. Spring earns its overhead on substantial, long-running, integration-heavy systems, not on a 200-line throwaway.

What you’ll learn

  • How Inversion of Control and Dependency Injection invert who builds the object graph, and why that inversion is what makes components testable in isolation
  • How the Spring container constructs and wires beans, what a bean’s lifecycle and scope are, and why constructor injection beats field injection in every case that matters
  • How Spring Boot’s auto-configuration and starters turn convention into a working application, and how the controller→service→repository layering organizes the result
  • How to build a REST API with @RestController, request mapping, bean validation, DTOs, and a single global exception handler instead of scattered try/catch blocks
  • How Spring Security’s filter chain enforces authentication and authorization, and where a token check fits in a single-service request
  • How Spring Data repositories and @Transactional give you persistence and clean transaction boundaries with almost no boilerplate — and the proxy trap that comes with it
  • How to make a service production-ready: Actuator health and metrics, profiles and externalized config, graceful shutdown, and the fat-jar/container deploy

Prerequisites

  • Java: Modern Java — records, sealed types, and the annotation model; Spring leans heavily on records for DTOs and on annotations for everything else
  • Concurrency and Parallelism Models — the thread-per-request model and thread pools that sit under every Spring MVC request, and the virtual threads that are reshaping that model
  • Working knowledge of HTTP and REST (methods, status codes, content negotiation) and of SQL and relational data, which the data-access section assumes
  • Comfort with Maven or Gradle and building a runnable JAR

Inversion of Control, dependency injection, and beans

Everything in Spring rests on the container, so start there. A bean is simply an object whose construction and lifecycle the container manages. You mark a class as a component — @Component, or the more specific @Service, @Repository, @RestController, which are the same thing with intent attached — and at startup the container scans your packages, finds every such class, and adds it to the ApplicationContext: a registry of beans, built once, that lives for the life of the application. The context is not magic; it is, almost literally, a map from a type to the single constructed instance of that type. @Autowired and constructor injection just look up beans in that map. There is no per-request reflection conjuring; wiring is resolved once, at boot.

The container resolves dependencies by following the constructor parameters. When it goes to build a bean and sees that the constructor needs a UserRepository, it finds the UserRepository bean (building it first if necessary) and passes it in. It works the graph bottom-up: the repository before the service that needs it, the service before the controller that needs it. Figure 21.1 shows exactly this — the container injecting a repository into a service into a controller, with the infrastructure beans (server, data source, security) supplied by auto-configuration. The shape of the code is unremarkable, which is the point: declare what you need as a final constructor parameter and the container does the rest.

// The service depends on an interface, not a concrete class it built.
// The container sees the constructor parameter and injects the bean.
@Service
class UserService {
    private final UserRepository repository;   // final: set once, never null

    UserService(UserRepository repository) {    // the only way in
        this.repository = repository;
    }
}

That single choice — constructor injection — is the most consequential habit in Spring. The tempting alternative is field injection: drop @Autowired onto a field and skip the constructor. It looks tidier. It is a trap. A field-injected dependency cannot be final, so the object can exist half-constructed; the dependencies are invisible from outside, so the eleven-argument problem hides instead of announcing itself; and worst, there is no way to supply a dependency except through Spring’s reflection, so you cannot construct the object in a plain unit test without standing up a container. Constructor injection makes dependencies explicit, lets them be final and immutable, and — the decisive property — lets a test build the object directly with mocks. The seam the welded order class lacked is exactly this constructor.

Beans also have a lifecycle and a scope. The container instantiates a bean, injects its dependencies, runs any @PostConstruct init, keeps it alive, and runs @PreDestroy cleanup at shutdown. The scope governs how many instances exist: the default is singleton — one shared instance per container, the right answer for stateless services and repositories. Other scopes (prototype per injection, request/session for web lifetimes) exist for the rare case that needs them. Singleton is safe precisely because idiomatic beans are stateless: they hold their collaborators and nothing else, so sharing one instance across every thread is fine.

Note

The singleton-by-default scope is why Spring beans must be stateless. A bean that stashes per-request data in a field is a data-corruption bug waiting for concurrency: every request shares the one instance, so two requests racing on the same field will clobber each other. Keep request state in method parameters and local variables, where each thread has its own copy, and let the bean hold only its injected, immutable collaborators.

Spring Boot: auto-configuration, starters, and layering

The container solves wiring; Spring Boot solves the setup that used to surround it. The mechanism is starters plus auto-configuration. A starter is a curated dependency bundle: spring-boot-starter-web pulls in Tomcat, the JSON mapper, and the validation library at mutually-compatible versions, so you depend on one artifact instead of assembling a dozen by hand and praying the versions agree. Auto-configuration then reads the classpath at startup and applies conditional configuration based on what it finds. Each piece of auto-config is guarded by a condition — “configure a DataSource only if a JDBC driver is present,” “start an embedded server only if a servlet web library is on the classpath.” Add the JPA starter and a Postgres driver, and a connection-pooled DataSource and an EntityManager appear, fully wired, with no XML and no @Bean of your own. Override any of it with a property in application.yml or your own @Bean, which the conditions defer to.

The entry point ties it together in one annotation. @SpringBootApplication is a composite of @Configuration (this class can define beans), @EnableAutoConfiguration (turn on the classpath scanning above), and @ComponentScan (find my @Components). The main method hands control to Spring, which builds the context, runs auto-configuration, and starts the embedded server.

@SpringBootApplication   // = @Configuration + @EnableAutoConfiguration + @ComponentScan
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);  // build context, start server
    }
}

Auto-configuration gives you working infrastructure; the layered architecture is the convention for organizing the code you write on top of it, and it falls out of the container’s wiring naturally. Idiomatic Spring code is three tiers, each a layer of beans the container injects into the one above. The web layer (@RestController) deals only with HTTP — deserialize the request, validate it, call down, serialize the response, set the status code. It holds no business rules and touches no database. The service layer (@Service) holds the business logic and owns the transaction boundaries; it orchestrates repositories, enforces domain rules, and maps between DTOs and entities. The data layer (@Repository) is persistence and nothing else. The discipline is that each layer knows only the layer directly below it, and dependencies always point downward — exactly the constructor-injected chain in Figure 21.1. This separation is not bureaucracy; it is what lets you test the service layer with a mocked repository, swap the data layer’s implementation, or expose the same service through a second protocol without rewriting the rules.

Building a REST API

With the layering in place, a REST endpoint is a thin web-layer method. @RestController marks the class as a controller whose return values are serialized straight to the response body (it combines @Controller and @ResponseBody), and @RequestMapping sets the base path. Within it, @GetMapping, @PostMapping, and friends bind HTTP methods to Java methods; @PathVariable and @RequestParam pull values out of the URL; and @RequestBody deserializes the JSON request into an object. The controller’s whole job is to translate between HTTP and method calls, then delegate.

Two practices keep that translation clean. First, validate at the boundary: annotate the incoming object’s fields with bean-validation constraints (@NotBlank, @Email, @Positive) and mark the parameter @Valid, and Spring rejects a malformed request before your code runs, with a 400 and field-level errors. Second, use DTOs, not entities, at the edge. A DTO (a Java record is ideal — immutable, concise, exactly the shape of the contract) decouples your API from your database model, so you don’t leak internal fields like a password hash, and the two can evolve independently. The controller maps the request DTO into a domain call and maps the result back to a response DTO.

@RestController
@RequestMapping("/api/users")
class UserController {
    private final UserService users;                 // constructor-injected (omitted)
    UserController(UserService users) { this.users = users; }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)              // 201, declaratively
    UserResponse create(@Valid @RequestBody CreateUserRequest req) {  // validated at the edge
        return UserResponse.from(users.create(req.toUser()));         // DTO in, DTO out
    }
}

The remaining question is what happens when something goes wrong, and the wrong answer is a try/catch in every controller method. Spring’s mechanism is the global exception handler: a class annotated @RestControllerAdvice whose @ExceptionHandler methods map exception types to HTTP responses, applied across every controller. Your service layer throws a meaningful domain exception — UserNotFoundException, DuplicateEmailException — and lets it propagate; the advice catches it and renders the right status and a consistent error body. Controllers stay focused on the happy path, error handling lives in one place, and every endpoint returns errors in the same shape.

@RestControllerAdvice
class GlobalExceptionHandler {
    @ExceptionHandler(UserNotFoundException.class)
    ResponseEntity<ErrorBody> notFound(UserNotFoundException ex) {     // one place, every controller
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(new ErrorBody(404, ex.getMessage()));
    }
}

Spring Security basics

Authentication and authorization in Spring are enforced by a filter chain that sits in front of your controllers — a series of servlet filters, each with one job, that every request passes through before it ever reaches a handler method. This is the single most useful thing to internalize about Spring Security: it is not scattered checks inside your code, it is a pipeline of filters that runs first. One filter establishes who the caller is (authentication); a later step decides whether they may do what they asked (authorization). If a filter rejects the request, your controller is never invoked. You configure the chain declaratively by defining a SecurityFilterChain bean.

@Configuration
@EnableWebSecurity
class SecurityConfig {
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()   // open
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())                   // default: locked
            .sessionManagement(s -> s.sessionCreationPolicy(STATELESS))  // no server session
            .build();
    }
}

The order of those rules matters and the default matters more. Rules are evaluated top-to-bottom, first match wins, and anyRequest().authenticated() should be the last line so that the default is locked down — anything you didn’t explicitly open requires authentication. The opposite default, where everything is open unless you remember to close it, is how endpoints get exposed (see the war story below). For a stateless API, SessionCreationPolicy.STATELESS tells Spring not to create a server-side session; the caller proves their identity on every request, typically with a bearer token. A token fits here as a custom filter early in the chain: it reads the Authorization: Bearer ... header, validates the token, and populates the security context with the authenticated user, so the authorization rules above have someone to check. Passwords, when you store them, go through a PasswordEncoder bean (BCryptPasswordEncoder is the standard) and are never stored in plaintext.

This chapter scopes security to a single service. The harder distributed questions — where tokens come from, how a token is trusted across many services, how an identity provider issues and rotates them — belong to the microservices material; here the point is the filter-chain mechanism and the locked-by-default posture.

War story: field injection, a hidden cycle, and an open door

A team shipped a ReportService that used field injection — @Autowired straight onto its BillingClient field, no constructor. It worked, so nobody looked. Two things hid behind that convenience. When a refactor introduced a mutual dependency between ReportService and AuditService, field injection let the circular dependency slip to runtime instead of failing at construction; the app started, then threw BeanCurrentlyInCreationException intermittently under load — a day to trace, because the dependencies were invisible in the constructors. And when someone finally wrote the unit test, there was no way to construct the service with a fake BillingClient without booting the whole Spring context, so the “unit” test pulled up a database and was quietly deleted. Both problems vanished when the fields became final constructor parameters: the cycle now failed at startup with a clear message, and the test built the service in one line with two mocks.

The same week, a security review found a new /api/internal/metrics endpoint world-readable. The SecurityFilterChain had been written rule-by-rule with explicit permitAll() entries and no terminal anyRequest().authenticated(), so any path not named defaulted to open — and the new endpoint had never been added. The fix was one line: make the last rule deny by default, so a forgotten endpoint fails closed instead of open. Two unrelated incidents, one lesson: prefer the configuration that fails loudly and locks down by default over the one that is quietly permissive.

Data access and transactions

Persistence is where Spring’s boilerplate-elimination is most dramatic. Spring Data JPA lets you declare a repository as an interface and generates the implementation at startup. Extend JpaRepository<User, Long> and you inherit save, findById, findAll, delete, and pagination for free. Beyond that, Spring Data derives queries from method names: declare Optional<User> findByEmail(String email) and it parses the name into SELECT * FROM users WHERE email = ? and implements it. For anything the name-derivation can’t express, a @Query annotation carries explicit JPQL or native SQL. You write an interface; the container supplies a working repository bean.

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);          // derived from the method name
    boolean existsByEmail(String email);

    @Query("SELECT u FROM User u LEFT JOIN FETCH u.orders WHERE u.id = :id")
    Optional<User> findByIdWithOrders(@Param("id") Long id);   // explicit JPQL
}

The service layer owns transactions, and it does so declaratively with @Transactional. Annotate a service method and Spring opens a transaction before it runs and commits after it returns — or rolls back if it throws — so a method that saves an order, reserves inventory, and charges payment either does all three or none, never a half. Mark read-only methods @Transactional(readOnly = true) to let Hibernate skip dirty-checking and the connection pool optimize. Two sharp edges come with the convenience. @Transactional is implemented with a proxy — Spring wraps the bean at startup with a proxy that opens the transaction around your call — which means it only works on external calls into the bean: a method calling another @Transactional method on the same object bypasses the proxy entirely, and the inner transaction silently never starts. And by default Spring rolls back on unchecked exceptions but not checked ones, so a checked exception thrown mid-method commits the partial work unless you specify rollbackFor = Exception.class. Both bite quietly; both are worth knowing before they do.

Tip

The N+1 query problem is the most common Spring Data performance trap. A list endpoint fetches N entities in one query, then lazily loads a related entity per row — one query becomes N+1, and a page that flew with test data crawls in production. The fix is to fetch the association eagerly when you know you’ll need it, with a JOIN FETCH in the query or an @EntityGraph, turning N+1 queries back into one.

Production: Actuator, profiles, graceful shutdown, and the deploy

A service is production-ready when operators can see it, configure it, and stop it safely. Spring Boot supplies all three with little code. Actuator adds production endpoints out of the box: /actuator/health reports whether the app and its dependencies (database, cache) are up — exactly what a container orchestrator or load balancer probes to decide whether to send traffic — and /actuator/metrics exposes counters and timers through Micrometer, ready to scrape into Prometheus. You expose only the endpoints you want and keep health details hidden from anonymous callers:

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, prometheus   # nothing else exposed
  endpoint:
    health:
      show-details: when-authorized                  # no internals to the public

Profiles and externalized configuration keep one build deployable everywhere. application.yml holds defaults; application-prod.yml overrides only what differs in production; and the active profile is selected at runtime (SPRING_PROFILES_ACTIVE=prod). Secrets and environment-specific values come from outside the artifact — environment variables and ${...} placeholders, never committed — so the same jar that ran in CI runs in production with different config injected around it. A production profile typically flips ddl-auto to validate (never let Hibernate alter a production schema; manage it with migrations), turns off SQL logging, tightens the connection pool, and stops leaking stack traces in error responses.

Graceful shutdown matters because a service is stopped constantly — every deploy, autoscale-down, and rolling restart kills pods. Without it, a SIGTERM drops in-flight requests on the floor; with it, Spring stops accepting new requests and gives the running ones a window to finish before exiting. It is two properties:

server:
  shutdown: graceful            # stop accepting, drain in-flight requests
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

Finally, the deploy artifact is a fat jar: mvn package produces one self-contained JAR with your code, every dependency, and an embedded server inside, run with java -jar app.jar. There is no external servlet container to install and version-match — the server ships in the artifact, which is what makes the same jar run identically on a laptop, in CI, and in production. That self-containment also makes Spring Boot a clean fit for a container: the standard Dockerfile is a multi-stage build that compiles the jar in a JDK stage and copies it into a slim JRE image with a non-root user, exactly the pattern the containerization chapter develops in full.


Practical exercise

Difficulty: Level I · Level II · Level III

  1. Level I — A validated endpoint with constructor injection. Scaffold a Spring Boot project (start.spring.io, web + validation starters) and build one @RestController with a POST /api/widgets endpoint. Take a request as a Java record DTO with bean-validation constraints (@NotBlank, @Positive), mark the parameter @Valid, and return 201 Created. Inject one collaborator through the constructor (a final field, no @Autowired annotation needed for a single constructor). Verify that a malformed request gets a 400 and a valid one gets a 201 — and write down which class constructed which collaborator, so the inversion is concrete to you.
  2. Level II — Add a layer, a security rule, and the test that pays off. Introduce a @Service and a Spring Data @Repository (an interface extending JpaRepository), with constructor injection wiring controller→service→repository. Add a SecurityFilterChain that permits the public endpoint and requires authentication for the rest, with anyRequest().authenticated() as the terminal rule. Then write a unit test for the service in which the repository is a Mockito mock — no Spring context, no database — asserting that the service rejects a duplicate and saves a valid entity. The test should build the service in one line with the mock; if it can’t, your injection is wrong. This is DI paying for itself.
  3. Level III — Make it production-grade, then argue the trade-off. Take the service to operations readiness: add Actuator with health and metrics exposed (details hidden from anonymous callers), externalize all environment-specific config into a prod profile and environment variables (no secrets in the repo), enable graceful shutdown with a drain timeout, and package it as a fat jar in a multi-stage Docker image running as a non-root user. Confirm /actuator/health returns UP and that SIGTERM drains in-flight requests rather than dropping them. Then write a one-page argument: for this workload, where is Spring’s startup-time and memory weight worth the ecosystem and productivity it buys, and where would a lighter stack (Quarkus/Micronaut native, or another language) win — name the specific deployment shapes (serverless, edge, long-running enterprise service) that tip the decision each way.

Summary

Spring exists to take construction away from your objects. Left to themselves, classes that build their own dependencies become impossible to substitute, configure, or test in isolation — the welded order service that opened the chapter. Inversion of Control flips who builds the graph: a container constructs every bean and injects its declared collaborators, so components depend on interfaces they were handed rather than implementations they built. That single inversion buys decoupling, centralized configuration, and — through constructor injection specifically — testability, because every collaborator can be replaced with a mock. Spring Boot adds convention over configuration on top: starters bundle compatible dependencies, auto-configuration wires working infrastructure from the classpath, and a production-grade service becomes a few annotations over a controller→service→repository spine. On that spine you build REST APIs with validation and a single global exception handler, enforce a locked-by-default security filter chain, get persistence and clean transaction boundaries from Spring Data and @Transactional, and ship a self-contained fat jar instrumented with Actuator. Spring earns its real weight — seconds of startup, hundreds of megabytes of heap — on substantial, long-lived, integration-heavy JVM systems, and not on small or short-lived ones.

Key takeaways

  • IoC/DI inverts construction: the container builds the object graph and injects collaborators, which is what decouples components and makes them testable in isolation.
  • Always use constructor injection with final fields. Field injection hides dependencies, prevents immutability, conceals circular dependencies until runtime, and blocks plain unit testing.
  • Spring Boot is convention over configuration: starters + auto-configuration give you a working server, data source, and security from the classpath, all overridable.
  • Keep the layers clean — web handles HTTP, service owns business logic and transactions, data does persistence — and validate at the boundary with DTOs and a global exception handler, not scattered try/catch.
  • @Transactional is proxy-based: it only intercepts external calls, and by default rolls back on unchecked but not checked exceptions. Security should lock down by default, with anyRequest().authenticated() as the terminal rule.
  • Production readiness is Actuator (health/metrics), profiles + externalized config, graceful shutdown, and a self-contained fat jar — most of it a few properties away.

Connections to other chapters

  • Java: Modern Java (prerequisite): Spring leans on the modern language you learned there — records are the idiomatic DTO, and annotations drive every wiring decision. The immutability that records provide is the same property constructor injection gives a bean, applied at the data layer.
  • Concurrency and Parallelism Models (prerequisite): every Spring MVC request runs on a thread from a pool, the thread-per-request model from that chapter made concrete. The singleton-bean rule — beans must be stateless because one instance is shared across all those threads — is a direct application of its lessons, and virtual threads under Spring Boot are that chapter’s newest idea reshaping this one’s execution model.
  • Python: Design Patterns (sibling): dependency injection, inversion of control, and clean layered architecture are not Spring inventions — they are the same patterns taught there, which Spring simply institutionalizes into a framework. Reading the two together shows the pattern in the abstract and then welded into a production container.
  • Python: Microservices (extension): a Spring service is one node in the distributed systems that chapter covers. The general theory — service boundaries, resilience, inter-service auth, where tokens come from across many services — lives there; this chapter builds the single Spring node that plugs into it.
  • Containerization with Docker (extension): the fat jar this chapter produces is the artifact that chapter packages. The multi-stage build that compiles the jar and copies it into a slim, non-root JRE image is the concrete deploy path for everything built here.

Further reading

Essential

  • Spring Boot Reference Documentation (spring.io) — the canonical, current reference for starters, auto-configuration, Actuator, and externalized configuration.
  • Craig Walls, Spring in Action (Manning, 6th ed.) — the standard book-length introduction to the Spring ecosystem, from DI through web, data, and security.

Deep dives

  • Martin Fowler, “Inversion of Control Containers and the Dependency Injection Pattern”
    1. — the essay that named and clarified the pattern Spring is built on; read it to understand DI independent of any framework.
  • Spring Framework Reference — Core Technologies (spring.io) — the deep treatment of the container, bean lifecycle, scopes, and the proxy-based AOP that powers @Transactional.

Historical context

  • Rod Johnson, Expert One-on-One J2EE Design and Development (2002) — the book whose reference implementation became Spring; the original argument against the heavyweight EJB programming model of early enterprise Java.
  • The J2EE / EJB era it reacted to — understanding the XML-and-ceremony world Spring replaced, and then the XML-heavy early Spring that Spring Boot in turn replaced, explains why “make the wiring disappear” is the through-line of the whole platform.