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.

How do I organize service, repository and controller layers in Spring?

Typical Spring Layer Organization

A clean Spring application usually separates code into controller, service, repository, and model/entity layers.

com.example.app
├── AppApplication.java
├── controller
│   └── UserController.java
├── service
│   └── UserService.java
├── repository
│   └── UserRepository.java
├── entity
│   └── User.java
└── dto
    ├── CreateUserRequest.java
    └── UserResponse.java

The usual request flow is:

HTTP Request
    ↓
Controller
    ↓
Service
    ↓
Repository
    ↓
Database

1. Controller Layer

The controller handles HTTP requests and responses.

Use:

  • @RestController for JSON APIs
  • @Controller for server-rendered pages such as Thymeleaf/JSP

Controllers should be thin. They should mainly:

  • Accept requests
  • Validate input
  • Call services
  • Return responses

Example:

package com.example.app.controller;

import com.example.app.dto.CreateUserRequest;
import com.example.app.dto.UserResponse;
import com.example.app.service.UserService;
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();
    }

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

2. Service Layer

The service contains business logic.

Use @Service.

Services should:

  • Implement business rules
  • Coordinate multiple repositories
  • Handle transactions
  • Convert between entities and DTOs if your app is small or medium-sized

Example:

package com.example.app.service;

import com.example.app.dto.CreateUserRequest;
import com.example.app.dto.UserResponse;
import com.example.app.entity.User;
import com.example.app.repository.UserRepository;
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(user -> new UserResponse(
                        user.getId(),
                        user.getName(),
                        user.getEmail()
                ))
                .toList();
    }

    @Transactional
    public UserResponse create(CreateUserRequest request) {
        User user = new User();
        user.setName(request.name());
        user.setEmail(request.email());

        User savedUser = userRepository.save(user);

        return new UserResponse(
                savedUser.getId(),
                savedUser.getName(),
                savedUser.getEmail()
        );
    }
}

Use @Transactional on service methods rather than controller methods.


3. Repository Layer

The repository handles database access.

With Spring Data JPA, you usually define an interface that extends JpaRepository.

package com.example.app.repository;

import com.example.app.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {
}

Spring Data JPA automatically provides common methods such as:

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

You can also add query methods:

package com.example.app.repository;

import com.example.app.entity.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);
}

You generally do not need to annotate Spring Data repository interfaces with @Repository; Spring detects them automatically.


4. Entity Layer

The entity represents database tables.

Use Jakarta persistence imports:

package com.example.app.entity;

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;
}

Entities should mostly represent persistent state. Avoid putting HTTP-specific logic in entities.


5. DTO Layer

DTOs separate your API contract from your database model.

Request DTO:

package com.example.app.dto;

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

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

Response DTO:

package com.example.app.dto;

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

Using DTOs helps avoid exposing internal entity fields directly through your API.


Recommended Responsibilities

Layer Annotation Responsibility
Controller @RestController, @Controller HTTP request/response handling
Service @Service Business logic, transactions
Repository Spring Data JpaRepository Database access
Entity @Entity Database table mapping
DTO/Form Records/classes with validation API input/output models

Dependency Direction

Keep dependencies flowing one way:

Controller → Service → Repository → Entity

Avoid this:

Repository → Service
Service → Controller
Entity → Controller

For example:

  • A controller can inject a service.
  • A service can inject a repository.
  • A repository should not know about services or controllers.
  • Entities should not depend on web/controller classes.

Best Practices

  1. Use constructor injection
    @Service
    public class OrderService {
    
        private final OrderRepository orderRepository;
    
        public OrderService(OrderRepository orderRepository) {
            this.orderRepository = orderRepository;
        }
    }
    
  2. Keep controllers thin

    Bad:

    @PostMapping
    public User create(@RequestBody User user) {
        if (user.getEmail() == null) {
            throw new IllegalArgumentException("Email is required");
        }
    
        return userRepository.save(user);
    }
    

    Better:

    @PostMapping
    public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
        return userService.create(request);
    }
    
  3. Put transactions in services
    @Transactional
    public UserResponse create(CreateUserRequest request) {
        // business logic and repository calls
    }
    
  4. Use DTOs for API boundaries

    Do not expose entities directly unless the application is very small or internal.

  5. Keep the main application class in the root package

    com.example.app.AppApplication
    

That way Spring can scan:

com.example.app.controller
com.example.app.service
com.example.app.repository
com.example.app.entity

Feature-Based Alternative

For larger applications, you may prefer organizing by feature instead of technical layer:

com.example.app
├── user
│   ├── UserController.java
│   ├── UserService.java
│   ├── UserRepository.java
│   ├── User.java
│   ├── CreateUserRequest.java
│   └── UserResponse.java
├── order
│   ├── OrderController.java
│   ├── OrderService.java
│   ├── OrderRepository.java
│   └── Order.java
└── AppApplication.java

This is often easier to maintain as the project grows because related files stay together.


Simple Rule of Thumb

Ask this when deciding where code belongs:

  • Is it about HTTP? Put it in the controller.
  • Is it business logic? Put it in the service.
  • Is it database access? Put it in the repository.
  • Is it database structure? Put it in the entity.
  • Is it request/response shape? Put it in a DTO.

For most Spring applications, the clean structure is:

Controller → Service → Repository → Database

with DTOs at the API boundary and entities at the persistence boundary.

How do I manage transactions in Spring?

In Spring, transactions are usually managed with the @Transactional annotation.

A transaction makes sure that a group of database operations is either:

  • all succeed, or
  • all fail and roll back

This is important when one business operation changes multiple records or tables.


1. Enable Transaction Management

If you are using Spring Boot with Spring Data JPA, transaction management is usually configured automatically.

In most Spring Boot applications, you do not need to manually enable it.

If you are using plain Spring configuration, you may need:

import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@Configuration
@EnableTransactionManagement
public class TransactionConfig {
}

With Spring Boot, this is normally unnecessary.


2. Use @Transactional on Service Methods

The most common place to put transactions is the service layer, not the controller or repository.

Example:

package com.example.app.order;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final PaymentRepository paymentRepository;

    public OrderService(
            OrderRepository orderRepository,
            PaymentRepository paymentRepository
    ) {
        this.orderRepository = orderRepository;
        this.paymentRepository = paymentRepository;
    }

    @Transactional
    public void placeOrder(Order order, Payment payment) {
        orderRepository.save(order);
        paymentRepository.save(payment);
    }
}

If paymentRepository.save(payment) fails, Spring rolls back the earlier orderRepository.save(order) operation.


3. Use readOnly = true for Query Methods

For methods that only read data, use:

@Transactional(readOnly = true)

Example:

package com.example.app.order;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class OrderQueryService {

    private final OrderRepository orderRepository;

    public OrderQueryService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional(readOnly = true)
    public List<Order> findAllOrders() {
        return orderRepository.findAll();
    }
}

readOnly = true can help performance and communicates that the method should not modify data.


4. Rollback Behavior

By default, Spring rolls back a transaction for:

  • RuntimeException
  • Error

By default, Spring does not roll back for checked exceptions.

Example:

@Transactional
public void updateOrder() {
    throw new IllegalStateException("Something failed");
}

This transaction rolls back because IllegalStateException is a runtime exception.


5. Roll Back for Checked Exceptions

If you want rollback for a checked exception, specify rollbackFor.

@Transactional(rollbackFor = Exception.class)
public void importOrders() throws Exception {
    // database changes
    throw new Exception("Import failed");
}

You can also target a specific exception:

@Transactional(rollbackFor = OrderImportException.class)
public void importOrders() throws OrderImportException {
    // database changes
    throw new OrderImportException("Import failed");
}

6. Avoid Catching Exceptions Without Rethrowing

This can prevent rollback:

@Transactional
public void placeOrder(Order order) {
    try {
        orderRepository.save(order);
        paymentService.charge(order);
    } catch (Exception ex) {
        // Bad if you swallow the exception
    }
}

If the exception is caught and not rethrown, Spring may think the method completed successfully and commit the transaction.

Prefer:

@Transactional
public void placeOrder(Order order) {
    try {
        orderRepository.save(order);
        paymentService.charge(order);
    } catch (Exception ex) {
        throw new OrderProcessingException("Could not place order", ex);
    }
}

7. Transaction Boundaries Should Match Business Operations

A transaction should usually wrap one complete business action.

Good examples:

@Transactional
public void transferMoney(Long fromAccountId, Long toAccountId, BigDecimal amount) {
    debitAccount(fromAccountId, amount);
    creditAccount(toAccountId, amount);
}
@Transactional
public void registerUser(RegisterUserRequest request) {
    createUser(request);
    createDefaultSettings(request);
    sendWelcomeEvent(request);
}

Avoid making transactions too large, especially if they include slow external calls.


8. Be Careful with External API Calls

Avoid doing slow network calls inside a database transaction when possible.

Less ideal:

@Transactional
public void placeOrder(Order order) {
    orderRepository.save(order);
    paymentGateway.charge(order); // external network call inside transaction
    order.setStatus(OrderStatus.PAID);
}

Better pattern:

@Transactional
public Order createPendingOrder(Order order) {
    order.setStatus(OrderStatus.PENDING_PAYMENT);
    return orderRepository.save(order);
}

@Transactional
public void markOrderPaid(Long orderId) {
    Order order = orderRepository.findById(orderId)
            .orElseThrow();

    order.setStatus(OrderStatus.PAID);
}

Then call the payment gateway between those operations.


9. Common @Transactional Options

@Transactional(
        readOnly = false,
        rollbackFor = Exception.class,
        timeout = 10
)
public void processOrder() {
    // database work
}

Common options:

Option Meaning
readOnly Marks the transaction as read-only
rollbackFor Exceptions that should trigger rollback
noRollbackFor Exceptions that should not trigger rollback
timeout Maximum transaction duration in seconds
propagation How this method joins or creates transactions
isolation How isolated this transaction is from other transactions

10. Propagation Basics

Propagation controls what happens if a transactional method calls another transactional method.

The default is:

Propagation.REQUIRED

That means:

  • join the existing transaction if one exists
  • otherwise create a new transaction

Example:

@Transactional
public void checkout() {
    reserveInventory();
    chargePayment();
}

If reserveInventory() and chargePayment() are also transactional with default propagation, they participate in the same transaction.

Common propagation values:

Propagation Meaning
REQUIRED Use current transaction or create one
REQUIRES_NEW Always start a new transaction
MANDATORY Must already have a transaction
SUPPORTS Use a transaction if one exists
NOT_SUPPORTED Run without a transaction
NEVER Fail if a transaction exists
NESTED Use a nested transaction if supported

Example using a separate transaction for audit logging:

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAuditLog(String message) {
    auditLogRepository.save(new AuditLog(message));
}

11. Isolation Basics

Isolation controls how much one transaction can see changes from another transaction.

Example:

@Transactional(isolation = Isolation.READ_COMMITTED)
public void processPayment() {
    // database work
}

Common isolation levels:

Isolation Meaning
DEFAULT Use the database default
READ_UNCOMMITTED May read uncommitted changes
READ_COMMITTED Only read committed data
REPEATABLE_READ Same row read twice stays consistent
SERIALIZABLE Strongest isolation, lowest concurrency

Most applications use the database by default unless there is a specific consistency problem.


12. Important Limitation: Self-Invocation

Spring transactions are usually applied through proxies.

That means this may not start a transaction as expected:

@Service
public class UserService {

    public void outerMethod() {
        innerMethod();
    }

    @Transactional
    public void innerMethod() {
        // database work
    }
}

Because innerMethod() is called from the same class, the call may bypass Spring’s transactional proxy.

Prefer calling transactional methods from another Spring bean, or put @Transactional on the outer method:

@Service
public class UserService {

    @Transactional
    public void outerMethod() {
        innerMethod();
    }

    public void innerMethod() {
        // database work
    }
}

13. Recommended Structure

A typical Spring application uses transactions like this:

Controller
    ↓
Service  ← @Transactional here
    ↓
Repository
    ↓
Database

Example:

@RestController
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping("/orders")
    public void createOrder(@RequestBody Order order) {
        orderService.createOrder(order);
    }
}
@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional
    public void createOrder(Order order) {
        orderRepository.save(order);
    }
}

Quick Rules

Use these defaults for most Spring applications:

  1. Put @Transactional on service methods.
  2. Use @Transactional(readOnly = true) for query methods.
  3. Use @Transactional for create, update, and delete methods.
  4. Do not swallow exceptions inside transactional methods.
  5. Use rollbackFor if you need rollback for checked exceptions.
  6. Keep transactions short.
  7. Avoid slow external API calls inside transactions.
  8. Be aware that self-invocation can bypass transactional behavior.

For most Spring Boot + Spring Data JPA applications, this is enough:

@Service
public class UserService {

    private final UserRepository userRepository;

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

    @Transactional(readOnly = true)
    public List<User> findUsers() {
        return userRepository.findAll();
    }

    @Transactional
    public User createUser(User user) {
        return userRepository.save(user);
    }
}

How do I use JDBC with Spring?

You can use JDBC with Spring through Spring’s JDBC support, especially JdbcTemplate. It removes much of the repetitive JDBC boilerplate such as opening connections, closing resources, handling PreparedStatement, iterating ResultSet, and translating SQLException into Spring’s DataAccessException hierarchy.

The typical setup is:

  1. Configure a DataSource
  2. Create a JdbcTemplate
  3. Inject it into a repository/DAO class
  4. Use it to run queries and updates

1. Add Spring JDBC and a database driver

For a Maven project, you usually need spring-jdbc and your database driver.

Example for PostgreSQL:

<dependencies>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-jdbc</artifactId>
        <version>6.2.8</version>
    </dependency>

    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <version>42.7.7</version>
    </dependency>
</dependencies>

If you use Spring Boot, you would usually use:

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

plus the database driver.


2. Configure a DataSource

In plain Spring Java configuration, you can define a DataSource bean.

A common choice is HikariCP:

package org.kodejava.spring;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;
import java.time.Duration;

@Configuration
public class DatabaseConfig {

    @Bean
    public DataSource dataSource() {
        HikariConfig config = new HikariConfig();

        config.setJdbcUrl("jdbc:postgresql://localhost:5432/app");
        config.setUsername("postgres");
        config.setPassword("postgres");

        config.setMaximumPoolSize(10);
        config.setMinimumIdle(2);
        config.setConnectionTimeout(Duration.ofSeconds(5).toMillis());
        config.setPoolName("AppHikariPool");

        return new HikariDataSource(config);
    }
}

You would also need the HikariCP dependency if you are not using Spring Boot:

<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    <version>6.3.0</version>
</dependency>

3. Create a JdbcTemplate bean

Spring can create JdbcTemplate from the configured DataSource.

package org.kodejava.spring;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;

import javax.sql.DataSource;

@Configuration
public class JdbcConfig {

    @Bean
    public JdbcTemplate jdbcTemplate(DataSource dataSource) {
        return new JdbcTemplate(dataSource);
    }
}

If you are using Spring Boot, Boot usually autoconfigures JdbcTemplate for you as long as a DataSource exists.


4. Create a model class

For example, suppose you have a users table:

CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(150) NOT NULL
);

You can map rows to a Java object:

package org.kodejava.spring;

public class User {
    private Long id;
    private String name;
    private String email;

    public User() {
    }

    public User(Long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

5. Use JdbcTemplate in a repository

A repository class can receive JdbcTemplate through constructor injection.

package org.kodejava.spring;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public class UserRepository {
    private final JdbcTemplate jdbcTemplate;

    public UserRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public User findById(Long id) {
        String sql = """
                SELECT id, name, email
                FROM users
                WHERE id = ?
                """;

        return jdbcTemplate.queryForObject(
                sql,
                (rs, rowNum) -> new User(
                        rs.getLong("id"),
                        rs.getString("name"),
                        rs.getString("email")
                ),
                id
        );
    }

    public List<User> findAll() {
        String sql = """
                SELECT id, name, email
                FROM users
                ORDER BY id
                """;

        return jdbcTemplate.query(
                sql,
                (rs, rowNum) -> new User(
                        rs.getLong("id"),
                        rs.getString("name"),
                        rs.getString("email")
                )
        );
    }

    public int insert(User user) {
        String sql = """
                INSERT INTO users (id, name, email)
                VALUES (?, ?, ?)
                """;

        return jdbcTemplate.update(
                sql,
                user.getId(),
                user.getName(),
                user.getEmail()
        );
    }

    public int update(User user) {
        String sql = """
                UPDATE users
                SET name = ?, email = ?
                WHERE id = ?
                """;

        return jdbcTemplate.update(
                sql,
                user.getName(),
                user.getEmail(),
                user.getId()
        );
    }

    public int deleteById(Long id) {
        String sql = "DELETE FROM users WHERE id = ?";

        return jdbcTemplate.update(sql, id);
    }
}

6. Enable component scanning

If you are using plain Spring, your configuration class should scan for repositories and services.

package org.kodejava.spring;

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@ComponentScan("org.kodejava.spring")
public class AppConfig {
}

Then you can bootstrap Spring:

package org.kodejava.spring;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class SpringJdbcExample {
    public static void main(String[] args) {
        try (AnnotationConfigApplicationContext context =
                     new AnnotationConfigApplicationContext(AppConfig.class, DatabaseConfig.class, JdbcConfig.class)) {

            UserRepository userRepository = context.getBean(UserRepository.class);

            User user = new User(1L, "Alice", "[email protected]");
            userRepository.insert(user);

            User savedUser = userRepository.findById(1L);
            System.out.println(savedUser.getName());
        }
    }
}

7. Handling query results safely

queryForObject() is convenient, but it throws an exception when no row is found. You can handle that explicitly:

package org.kodejava.spring;

import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;

import java.util.Optional;

public class UserRepository {
    private final JdbcTemplate jdbcTemplate;

    public UserRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public Optional<User> findOptionalById(Long id) {
        String sql = """
                SELECT id, name, email
                FROM users
                WHERE id = ?
                """;

        try {
            User user = jdbcTemplate.queryForObject(
                    sql,
                    (rs, rowNum) -> new User(
                            rs.getLong("id"),
                            rs.getString("name"),
                            rs.getString("email")
                    ),
                    id
            );

            return Optional.ofNullable(user);
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }
}

8. Using NamedParameterJdbcTemplate

For more readable SQL parameters, use NamedParameterJdbcTemplate.

package org.kodejava.spring;

import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
public class NamedUserRepository {
    private final NamedParameterJdbcTemplate jdbcTemplate;

    public NamedUserRepository(NamedParameterJdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public User findById(Long id) {
        String sql = """
                SELECT id, name, email
                FROM users
                WHERE id = :id
                """;

        MapSqlParameterSource params = new MapSqlParameterSource()
                .addValue("id", id);

        return jdbcTemplate.queryForObject(
                sql,
                params,
                (rs, rowNum) -> new User(
                        rs.getLong("id"),
                        rs.getString("name"),
                        rs.getString("email")
                )
        );
    }
}

You can define it as a bean:

package org.kodejava.spring;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;

import javax.sql.DataSource;

@Configuration
public class NamedJdbcConfig {

    @Bean
    public NamedParameterJdbcTemplate namedParameterJdbcTemplate(DataSource dataSource) {
        return new NamedParameterJdbcTemplate(dataSource);
    }
}

9. Transactions

For multiple database operations that should succeed or fail together, use Spring transactions.

Add a transaction manager:

package org.kodejava.spring;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.TransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;

@Configuration
@EnableTransactionManagement
public class TransactionConfig {

    @Bean
    public TransactionManager transactionManager(DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

Then use @Transactional in a service:

package org.kodejava.spring;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class UserService {
    private final UserRepository userRepository;

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

    @Transactional
    public void registerUser(User user) {
        userRepository.insert(user);

        // Other related database operations can go here.
        // If a RuntimeException occurs, the transaction is rolled back.
    }
}

10. Typical Spring Boot configuration

If you are using Spring Boot, the configuration is simpler.

application.properties:

spring.datasource.url=jdbc:postgresql://localhost:5432/app
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.hikari.maximum-pool-size=10

Repository:

package org.kodejava.spring;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
public class UserRepository {
    private final JdbcTemplate jdbcTemplate;

    public UserRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public int countUsers() {
        return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users", Integer.class);
    }
}

Summary

To use JDBC with Spring:

  1. Add spring-jdbc and your database driver.
  2. Configure a DataSource.
  3. Create or autoconfigure JdbcTemplate.
  4. Inject JdbcTemplate into repository classes.
  5. Use query(), queryForObject(), and update() for database operations.
  6. Use @Transactional for operations that need transaction boundaries.

For most applications, prefer JdbcTemplate over raw JDBC because it keeps the code shorter, safer, and easier to test.