Spring Boot in Production: 12 Practices That Matter

The decisions that separate a Spring Boot application that works on your machine from one that survives production — layering, JPA pitfalls, security, testing and observability.


Spring Boot makes it easy to get an endpoint responding in ten minutes. It does not make it easy to build something that stays maintainable after eighteen months and four developers.

These are the practices that make the difference, in rough order of how much pain they save.


1. Constructor injection, always

// Do this
@Service
public class OrderService {
    private final OrderRepository repository;
    private final PaymentGateway gateway;

    public OrderService(OrderRepository repository, PaymentGateway gateway) {
        this.repository = repository;
        this.gateway = gateway;
    }
}
// Not this
@Service
public class OrderService {
    @Autowired private OrderRepository repository;
    @Autowired private PaymentGateway gateway;
}

Field injection costs you three things: the fields can't be final, so the object is mutable after construction; you can't instantiate the class in a unit test without a Spring context or reflection; and a constructor with eight parameters becomes visibly ugly, which is useful — it's the class telling you it does too much. Field injection hides that signal.

If the boilerplate bothers you, Lombok's @RequiredArgsConstructor removes it. Just be aware that adding Lombok to a team that doesn't use it is a decision, not a detail.


2. Never expose entities through your API

This is the mistake I see most often in code that otherwise looks fine.

// Don't
@GetMapping("/{id}")
public User findById(@PathVariable Long id) {
    return userRepository.findById(id).orElseThrow();
}

Four problems, and they compound. The password hash and internal flags are now in the JSON response. Your database schema is now your public API contract, so renaming a column breaks the frontend. Lazy associations serialise unexpectedly and either explode or trigger a cascade of queries. And a PUT accepting an entity lets a client set fields you never intended to expose.

Map to a DTO at the boundary. Every time, including for the small endpoints that "obviously don't need it".


3. Transaction boundaries belong in the service layer

Not the controller, not the repository. The service is where a unit of business work lives, and that's what a transaction should wrap.

@Service
@Transactional(readOnly = true)
public class TransferService {

    @Transactional
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        Account from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
        Account to = accountRepository.findByIdForUpdate(toId).orElseThrow();

        from.debit(amount);
        to.credit(amount);
    }
}

Two things people get wrong here. First, @Transactional works through a proxy, so calling a transactional method from another method in the same class bypasses it entirely — the call doesn't go through the proxy. Move it to a separate bean. Second, by default Spring only rolls back on unchecked exceptions; a checked exception commits unless you declare @Transactional(rollbackFor = ...).


4. Learn the N+1 problem before it finds you

You have 100 orders, each with a customer. This looks harmless:

List<Order> orders = orderRepository.findAll();
orders.forEach(o -> log.info(o.getCustomer().getName()));

That's 101 queries. One for the orders, one per customer. On a page that renders fine in development with 10 rows and times out in production with 10,000.

Fix it by fetching what you need up front:

@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
List<Order> findByStatusWithCustomer(@Param("status") OrderStatus status);

Or declaratively:

@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findByStatus(OrderStatus status);

To catch these before your users do, log the SQL in development:

logging:
  level:
    org.hibernate.SQL: DEBUG

Then open a page and count. It's an uncomfortable exercise the first time.


5. Make every @ManyToOne lazy

JPA defaults @ManyToOne and @OneToOne to EAGER, which is almost never what you want — loading one order silently drags in the customer, and the customer's address, and so on down the graph.

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;

Set it explicitly on every to-one association, and fetch what you need with a join fetch or entity graph at the query site, where the decision belongs.


6. Turn off open-in-view

spring:
  jpa:
    open-in-view: false

This is on by default, and Spring Boot logs a warning about it at startup that most people learn to ignore. It keeps the Hibernate session open through view rendering, which means lazy loading silently works in your controller — and hides every N+1 you have, while holding a database connection for the entire request.

Turning it off will break things. That's the point: it surfaces the places where you were loading data outside a transaction without realising.


7. Version your schema, never let Hibernate own it

spring:
  jpa:
    hibernate:
      ddl-auto: validate

ddl-auto: update is convenient in week one and a liability by month three: it never drops columns, applies changes in an order you don't control, and gives you no history and no rollback path.

Use Flyway. Migrations are plain SQL files, applied in order, recorded in a table:

src/main/resources/db/migration/
├── V1__create_users_table.sql
├── V2__add_orders_table.sql
└── V3__add_index_on_orders_status.sql

validate then makes startup fail loudly if your entities and your schema have drifted — which is exactly when you want to find out.


8. One error contract for the whole API

@RestControllerAdvice
public class GlobalExceptionHandler {

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

    @ExceptionHandler(ResourceNotFoundException.class)
    public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(AccessDeniedException.class)
    public ProblemDetail handleAccessDenied(AccessDeniedException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, "Access denied");
    }

    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(Exception ex) {
        log.error("Unhandled exception", ex);
        return ProblemDetail.forStatusAndDetail(
            HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
    }
}

Note the last handler: log the real exception, return a generic message. Stack traces in an HTTP response are a genuine information leak — they expose your framework versions, your package structure, and occasionally your SQL.


9. Security: the modern configuration

Spring Security 6 removed WebSecurityConfigurerAdapter. A large share of the tutorials you'll find still use it. The current form is a SecurityFilterChain bean with the lambda DSL:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtFilter) {
        this.jwtFilter = jwtFilter;
    }

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s ->
                s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Disabling CSRF is correct only for a stateless token-based API. If you use cookie sessions, leave it on.

@EnableMethodSecurity then lets you put authorisation next to the business rule, which is where it's actually readable:

@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public UserResponse findById(Long userId) { ... }

10. Test at three levels, not one

@SpringBootTest on everything is slow and tells you little about where a failure came from. Use the sliced annotations:

Service logic — plain JUnit and Mockito, no Spring context:

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock private OrderRepository repository;
    @InjectMocks private OrderService service;

    @Test
    void rejectsOrderBelowMinimumAmount() {
        var request = new CreateOrderRequest("SKU-1", BigDecimal.ONE);

        assertThatThrownBy(() -> service.create(request))
            .isInstanceOf(InvalidOrderException.class);
    }
}

Milliseconds per test. This is where most of your tests should live.

Web layer — @WebMvcTest, controllers only, services mocked:

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired private MockMvc mockMvc;
    @MockitoBean private OrderService orderService;

    @Test
    void returns400WhenAmountIsNegative() throws Exception {
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"sku": "SKU-1", "amount": -5}
                    """))
            .andExpect(status().isBadRequest());
    }
}

Persistence — Testcontainers against the real database:

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired private OrderRepository repository;

    @Test
    void findsOrdersByStatus() { ... }
}

Testing JPA against H2 when you run PostgreSQL in production tests the wrong thing. @ServiceConnection wires the container's connection details into Spring automatically, which removes the boilerplate that used to make this annoying.


11. Make the application observable

Add Actuator and expose only what you need:

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  endpoint:
    health:
      show-details: when-authorized

/actuator/health gives your orchestrator a probe. /actuator/prometheus gives you metrics. Never expose the full endpoint set publicly — some of them dump your entire configuration, environment variables included.

For logs, think about the machine that reads them. Structured JSON with a correlation ID per request turns debugging a production incident from archaeology into a query.


12. Configuration and secrets

Three rules, in order of how badly it goes when you break them.

Secrets come from the environment, never the repository. ${DB_PASSWORD} in your YAML, the value injected at deploy time. A committed secret lives in git history forever; rotating it is the only real fix.

Bind configuration to typed records, not scattered @Value annotations:

@ConfigurationProperties(prefix = "app.storage")
public record StorageProperties(String bucket, Duration urlTtl, int maxFileSizeMb) {}

Typos fail at startup rather than at the first request that touches the property.

Use profiles for environments, and keep the differences small. If application-prod.yml and application-dev.yml have diverged substantially, you're no longer testing what you deploy.


Two things worth knowing about the current generation

Virtual threads. On Java 21 or later:

spring:
  threads:
    virtual:
      enabled: true

Each request gets a virtual thread instead of a pooled platform thread. For the I/O-bound workload most APIs actually have — waiting on a database, waiting on an HTTP call — this raises concurrency substantially with no code change. It is not a fix for CPU-bound work, and it doesn't make your connection pool bigger, so size that deliberately.

Spring Boot 4. The current line is built on Spring Framework 7, with a Jakarta EE 11 baseline, Jackson 3 as the default, and first-class support for API versioning and declarative HTTP clients via @HttpExchange. If you're following a tutorial written for 3.x, most of it still applies — but check the migration guide before assuming an import path is right.


Where to go next

The official Spring Boot reference documentation is unusually good, and it's kept current in a way that most blog content isn't. When something here conflicts with the docs, the docs win.


Notes assembled while working through Spring Boot on the JVM after several years on NestJS. Corrections welcome.