How do I write unit tests for Spring components?

Writing Unit Tests for Spring Components

For Spring components, you usually want to test business logic without starting the full Spring application context. That means using JUnit 5 and Mockito for most unit tests.

Use Spring’s test support only when you need Spring-specific behavior such as dependency injection, MVC request handling, configuration binding, or persistence integration.


1. Unit Test a Spring @Service

Example service:

package com.example.order;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    public Order createOrder(String customerEmail) {
        if (customerEmail == null || customerEmail.isBlank()) {
            throw new IllegalArgumentException("Customer email is required");
        }

        Order order = new Order(customerEmail);
        return orderRepository.save(order);
    }
}

Unit test:

package com.example.order;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @InjectMocks
    private OrderService orderService;

    @Test
    void createOrderSavesOrder() {
        Order savedOrder = new Order("[email protected]");

        when(orderRepository.save(any(Order.class))).thenReturn(savedOrder);

        Order result = orderService.createOrder("[email protected]");

        assertEquals("[email protected]", result.getCustomerEmail());
        verify(orderRepository).save(any(Order.class));
    }

    @Test
    void createOrderRejectsBlankEmail() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> orderService.createOrder(" ")
        );

        assertEquals("Customer email is required", exception.getMessage());
    }
}

This is a true unit test because no Spring context is started.


2. Unit Test a Spring @Component

Example component:

package com.example.notification;

import org.springframework.stereotype.Component;

@Component
public class EmailValidator {

    public boolean isValid(String email) {
        return email != null && email.contains("@");
    }
}

Test:

package com.example.notification;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

class EmailValidatorTest {

    private final EmailValidator emailValidator = new EmailValidator();

    @Test
    void returnsTrueForValidEmail() {
        assertTrue(emailValidator.isValid("[email protected]"));
    }

    @Test
    void returnsFalseForInvalidEmail() {
        assertFalse(emailValidator.isValid("invalid-email"));
        assertFalse(emailValidator.isValid(null));
    }
}

If a component has no dependencies, just instantiate it directly.


3. Unit Test a Spring MVC @Controller

For controllers, use @WebMvcTest. This loads only the MVC layer, not the whole application.

Example controller:

package com.example.order;

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    @GetMapping("/orders/{id}")
    public OrderResponse getOrder(@PathVariable Long id) {
        return orderService.getOrder(id);
    }
}

Controller test:

package com.example.order;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private OrderService orderService;

    @Test
    void getOrderReturnsOrder() throws Exception {
        when(orderService.getOrder(1L))
                .thenReturn(new OrderResponse(1L, "[email protected]"));

        mockMvc.perform(get("/orders/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1L))
                .andExpect(jsonPath("$.customerEmail").value("[email protected]"));
    }
}

In newer Spring Boot versions, prefer @MockitoBean over the older @MockBean.


4. Unit Test Repository-Using Services

If your service depends on a Spring Data JPA repository, mock the repository in a unit test.

package com.example.user;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @Test
    void findUserReturnsUser() {
        User user = new User(1L, "[email protected]");

        when(userRepository.findById(1L)).thenReturn(Optional.of(user));

        User result = userService.findUser(1L);

        assertEquals("[email protected]", result.getEmail());
    }
}

Do not use a real database for a unit test. If you want to test repository mappings or queries, use an integration/slice test such as @DataJpaTest.


5. Test Spring Data JPA Repositories with @DataJpaTest

This is not a pure unit test, but it is the standard way to test repositories.

package com.example.user;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertTrue;

@DataJpaTest
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    void findByEmailReturnsUser() {
        User user = new User();
        user.setEmail("[email protected]");

        userRepository.save(user);

        Optional<User> result = userRepository.findByEmail("[email protected]");

        assertTrue(result.isPresent());
    }
}

Use this when you want to verify:

  • JPA mappings
  • repository query methods
  • custom JPQL/native queries
  • database constraints

6. Recommended Dependencies

For Maven, the common Spring Boot test starter is usually enough:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

It includes commonly used testing libraries such as:

  • JUnit Jupiter
  • AssertJ
  • Mockito
  • Spring Test
  • MockMvc support

7. Common Testing Patterns

Arrange, Act, Assert

@Test
void methodNameExpectedBehavior() {
    // Arrange
    when(repository.findById(1L)).thenReturn(Optional.of(entity));

    // Act
    Result result = service.doSomething(1L);

    // Assert
    assertEquals(expectedValue, result.value());
}

Verify interactions only when meaningful

verify(repository).save(any(Order.class));

Avoid verifying every single method call. Prefer verifying observable behavior.

Test exceptions

@Test
void throwsExceptionWhenUserNotFound() {
    assertThrows(
            UserNotFoundException.class,
            () -> userService.findUser(999L)
    );
}

8. Choosing the Right Test Type

Component Recommended test style
Plain utility/component Instantiate directly
@Service with dependencies JUnit 5 + Mockito
@Controller @WebMvcTest + MockMvc
Repository @DataJpaTest
Full application flow @SpringBootTest

Rule of Thumb

Use the smallest test scope that proves the behavior:

  • Business logic: plain JUnit + Mockito
  • Web layer: @WebMvcTest
  • Persistence layer: @DataJpaTest
  • End-to-end Spring wiring: @SpringBootTest

Most Spring component unit tests should not need @SpringBootTest.

How do I secure a Java web application with authentication and authorization?

Securing a Java web application typically means adding:

  1. Authentication — verifying who the user is.
  2. Authorization — deciding what the authenticated user can access.
  3. Session/token protection — keeping the login state secure.
  4. Transport and application hardening — HTTPS, CSRF protection, password hashing, etc.

Since your stack includes Spring MVC / Spring Data JPA / Jakarta EE, the most common approach is Spring Security.


1. Add Spring Security

If you use Maven:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-web</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-config</artifactId>
</dependency>

If this is a Spring Boot app, use:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

2. Create a Security Configuration

For modern Spring Security, define a SecurityFilterChain.

package com.example.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/", "/login", "/css/**", "/js/**").permitAll()
                        .requestMatchers("/admin/**").hasRole("ADMIN")
                        .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/login")
                        .defaultSuccessUrl("/dashboard", true)
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/")
                        .invalidateHttpSession(true)
                        .deleteCookies("JSESSIONID")
                )
                .build();
    }
}

This configuration means:

URL Access
/, /login, static files Public
/user/** USER or ADMIN
/admin/** ADMIN only
Everything else Must be logged in

3. Store Users in the Database

A simple JPA entity could look like this:

package com.example.user;

import jakarta.persistence.CollectionTable;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;

import java.util.Set;

@Entity
@Table(name = "app_users")
@Getter
@Setter
public class User {

    @Id
    private Long id;

    private String username;

    private String password;

    private boolean enabled = true;

    @ElementCollection(fetch = FetchType.EAGER)
    @CollectionTable(
            name = "app_user_roles",
            joinColumns = @JoinColumn(name = "user_id")
    )
    private Set<String> roles;
}

Example roles:

ROLE_USER
ROLE_ADMIN

Spring Security’s hasRole("ADMIN") checks for ROLE_ADMIN internally.


4. Create a Repository

package com.example.user;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByUsername(String username);
}

5. Implement UserDetailsService

Spring Security uses UserDetailsService to load users during login.

package com.example.security;

import com.example.user.User;
import com.example.user.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

public class DatabaseUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public DatabaseUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException(username));

        return org.springframework.security.core.userdetails.User
                .withUsername(user.getUsername())
                .password(user.getPassword())
                .authorities(user.getRoles().toArray(String[]::new))
                .disabled(!user.isEnabled())
                .build();
    }
}

Register it as a bean:

@Bean
public UserDetailsService userDetailsService(UserRepository userRepository) {
    return new DatabaseUserDetailsService(userRepository);
}

6. Hash Passwords with BCrypt

Never store plain-text passwords.

import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

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

When registering a user:

user.setPassword(passwordEncoder.encode(rawPassword));

A stored password should look similar to:

$2a$10$...

7. Add Method-Level Authorization

You can also secure service methods.

Enable method security:

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;

@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}

Then protect methods:

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
public class ReportService {

    @PreAuthorize("hasRole('ADMIN')")
    public void deleteReport(Long reportId) {
        // admin-only logic
    }

    @PreAuthorize("hasAnyRole('USER', 'ADMIN')")
    public Object viewReport(Long reportId) {
        // user/admin logic
        return new Object();
    }
}

8. Protect Against CSRF

For browser-based apps using sessions and forms, keep CSRF enabled.

Spring Security enables CSRF by default.

In a Thymeleaf form, include the CSRF token:

<form method="post" action="/profile">
    <input type="hidden"
           name="${_csrf.parameterName}"
           value="${_csrf.token}" />

    <button type="submit">Save</button>
</form>

For JSP:

<input type="hidden"
       name="${_csrf.parameterName}"
       value="${_csrf.token}" />

For stateless REST APIs using JWT, CSRF is often disabled because the API does not rely on cookies for authentication.


9. Use HTTPS

Always run authentication over HTTPS.

For production:

server.ssl.enabled=true
server.ssl.key-store=classpath:keystore.p12
server.ssl.key-store-password=changeit
server.ssl.key-store-type=PKCS12
server.ssl.key-alias=myapp

If your app is behind a reverse proxy, configure forwarded headers correctly so Spring knows the original request was HTTPS.

server.forward-headers-strategy=framework

10. Use Secure Session Cookies

Configure cookies as secure and HTTP-only.

server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=lax

Use same-site=strict if your app does not require cross-site navigation flows.


11. Example REST API Authorization

For controller endpoints:

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AdminController {

    @GetMapping("/admin/stats")
    @PreAuthorize("hasRole('ADMIN')")
    public String stats() {
        return "admin stats";
    }
}

Or configure URL-based access:

.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")

12. JWT Option for Stateless APIs

If your frontend is separate, such as React, Angular, mobile, or another client, you may use JWT or OAuth2.

Typical setup:

.oauth2ResourceServer(oauth2 -> oauth2.jwt())

Then secure routes:

.authorizeHttpRequests(auth -> auth
        .requestMatchers("/api/public/**").permitAll()
        .requestMatchers("/api/admin/**").hasRole("ADMIN")
        .anyRequest().authenticated()
)

For production, prefer a standards-based identity provider such as:

  • Keycloak
  • Auth0
  • Okta
  • Microsoft Entra ID
  • AWS Cognito

13. Important Security Checklist

Use this checklist before production:

  • Hash passwords with BCrypt, Argon2, or PBKDF2
  • Never store plain-text passwords
  • Use HTTPS only
  • Keep CSRF enabled for session-based browser apps
  • Use secure HTTP-only cookies
  • Validate all user inputs
  • Apply authorization in the service layer for sensitive business operations
  • Avoid exposing stack traces or internal errors
  • Lock down admin endpoints
  • Use least-privilege roles
  • Add audit logging for sensitive actions
  • Rate-limit login attempts
  • Use MFA for admin users
  • Keep dependencies updated
  • Add security headers
  • Do not put secrets in source code

Minimal Spring Security Setup

A compact working configuration could look like this:

package com.example.security;

import com.example.user.UserRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/", "/login", "/css/**", "/js/**").permitAll()
                        .requestMatchers("/admin/**").hasRole("ADMIN")
                        .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/login")
                        .defaultSuccessUrl("/dashboard", true)
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutSuccessUrl("/")
                        .invalidateHttpSession(true)
                        .deleteCookies("JSESSIONID")
                )
                .build();
    }

    @Bean
    public UserDetailsService userDetailsService(UserRepository userRepository) {
        return new DatabaseUserDetailsService(userRepository);
    }

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

For most Java web applications, the recommended path is:

Spring Security + database-backed users + BCrypt password hashing + role-based authorization + HTTPS + CSRF protection.

How do I use Java Streams without making my code hard to read?

Using Java Streams readably is mostly about using them where they express intent clearly and avoiding “clever” pipelines that hide business logic.

Good uses of Streams

Streams are great when you are doing simple collection transformations:

List<String> activeUserEmails = users.stream()
        .filter(User::isActive)
        .map(User::getEmail)
        .toList();

This reads almost like a sentence:

From users, keep active ones, get their emails, collect to a list.

Prefer method references when they are obvious

Readable:

List<Long> ids = orders.stream()
        .map(Order::getId)
        .toList();

Less readable:

List<Long> ids = orders.stream()
        .map(order -> order.getId())
        .toList();

Both are valid, but the method reference is simpler here.

However, do not force method references if a lambda is clearer:

List<Order> expensiveOrders = orders.stream()
        .filter(order -> order.total().compareTo(BigDecimal.valueOf(1000)) > 0)
        .toList();

Name complex predicates

If your filter condition gets complicated, extract it.

Hard to read:

List<Customer> customers = customers.stream()
        .filter(customer -> customer.isActive()
                && customer.getBalance().compareTo(BigDecimal.ZERO) > 0
                && customer.getLastOrderDate().isAfter(cutoffDate))
        .toList();

Better:

List<Customer> eligibleCustomers = customers.stream()
        .filter(customer -> isEligible(customer, cutoffDate))
        .toList();

private boolean isEligible(Customer customer, LocalDate cutoffDate) {
    return customer.isActive()
            && customer.getBalance().compareTo(BigDecimal.ZERO) > 0
            && customer.getLastOrderDate().isAfter(cutoffDate);
}

The stream now says what you are doing, and the helper explains how.

Avoid deeply nested streams

This is usually a readability warning sign:

List<String> productNames = orders.stream()
        .flatMap(order -> order.getLineItems().stream()
                .filter(item -> item.getQuantity() > 0)
                .map(item -> item.getProduct().getName()))
        .distinct()
        .sorted()
        .toList();

This is not terrible, but if it grows more complex, extract the inner logic:

List<String> productNames = orders.stream()
        .flatMap(order -> validProductNames(order).stream())
        .distinct()
        .sorted()
        .toList();

private List<String> validProductNames(Order order) {
    return order.getLineItems().stream()
            .filter(item -> item.getQuantity() > 0)
            .map(item -> item.getProduct().getName())
            .toList();
}

Do not use streams for a complicated control flow

Streams are not ideal when you need lots of branching, mutation, logging, exception handling, or early exits.

Less readable:

orders.stream()
        .filter(order -> {
            if (order.isCancelled()) {
                log.info("Skipping cancelled order {}", order.getId());
                return false;
            }

            if (!order.hasValidPayment()) {
                log.warn("Skipping unpaid order {}", order.getId());
                return false;
            }

            return true;
        })
        .forEach(this::ship);

A plain loop may be clearer:

for (Order order : orders) {
    if (order.isCancelled()) {
        log.info("Skipping cancelled order {}", order.getId());
        continue;
    }

    if (!order.hasValidPayment()) {
        log.warn("Skipping unpaid order {}", order.getId());
        continue;
    }

    ship(order);
}

Rule of thumb:

If the stream needs block lambdas with several statements, a loop may be better.

Keep stream operations on separate lines

Prefer this:

List<ProductDto> products = products.stream()
        .filter(Product::isVisible)
        .sorted(Comparator.comparing(Product::getName))
        .map(ProductDto::from)
        .toList();

Avoid cramming everything into one line:

List<ProductDto> products = products.stream().filter(Product::isVisible).sorted(Comparator.comparing(Product::getName)).map(ProductDto::from).toList();

Vertical formatting makes each step visible.

Avoid side effects inside streams

This is usually a bad sign:

List<String> names = new ArrayList<>();

users.stream()
        .filter(User::isActive)
        .forEach(user -> names.add(user.getName()));

Prefer collecting the result directly:

List<String> names = users.stream()
        .filter(User::isActive)
        .map(User::getName)
        .toList();

Side effects inside streams can make code harder to reason about, especially if someone later changes it to parallelStream().

Use collect only when needed

In modern Java, prefer toList() when you just need a list:

List<String> emails = users.stream()
        .map(User::getEmail)
        .toList();

Use Collectors when you need something more specific:

Map<Long, User> usersById = users.stream()
        .collect(Collectors.toMap(User::getId, Function.identity()));

Or grouping:

Map<Department, List<Employee>> employeesByDepartment = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment));

Avoid overly clever collectors

This may be technically impressive but hard to maintain:

Map<Department, Set<String>> namesByDepartment = employees.stream()
        .collect(Collectors.groupingBy(
                Employee::getDepartment,
                Collectors.mapping(
                        Employee::getName,
                        Collectors.toCollection(TreeSet::new)
                )
        ));

This is acceptable if your team is comfortable with collectors. Otherwise, consider extracting it:

Map<Department, Set<String>> namesByDepartment = employees.stream()
        .collect(groupEmployeeNamesByDepartment());

private Collector<Employee, ?, Map<Department, Set<String>>> groupEmployeeNamesByDepartment() {
    return Collectors.groupingBy(
            Employee::getDepartment,
            Collectors.mapping(
                    Employee::getName,
                    Collectors.toCollection(TreeSet::new)
            )
    );
}

Use meaningful variable names

Bad:

List<String> result = list.stream()
        .filter(x -> x.isActive())
        .map(x -> x.getName())
        .toList();

Better:

List<String> activeUserNames = users.stream()
        .filter(User::isActive)
        .map(User::getName)
        .toList();

Readable streams depend heavily on meaningful names.

Be careful with Optional.stream()

This can be elegant:

List<Address> addresses = users.stream()
        .map(User::getAddress)
        .flatMap(Optional::stream)
        .toList();

But if your team is unfamiliar with it, this may be clearer:

List<Address> addresses = users.stream()
        .map(User::getAddress)
        .filter(Optional::isPresent)
        .map(Optional::get)
        .toList();

The first version is more idiomatic; the second may be easier for some teams. Prefer consistency with your codebase.

Use loops when they are clearer

Streams are not inherently better than loops.

Readable stream:

boolean hasExpiredInvoice = invoices.stream()
        .anyMatch(Invoice::isExpired);

Readable loop:

boolean hasExpiredInvoice = false;

for (Invoice invoice : invoices) {
    if (invoice.isExpired()) {
        hasExpiredInvoice = true;
        break;
    }
}

For simple matching, the stream is excellent:

boolean hasExpiredInvoice = invoices.stream()
        .anyMatch(Invoice::isExpired);

But for multistep logic, logging, error handling, or mutation, use a loop.

Practical rules of thumb

Use streams when:

  • You are filtering, mapping, sorting, grouping, or matching.
  • The pipeline has about 2–5 clear steps.
  • Each lambda is short and clear.
  • The result is a transformed collection, map, count, boolean, or optional.

Avoid streams when:

  • You need complex branching.
  • You need many side effects.
  • You need checked exception handling in lambdas.
  • The pipeline becomes deeply nested.
  • The stream is harder to debug than a loop.
  • You are using streams just to avoid writing for.

A good readable stream style

List<OrderSummary> summaries = orders.stream()
        .filter(Order::isCompleted)
        .filter(order -> order.placedAfter(startDate))
        .sorted(Comparator.comparing(Order::getPlacedAt).reversed())
        .map(OrderSummary::from)
        .toList();

This is readable because:

  • Each operation has one job.
  • The order of operations is clear.
  • The variable name explains the result.
  • Lambdas are short.
  • Business logic can be extracted if it grows.

Bottom line

Use Java Streams to make simple data transformations read like a pipeline. If the stream starts needing complex lambdas, nested streams, side effects, or lots of comments to explain it, switch to helper methods or a plain loop. Readability matters more than using Streams everywhere.

How do I build a REST API in Java using Spring Boot the right way?

The “right way” to build a REST API in Java with Spring Boot is not just about making URLs return JSON. A good REST API should be:

  • Cleanly structured
  • Easy to test
  • Easy to maintain
  • Validated properly
  • Consistent in error handling
  • Separated into controller, service, repository, entity, and DTO layers
  • Built around HTTP semantics, not just Java methods exposed over HTTP

In this article, we will build a simple User REST API using:

  • Spring Boot
  • Spring MVC
  • Spring Data JPA
  • Jakarta Persistence
  • Jakarta Validation
  • Java records
  • Java 25
  • Lombok

The example API will support basic user operations:

GET    /api/users
GET    /api/users/{id}
POST   /api/users
PUT    /api/users/{id}
DELETE /api/users/{id}

1. Create a Spring Boot Project

You can create a Spring Boot project from Spring Initializr with these dependencies:

  • Spring Web
  • Spring Data JPA
  • Validation
  • PostgreSQL Driver, MySQL Driver, or H2 Database
  • Lombok

For Maven, the important dependencies look like this:

<dependencies>
    <!-- REST API support -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring Data JPA and Hibernate -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <!-- Jakarta Bean Validation -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <!-- Example database: PostgreSQL -->
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>

    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>

    <!-- Testing -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

If you only want an in-memory database while learning, you can use H2 instead:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

2. Use a Clean Project Structure

A common clean structure is:

com.example.demo
├── DemoApplication.java
├── user
│   ├── User.java
│   ├── UserRepository.java
│   ├── UserService.java
│   ├── UserController.java
│   ├── CreateUserRequest.java
│   ├── UpdateUserRequest.java
│   └── UserResponse.java
└── exception
    ├── ApiError.java
    ├── ResourceNotFoundException.java
    └── GlobalExceptionHandler.java

This is a feature-based structure. Instead of separating everything by technical layer only, all user-related classes stay together.

For small applications, this is easy to understand. For larger applications, it also scales well because each feature remains self-contained.


3. Create the Main Spring Boot Application Class

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

Keep this class in the root package, such as:

com.example.demo

That allows Spring Boot to automatically scan subpackages such as:

com.example.demo.user
com.example.demo.exception

4. Configure the Database

For PostgreSQL, create:

src/main/resources/application.properties

Example:

spring.datasource.url=jdbc:postgresql://localhost:5432/demo
spring.datasource.username=postgres
spring.datasource.password=postgres

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

For local learning, ddl-auto=update is convenient.

For production, prefer:

spring.jpa.hibernate.ddl-auto=validate

Then manage schema changes using a migration tool such as Flyway or Liquibase.


5. Create the Entity

The entity represents the database table.

package com.example.demo.user;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;

@Entity
@Getter
@Setter
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private String email;
}

Notice the import:

import jakarta.persistence.Entity;

Modern Spring Boot uses Jakarta EE packages, not the old javax.persistence packages.


6. Create DTOs for Requests and Responses

A common mistake is exposing entities directly from controllers.

For small demos, returning entities may seem fine. But in real applications, it is better to use DTOs because they separate your API contract from your database model.

Create User Request

package com.example.demo.user;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record CreateUserRequest(
        @NotBlank(message = "Name is required")
        @Size(max = 100, message = "Name must not exceed 100 characters")
        String name,

        @NotBlank(message = "Email is required")
        @Email(message = "Email must be valid")
        @Size(max = 150, message = "Email must not exceed 150 characters")
        String email
) {
}

Update User Request

package com.example.demo.user;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record UpdateUserRequest(
        @NotBlank(message = "Name is required")
        @Size(max = 100, message = "Name must not exceed 100 characters")
        String name,

        @NotBlank(message = "Email is required")
        @Email(message = "Email must be valid")
        @Size(max = 150, message = "Email must not exceed 150 characters")
        String email
) {
}

User Response

package com.example.demo.user;

public record UserResponse(
        Long id,
        String name,
        String email
) {
}

Java records are excellent for DTOs because they are concise and immutable by default.


7. Create the Repository

Spring Data JPA provides most CRUD operations automatically.

package com.example.demo.user;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);

    boolean existsByEmail(String email);
}

By extending JpaRepository<User, Long>, you automatically get methods such as:

findAll()
findById(id)
save(entity)
delete(entity)
deleteById(id)
existsById(id)

You do not need to write SQL for basic CRUD operations.


8. Create a Custom Not Found Exception

Instead of returning null or manually building error responses everywhere, create a reusable exception.

package com.example.demo.exception;

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}

We will handle this exception globally later.


9. Create the Service Layer

The service layer contains business logic and transaction boundaries.

package com.example.demo.user;

import com.example.demo.exception.ResourceNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Transactional(readOnly = true)
    public List<UserResponse> findAll() {
        return userRepository.findAll()
                .stream()
                .map(this::toResponse)
                .toList();
    }

    @Transactional(readOnly = true)
    public UserResponse findById(Long id) {
        User user = findUserById(id);
        return toResponse(user);
    }

    @Transactional
    public UserResponse create(CreateUserRequest request) {
        if (userRepository.existsByEmail(request.email())) {
            throw new IllegalArgumentException("Email is already used");
        }

        User user = new User();
        user.setName(request.name());
        user.setEmail(request.email());

        User savedUser = userRepository.save(user);

        return toResponse(savedUser);
    }

    @Transactional
    public UserResponse update(Long id, UpdateUserRequest request) {
        User user = findUserById(id);

        user.setName(request.name());
        user.setEmail(request.email());

        return toResponse(user);
    }

    @Transactional
    public void delete(Long id) {
        User user = findUserById(id);
        userRepository.delete(user);
    }

    private User findUserById(Long id) {
        return userRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException(
                        "User with id " + id + " was not found"
                ));
    }

    private UserResponse toResponse(User user) {
        return new UserResponse(
                user.getId(),
                user.getName(),
                user.getEmail()
        );
    }
}

A few important things are happening here:

  1. The controller will not access the repository directly.
  2. Read methods use @Transactional(readOnly = true).
  3. Write methods use @Transactional.
  4. The service maps entities to response DTOs.
  5. Missing users throw a meaningful exception.

This keeps the controller thin and the business logic centralized.


10. Create the REST Controller

The controller handles HTTP details: URLs, request bodies, response status codes, and validation.

package com.example.demo.user;

import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping
    public List<UserResponse> findAll() {
        return userService.findAll();
    }

    @GetMapping("/{id}")
    public UserResponse findById(@PathVariable Long id) {
        return userService.findById(id);
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
        return userService.create(request);
    }

    @PutMapping("/{id}")
    public UserResponse update(
            @PathVariable Long id,
            @Valid @RequestBody UpdateUserRequest request
    ) {
        return userService.update(id, request);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        userService.delete(id);
    }
}

The controller is intentionally small.

It does not:

  • Contain database logic
  • Build SQL queries
  • Manage transactions
  • Know how users are persisted
  • Contain complicated business rules

Its job is HTTP handling.


11. Understand REST Endpoint Design

Good REST URLs usually identify resources using nouns.

Good:

GET    /api/users
GET    /api/users/10
POST   /api/users
PUT    /api/users/10
DELETE /api/users/10

Less ideal:

GET    /api/getUsers
POST   /api/createUser
POST   /api/deleteUser

The HTTP method already describes the action.

HTTP Method Meaning Example
GET Read data GET /api/users
POST Create new data POST /api/users
PUT Replace or update data PUT /api/users/1
PATCH Partially update data PATCH /api/users/1
DELETE Delete data DELETE /api/users/1

12. Add Global Exception Handling

A good API should return consistent error responses.

Create an API error response:

package com.example.demo.exception;

import java.time.Instant;
import java.util.List;

public record ApiError(
        int status,
        String error,
        String message,
        String path,
        Instant timestamp,
        List<FieldErrorDetail> fieldErrors
) {
    public ApiError(
            int status,
            String error,
            String message,
            String path
    ) {
        this(status, error, message, path, Instant.now(), List.of());
    }

    public ApiError(
            int status,
            String error,
            String message,
            String path,
            List<FieldErrorDetail> fieldErrors
    ) {
        this(status, error, message, path, Instant.now(), fieldErrors);
    }

    public record FieldErrorDetail(
            String field,
            String message
    ) {
    }
}

Now create the global exception handler:

package com.example.demo.exception;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ApiError handleResourceNotFoundException(
            ResourceNotFoundException ex,
            HttpServletRequest request
    ) {
        return new ApiError(
                HttpStatus.NOT_FOUND.value(),
                HttpStatus.NOT_FOUND.getReasonPhrase(),
                ex.getMessage(),
                request.getRequestURI()
        );
    }

    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiError handleIllegalArgumentException(
            IllegalArgumentException ex,
            HttpServletRequest request
    ) {
        return new ApiError(
                HttpStatus.BAD_REQUEST.value(),
                HttpStatus.BAD_REQUEST.getReasonPhrase(),
                ex.getMessage(),
                request.getRequestURI()
        );
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiError handleValidationException(
            MethodArgumentNotValidException ex,
            HttpServletRequest request
    ) {
        List<ApiError.FieldErrorDetail> fieldErrors = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> new ApiError.FieldErrorDetail(
                        error.getField(),
                        error.getDefaultMessage()
                ))
                .toList();

        return new ApiError(
                HttpStatus.BAD_REQUEST.value(),
                HttpStatus.BAD_REQUEST.getReasonPhrase(),
                "Validation failed",
                request.getRequestURI(),
                fieldErrors
        );
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ApiError handleException(
            Exception ex,
            HttpServletRequest request
    ) {
        return new ApiError(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(),
                "An unexpected error occurred",
                request.getRequestURI()
        );
    }
}

Now, when something fails, your API returns structured JSON instead of a stack trace or inconsistent response.

Example validation error:

{
  "status": 400,
  "error": "Bad Request",
  "message": "Validation failed",
  "path": "/api/users",
  "timestamp": "2026-07-06T10:15:30Z",
  "fieldErrors": [
    {
      "field": "email",
      "message": "Email must be valid"
    }
  ]
}

13. Test the API with HTTP Requests

You can use curl, Postman, HTTPie, or IntelliJ IDEA HTTP Client.

Create a User

curl -X POST http://localhost:8080/api/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice","email":"[email protected]"}'

Expected response:

{
  "id": 1,
  "name": "Alice",
  "email": "[email protected]"
}

HTTP status:

201 Created

Get All Users

curl http://localhost:8080/api/users

Example response:

[
  {
    "id": 1,
    "name": "Alice",
    "email": "[email protected]"
  }
]

Get One User

curl http://localhost:8080/api/users/1

Example response:

{
  "id": 1,
  "name": "Alice",
  "email": "[email protected]"
}

Update a User

curl -X PUT http://localhost:8080/api/users/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice Smith","email":"[email protected]"}'

Example response:

{
  "id": 1,
  "name": "Alice Smith",
  "email": "[email protected]"
}

Delete a User

curl -X DELETE http://localhost:8080/api/users/1

Expected status:

204 No Content

14. Add Basic Controller Tests

Testing your controller helps ensure the API contract works as expected.

Here is an example using @WebMvcTest and MockMvc.

package com.example.demo.user;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import java.util.List;

import static org.hamcrest.Matchers.hasSize;
import static org.mockito.ArgumentMatchers.any;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockitoBean
    private UserService userService;

    @Test
    void shouldReturnUsers() throws Exception {
        Mockito.when(userService.findAll())
                .thenReturn(List.of(
                        new UserResponse(1L, "Alice", "[email protected]"),
                        new UserResponse(2L, "Bob", "[email protected]")
                ));

        mockMvc.perform(get("/api/users"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$", hasSize(2)))
                .andExpect(jsonPath("$[0].name").value("Alice"))
                .andExpect(jsonPath("$[1].name").value("Bob"));
    }

    @Test
    void shouldCreateUser() throws Exception {
        CreateUserRequest request = new CreateUserRequest(
                "Alice",
                "[email protected]"
        );

        Mockito.when(userService.create(any(CreateUserRequest.class)))
                .thenReturn(new UserResponse(1L, "Alice", "[email protected]"));

        mockMvc.perform(post("/api/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.name").value("Alice"))
                .andExpect(jsonPath("$.email").value("[email protected]"));
    }

    @Test
    void shouldRejectInvalidCreateUserRequest() throws Exception {
        CreateUserRequest request = new CreateUserRequest(
                "",
                "invalid-email"
        );

        mockMvc.perform(post("/api/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isBadRequest());
    }
}

Testing at this level verifies:

  • URL mappings
  • HTTP status codes
  • JSON request/response structure
  • Validation behavior
  • Controller-service interaction

15. Common REST API Best Practices

Use DTOs Instead of Exposing Entities

Avoid this in real APIs:

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

Prefer this:

@GetMapping("/{id}")
public UserResponse findById(@PathVariable Long id) {
    return userService.findById(id);
}

DTOs give you control over what your API exposes.


Keep Controllers Thin

A controller should mostly do this:

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
    return userService.create(request);
}

Avoid putting business logic directly in the controller.


Put Transactions in Services

Use:

@Transactional
public UserResponse create(CreateUserRequest request) {
    // business operation
}

Avoid placing @Transactional on controller methods in most applications.


Use Validation on Request DTOs

Use Jakarta Validation annotations:

public record CreateUserRequest(
        @NotBlank String name,
        @Email @NotBlank String email
) {
}

Then activate validation in the controller:

public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
    return userService.create(request);
}

Return Correct HTTP Status Codes

Use meaningful status codes:

Situation Status Code
Successful read 200 OK
Successful creation 201 Created
Successful delete 204 No Content
Invalid request 400 Bad Request
Unauthorized 401 Unauthorized
Forbidden 403 Forbidden
Resource not found 404 Not Found
Conflict 409 Conflict
Server error 500 Internal Server Error

Use Plural Resource Names

Prefer:

/api/users
/api/orders
/api/products

Instead of:

/api/user
/api/order
/api/product

Use Query Parameters for Filtering

Example:

GET /api/[email protected]
GET /api/users?name=alice

Path variables are usually better for identifying a specific resource:

GET /api/users/1

Query parameters are usually better for searching, filtering, sorting, and pagination.


16. Add Pagination for Collection Endpoints

Returning all records may work during development, but it can become a problem when your table grows.

Spring Data supports pagination using Pageable.

Repository already supports it because JpaRepository includes paging methods.

Update the service:

package com.example.demo.user;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

// imports omitted

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Transactional(readOnly = true)
    public Page<UserResponse> findAll(Pageable pageable) {
        return userRepository.findAll(pageable)
                .map(this::toResponse);
    }

    private UserResponse toResponse(User user) {
        return new UserResponse(
                user.getId(),
                user.getName(),
                user.getEmail()
        );
    }
}

Update the controller:

package com.example.demo.user;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.*;

// imports omitted

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping
    public Page<UserResponse> findAll(Pageable pageable) {
        return userService.findAll(pageable);
    }
}

Now you can call:

GET /api/users?page=0&size=10

With sorting:

GET /api/users?page=0&size=10&sort=name,asc

17. A Better Response for Created Resources

For POST, you can return 201 Created with a Location header.

package com.example.demo.user;

import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.util.UriComponentsBuilder;

import java.net.URI;

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping
    public ResponseEntity<UserResponse> create(
            @Valid @RequestBody CreateUserRequest request,
            UriComponentsBuilder uriBuilder
    ) {
        UserResponse response = userService.create(request);

        URI location = uriBuilder
                .path("/api/users/{id}")
                .buildAndExpand(response.id())
                .toUri();

        return ResponseEntity
                .created(location)
                .body(response);
    }
}

This produces a response like:

HTTP/1.1 201 Created
Location: http://localhost:8080/api/users/1

This is a nice RESTful touch because the response tells the client where the new resource can be found.


18. Recommended Request Flow

A clean REST API usually follows this flow:

HTTP Request
    ↓
Controller
    ↓
Service
    ↓
Repository
    ↓
Database

And back:

Database
    ↓
Repository
    ↓
Service
    ↓
Controller
    ↓
HTTP Response

Each layer has a clear job:

Layer Responsibility
Controller Handles HTTP requests and responses
Service Contains business logic and transactions
Repository Handles database access
Entity Maps Java objects to database tables
DTO Defines API request and response shapes
Exception Handler Produces consistent error responses

19. What Makes It “The Right Way”?

A Spring Boot REST API is built the right way when it follows these principles:

  1. Use @RestController for REST endpoints
  2. Keep controllers thin
  3. Put business logic in services
  4. Use repositories only for data access
  5. Use DTOs at the API boundary
  6. Validate request bodies with Jakarta Validation
  7. Handle exceptions globally
  8. Return meaningful HTTP status codes
  9. Use transactions in the service layer
  10. Avoid exposing JPA entities directly
  11. Use pagination for collection endpoints
  12. Keep package structure clean
  13. Use Jakarta imports in modern Spring Boot applications

Complete Minimal Example

Here is the core structure again.

com.example.demo
├── DemoApplication.java
├── user
│   ├── User.java
│   ├── UserRepository.java
│   ├── UserService.java
│   ├── UserController.java
│   ├── CreateUserRequest.java
│   ├── UpdateUserRequest.java
│   └── UserResponse.java
└── exception
    ├── ApiError.java
    ├── ResourceNotFoundException.java
    └── GlobalExceptionHandler.java

That gives you a clean, maintainable foundation for a real REST API.


Summary

To build a REST API in Java using Spring Boot the right way:

  • Use Spring Web for REST controllers.
  • Use Spring Data JPA for persistence.
  • Use Jakarta Validation for request validation.
  • Use DTOs instead of exposing entities.
  • Keep your controller thin.
  • Put business logic and transactions in the service layer.
  • Use a repository for database access.
  • Use global exception handling for consistent error responses.
  • Return correct HTTP status codes such as 200, 201, 204, 400, and 404.
  • Add pagination before your API grows too large.

The clean pattern is:

Controller → Service → Repository → Database

With DTOs at the API boundary and entities at the persistence boundary, your Spring Boot REST API will be easier to maintain, test, and evolve.

How do I containerize and deploy a Java application with Docker?

Containerizing and Deploying a Java Application with Docker

A typical Java Docker workflow is:

  1. Build the Java application
  2. Package it as a JAR
  3. Create a Docker image
  4. Run the container locally
  5. Push the image to a registry
  6. Deploy it to a server or cloud platform

1. Build Your Java Application

If your project uses Maven, build it with:

mvn clean package

This usually creates a JAR file under:

target/

For example:

target/my-application.jar

If this is a Spring Boot application, the generated JAR is often executable and can be run with:

java -jar target/my-application.jar

2. Create a Dockerfile

Create a file named Dockerfile in the root of your project.

Simple Dockerfile

FROM eclipse-temurin:25-jre

WORKDIR /app

COPY target/*.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

What this does

  • FROM eclipse-temurin:25-jre uses a Java 25 runtime image
  • WORKDIR /app sets the working directory inside the container
  • COPY target/*.jar app.jar copies your packaged JAR into the image
  • EXPOSE 8080 documents that the app listens on port 8080
  • ENTRYPOINT starts the Java application

3. Add a .dockerignore File

Create a .dockerignore file to avoid copying unnecessary files into the Docker build context:

.git
.idea
*.iml
target
.DS_Store

If your Dockerfile copies from target/*.jar, you can still ignore most build artifacts carefully, but do not ignore the final JAR unless you use a multi-stage build.

A safer option is:

.git
.idea
*.iml
.DS_Store

4. Build the Docker Image

After running mvn clean package, build the image:

docker build -t my-java-app:1.0 .

You can also tag it as latest:

docker build -t my-java-app:latest .

5. Run the Container Locally

Run the container with:

docker run --name my-java-app -p 8080:8080 my-java-app:1.0

Then open:

http://localhost:8080

If your application uses a different internal port, change the second port value:

docker run -p 8080:9090 my-java-app:1.0

This maps:

host port 8080 -> container port 9090

6. Use Environment Variables

Most real applications need configuration such as database URLs, credentials, profiles, or API keys.

Example:

docker run \
  --name my-java-app \
  -p 8080:8080 \
  -e SPRING_PROFILES_ACTIVE=prod \
  -e DB_URL=jdbc:postgresql://db:5432/appdb \
  my-java-app:1.0

For Spring Boot, common environment variables include:

SPRING_PROFILES_ACTIVE=prod
SERVER_PORT=8080
SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/appdb
SPRING_DATASOURCE_USERNAME=appuser
SPRING_DATASOURCE_PASSWORD=secret

7. Multi-Stage Dockerfile

A better production approach is to build the application inside Docker.

FROM maven:3.9-eclipse-temurin-25 AS build

WORKDIR /app

COPY pom.xml .
COPY src ./src

RUN mvn clean package -DskipTests

FROM eclipse-temurin:25-jre

WORKDIR /app

COPY --from=build /app/target/*.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

This gives you:

  • Reproducible builds
  • No need to install Maven locally
  • A smaller final image because Maven is not included in the runtime image

8. Docker Compose Example

If your Java app needs a database, use Docker Compose.

Create docker-compose.yml:

services:
  app:
    build: .
    container_name: my-java-app
    ports:
      - "8080:8080"
    environment:
      SPRING_PROFILES_ACTIVE: docker
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/appdb
      SPRING_DATASOURCE_USERNAME: appuser
      SPRING_DATASOURCE_PASSWORD: secret
    depends_on:
      - db

  db:
    image: postgres:17
    container_name: app-postgres
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: secret
    ports:
      - "5432:5432"
    volumes:
      - postgres-data:/var/lib/postgresql/data

volumes:
  postgres-data:

Run it with:

docker compose up --build

Stop it with:

docker compose down

Remove volumes too:

docker compose down -v

9. Push the Image to a Registry

Tag the image for Docker Hub:

docker tag my-java-app:1.0 your-dockerhub-username/my-java-app:1.0

Log in:

docker login

Push:

docker push your-dockerhub-username/my-java-app:1.0

For GitHub Container Registry:

docker tag my-java-app:1.0 ghcr.io/your-github-username/my-java-app:1.0
docker push ghcr.io/your-github-username/my-java-app:1.0

10. Deploy on a Server

On your server:

docker pull your-dockerhub-username/my-java-app:1.0

Run it:

docker run -d \
  --name my-java-app \
  --restart unless-stopped \
  -p 80:8080 \
  -e SPRING_PROFILES_ACTIVE=prod \
  your-dockerhub-username/my-java-app:1.0

Now your app is available on:

http://your-server-ip

11. Production-Friendly Dockerfile

For a more production-ready Java container, add memory options and a non-root user.

FROM eclipse-temurin:25-jre

WORKDIR /app

RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser

COPY target/*.jar app.jar

RUN chown appuser:appgroup app.jar

USER appuser

EXPOSE 8080

ENV JAVA_OPTS=""

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

Run with JVM options:

docker run \
  -p 8080:8080 \
  -e JAVA_OPTS="-Xms256m -Xmx512m" \
  my-java-app:1.0

12. Common Commands

List images

docker images

List running containers

docker ps

List all containers

docker ps -a

View logs

docker logs my-java-app

Follow logs:

docker logs -f my-java-app

Stop container

docker stop my-java-app

Remove container

docker rm my-java-app

Remove image

docker rmi my-java-app:1.0

Open shell in container

docker exec -it my-java-app sh

Recommended Minimal Setup

For most Java web applications, start with these two files.

Dockerfile

FROM eclipse-temurin:25-jre

WORKDIR /app

COPY target/*.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

.dockerignore

.git
.idea
*.iml
.DS_Store

Then run:

mvn clean package
docker build -t my-java-app:1.0 .
docker run -p 8080:8080 my-java-app:1.0

That is the basic end-to-end flow for containerizing and deploying a Java application with Docker.