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.

How do I understand the evolution of Java from Java 8 to Java 25?

Understanding Java’s Evolution from Java 8 to Java 25

A good way to understand Java’s evolution from Java 8 to Java 25 is to view it in phases:

  1. Java 8 established modern Java’s functional-programming foundation.
  2. Java 9–11 reshaped the platform and release model.
  3. Java 12–17 modernized the language with records, pattern matching, text blocks, and sealed classes.
  4. Java 18–21 improved concurrency, APIs, and developer ergonomics.
  5. Java 22–25 continue the move toward simpler, safer, more expressive Java.

1. Java 8: The Baseline of Modern Java

Java 8, released in 2014, is often considered the beginning of “modern Java.”

Major features:

  • Lambda expressions
  • Functional interfaces
  • Stream API
  • Default methods in interfaces
  • Optional
  • New Date and Time API
  • CompletableFuture
  • Method references

Example:

List<String> names = List.of("Alice", "Bob", "Charlie");

List<String> filtered = names.stream()
        .filter(name -> name.startsWith("A"))
        .toList();

Java 8 changed Java from being mostly object-oriented and imperative to supporting a much more functional style.


2. Java 9–11: Platform Modernization

Java 9

Java 9 introduced one of the largest structural changes in Java’s history:

  • Java Platform Module System, also called JPMS or Project Jigsaw
  • JShell
  • Collection factory methods
  • Private methods in interfaces
  • Improved Stream API

Example:

List<String> names = List.of("Alice", "Bob");
Set<Integer> numbers = Set.of(1, 2, 3);
Map<String, Integer> scores = Map.of("Alice", 10, "Bob", 20);

The module system allowed applications and libraries to define explicit dependencies:

module com.example.app {
    requires java.sql;
    exports com.example.app.api;
}

Java 10

Java 10 introduced:

  • Local-variable type inference with var
  • Application Class-Data Sharing improvements
  • Garbage collector interface improvements

Example:

var message = "Hello, Java";
var count = 42;

Important: var does not make Java dynamically typed. The type is still determined at compile time.

Java 11

Java 11 was a major LTS release.

Notable features:

  • HTTP Client API standardized
  • String utility methods
  • var in lambda parameters
  • Single-file source-code execution
  • Removal of several Java EE and CORBA modules from the JDK

Example:

var client = java.net.http.HttpClient.newHttpClient();

var request = java.net.http.HttpRequest.newBuilder()
        .uri(java.net.URI.create("https://example.com"))
        .build();

var response = client.send(
        request,
        java.net.http.HttpResponse.BodyHandlers.ofString()
);

3. Java 12–17: Language Expressiveness

This period brought many language features that made Java more concise and expressive.

Switch Expressions

Standardized in Java 14.

String result = switch (status) {
    case 200 -> "OK";
    case 404 -> "Not Found";
    case 500 -> "Server Error";
    default -> "Unknown";
};

This made switch usable as an expression and reduced accidental fall-through bugs.


Text Blocks

Standardized in Java 15.

String json = """
        {
          "name": "Alice",
          "active": true
        }
        """;

Text blocks made multiline strings much easier to write, especially for JSON, SQL, HTML, and test data.


Records

Standardized in Java 16.

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

A record automatically provides:

  • Constructor
  • Accessor methods
  • equals
  • hashCode
  • toString

Records are ideal for immutable data carriers, DTOs, API responses, and value-like objects.


Pattern Matching for instanceof

Standardized in Java 16.

Before:

if (obj instanceof String) {
    String text = (String) obj;
    System.out.println(text.toUpperCase());
}

After:

if (obj instanceof String text) {
    System.out.println(text.toUpperCase());
}

This reduces boilerplate and makes type checks safer.


Sealed Classes

Standardized in Java 17.

public sealed interface Payment permits CardPayment, CashPayment {
}

public final class CardPayment implements Payment {
}

public final class CashPayment implements Payment {
}

Sealed classes let you restrict which classes can extend or implement a type. This is useful for domain modeling, state machines, and exhaustive pattern matching.

Java 17 is also an LTS release and became a major upgrade target for many Java 8 and Java 11 applications.


4. Java 18–21: Runtime, Concurrency, and API Improvements

Java 18

Notable changes:

  • UTF-8 became the default charset
  • Simple web server command-line tool
  • Code snippets in Java API documentation

Java 19–20

These releases continued incubating and previewing major platform improvements, especially around:

  • Virtual threads
  • Structured concurrency
  • Pattern matching
  • Foreign Function & Memory API

Java 21

Java 21 is another major LTS release.

Important features:

  • Virtual threads
  • Sequenced collections
  • Pattern matching for switch
  • Record patterns
  • String templates as preview
  • Unnamed patterns and variables as preview
  • Structured concurrency as preview
  • Scoped values as preview

Virtual Threads

Virtual threads are one of the biggest Java platform changes since lambdas.

They make thread-per-request programming scalable:

try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> {
        System.out.println("Running in a virtual thread");
    });
}

Virtual threads are especially important for server-side applications, web services, database calls, and blocking I/O workloads.

They do not automatically make CPU-heavy code faster, but they greatly improve scalability for many I/O-bound applications.


Sequenced Collections

Java 21 introduced interfaces for collections with a defined encounter order:

  • SequencedCollection
  • SequencedSet
  • SequencedMap

Example:

SequencedCollection<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");

String first = names.getFirst();
String last = names.getLast();

This regularized APIs for getting first and last elements across ordered collections.


5. Java 22–25: Continued Simplification and Modernization

Java 22, 23, 24, and 25 continue the six-month release cadence, building on earlier preview and incubator features.

Important ongoing areas include:

  • More powerful pattern matching
  • Improvements to unnamed variables and patterns
  • Class-file API work
  • Foreign Function & Memory API maturation
  • Stream gatherers
  • Structured concurrency
  • Scoped values
  • Better startup, monitoring, and runtime performance
  • More convenient entry points for beginner-friendly Java programs

The broad direction is clear: Java is becoming more concise, more expressive, better suited for cloud-native systems, and more approachable without abandoning its strong compatibility model.


LTS Releases Matter

From Java 8 to Java 25, the most important versions for many teams are the LTS releases:

Version Why It Matters
Java 8 Functional programming baseline; still widely used historically
Java 11 First major post-Java-8 LTS; HTTP Client; modular-era cleanup
Java 17 Records, sealed classes, pattern matching, strong modernization point
Java 21 Virtual threads, sequenced collections, advanced pattern matching
Java 25 Next LTS line after Java 21

If you are maintaining enterprise applications, understanding the path 8 → 11 → 17 → 21 → 25 is usually more useful than studying every interim version equally.


Big Themes Across Java 8 to Java 25

1. Less Boilerplate

Java has steadily reduced ceremony:

  • Lambdas
  • var
  • Records
  • Pattern matching
  • Switch expressions
  • Text blocks
  • Compact source files and simpler entry points

Example progression:

public record Customer(String name, String email) {
}

Compared to pre-record Java, this can replace dozens of lines of boilerplate.


2. Better Domain Modeling

Modern Java gives you stronger modeling tools:

  • Records for immutable data
  • Sealed classes for restricted hierarchies
  • Pattern matching for safe decomposition
  • Enhanced switch for exhaustive handling

Example:

sealed interface OrderStatus permits Pending, Paid, Cancelled {
}

record Pending() implements OrderStatus {
}

record Paid(String transactionId) implements OrderStatus {
}

record Cancelled(String reason) implements OrderStatus {
}

This style is useful when modeling finite states or domain events.


3. Better Concurrency

Java 8 gave developers:

  • CompletableFuture
  • Parallel streams

Java 21+ adds:

  • Virtual threads
  • Structured concurrency
  • Scoped values

The shift is from complex asynchronous programming toward simpler blocking-style code that scales better.


4. Better APIs

Across these releases, Java improved many everyday APIs:

  • Collections
  • Strings
  • Files
  • HTTP
  • Date/time
  • Random number generation
  • Foreign memory access
  • Cryptography
  • Monitoring and diagnostics

Examples:

boolean blank = "   ".isBlank();
String repeated = "Java ".repeat(3);
List<String> lines = "a\nb\nc".lines().toList();

5. Strong Compatibility, but Not No Change

Java is famous for backward compatibility. Most old Java code still runs on newer JVMs.

However, migration can still involve work:

  • Removed Java EE modules after Java 8
  • Stronger encapsulation of JDK internals
  • Dependency updates
  • Build tool updates
  • Framework compatibility
  • Reflection and proxy behavior changes
  • Container base image updates

This is especially relevant when moving from Java 8 to Java 17, 21, or 25.


Bytecode and Runtime Compatibility

Each Java version produces a corresponding class-file version. A newer JVM usually runs older class files, but an older JVM cannot run newer class files.

For example:

Java Version Class File Version
Java 8 52
Java 11 55
Java 17 61
Java 21 65
Java 25 69

So if code is compiled for Java 25, it generally requires a Java 25-compatible runtime.

To compile for a specific platform level, prefer:

javac --release 21 Example.java

The --release flag is safer than only using -source and -target because it also limits the available standard-library APIs to that Java version.


Practical Migration Path

If you are coming from Java 8, a practical learning and migration path is:

  1. Java 8 → 11
    • Learn modules conceptually, even if you do not modularize.
    • Replace removed Java EE dependencies explicitly.
    • Update build tools and libraries.
  2. Java 11 → 17
    • Adopt records where appropriate.
    • Use text blocks for multiline strings.
    • Use switch expressions.
    • Learn sealed classes and pattern matching.
  3. Java 17 → 21
    • Evaluate virtual threads.
    • Learn sequenced collections.
    • Use pattern matching for switch.
    • Review framework support.
  4. Java 21 → 25
    • Track finalized features from preview/incubator APIs.
    • Revisit concurrency patterns.
    • Update CI, containers, build plugins, and runtime images.

Mental Model

Think of the evolution like this:

Java 8  = functional Java arrives
Java 9  = modular Java begins
Java 11 = post-Java-8 LTS baseline
Java 17 = modern language Java
Java 21 = modern concurrency Java
Java 25 = next-generation LTS consolidation

Or more simply:

Java evolved from a verbose, class-heavy enterprise language into a more concise, expressive, cloud-ready platform while preserving strong backward compatibility.


What to Focus on First

If your goal is practical fluency, focus on these in order:

  1. Streams and lambdas
  2. var
  3. Modern collection factories
  4. Text blocks
  5. Switch expressions
  6. Records
  7. Pattern matching
  8. Sealed classes
  9. Virtual threads
  10. Modern build/runtime compatibility using --release

That path gives you the clearest understanding of how Java changed from Java 8 to Java 25.