Skip to main content

Command Palette

Search for a command to run...

Java Plain vs. Spring Framework: A Guide to JDBC and Transaction Management with Code

Published
6 min read
Java Plain vs. Spring Framework: A Guide to JDBC and Transaction Management with Code
J

Software Engineer

Discover how plain Java and the Spring Framework handle JDBC operations and transaction management differently. This guide compares both approaches with clear code examples to help you choose the right tool for your backend workflow.

Introduction:

When building a backend that talks to a relational database, you can either use plain Java (JDBC) or a framework like Spring. Plain JDBC works fine, but it quickly becomes more complex, difficult to handle, and hard to test as your project grows. Spring simplifies database access (via JdbcTemplate), provides declarative transactions (@Transactional), and reduces boilerplate. Here we compare both approaches and show how Spring improves readability, maintainability, and reliability.

Plain Java JDBC — what it looks like:

A typical plain-JDBC DAO must:

  • obtain a Connection (usually DriverManager.getConnection(...)),

  • create PreparedStatement s,

  • handle ResultSet s,

  • close resources (connection/statement/resultset) reliably,

  • handle SQLExceptions and transaction boundaries manually.

Example: insert a borrow record and mark the book as borrowed (no transaction management):

//This approach lacks declarative transaction support and is harder to test/mock
public class PlainBorrowDao {
    private final String jdbcUrl;
    private final String username;
    private final String password;

    public PlainBorrowDao(String jdbcUrl, String username, String password) {
        this.jdbcUrl = jdbcUrl;
        this.username = username;
        this.password = password;
    }

    public void borrowBook(int userId, int bookId) throws SQLException {
        Connection conn = null;
        PreparedStatement updateBook = null;
        PreparedStatement insertBorrow = null;
//You have to open connection manually and close it.
        try {
            conn = DriverManager.getConnection(jdbcUrl, username, password);
            conn.setAutoCommit(false); 

            updateBook = conn.prepareStatement(
                "UPDATE book SET available = false WHERE id = ? AND available = true");
            updateBook.setInt(1, bookId);
            int updated = updateBook.executeUpdate();
            if (updated == 0) {
                throw new SQLException("Book not available or not found");
            }

            insertBorrow = conn.prepareStatement(
                "INSERT INTO borrow(user_id, book_id, borrowed_on) VALUES (?, ?, now())");
            insertBorrow.setInt(1, userId);
            insertBorrow.setInt(2, bookId);
            insertBorrow.executeUpdate();

            conn.commit();
        } catch (SQLException e) {
            if (conn != null) {
                try { conn.rollback(); } catch (SQLException ex) { /* log */ }
            }
            throw e;
        } finally {
            if (insertBorrow != null) try { insertBorrow.close(); } catch(Exception ignored) {}
            if (updateBook != null) try { updateBook.close(); } catch(Exception ignored) {}
            if (conn != null) try { conn.close(); } catch(Exception ignored) {}
        }
    }
}

Problems with the plain approach

  • Boilerplate: lots of repetitive code for obtaining and closing resources.

  • Error-prone: easy to forget to close the ResultSet or roll back a failed transaction - leading to resource leaks or inconsistent data.

  • Hard to test: needs a database or heavy mocking of DriverManager + Connection API.

  • Cross-cutting concerns: Features like logging, metrics, and retries must be coded explicitly,

    increasing complexity and reducing maintainability.

Spring-managed JDBC — JdbcTemplate & DataSource:

Spring makes JDBC easier by using JdbcTemplate, which handles connection setup and cleanup automatically. It also converts SQL exceptions into readable runtime errors (DataAccessException) and supports clean transaction management using @Transactional and PlatformTransactionManager

Why Spring helps

  • Less boilerplate: JdbcTemplate manages the lifecycle of connections, statements, and

    result sets — no manual cleanup needed.

  • Exception translation: SQL errors are translated into DataAccessException, making them

    easier to catch and handle.

  • Declarative transactions: With @Transactional, Spring automatically handles commit and rollback, reducing error-prone logic.

  • Easier testing: mock JdbcTemplate or inject an in-memory DataSource for tests.

  • Flexible configuration: Swap implementations via dependency injection — use HikariCP for production, and an embedded database for testing.

@Configuration
@EnableTransactionManagement
public class AppConfig {

    @Bean
    public DataSource dataSource() {
        DriverManagerDataSource ds = new DriverManagerDataSource();
        ds.setDriverClassName("org.postgresql.Driver");
        ds.setUrl("jdbc:postgresql://localhost:5432/library_system");
        ds.setUsername("library_system");
        ds.setPassword("library");
        return ds;
    }

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

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

Service using JdbcTemplate and @Transactional

/**
 * Handles the borrowing of a book by a person.
 * This method is transactional — if any step fails, all changes are rolled back.
 */
@Transactional
public void borrowBook(int personId,int isbn) throws IllegalStateException {
    Book book = bookDAO.getBookByIsbn(isbn);
    if (book == null || !book.isAvailable()){
        throw new IllegalStateException("Book is not available");
    }
    int copies = book.getCopies();
    book.setCopies(copies - 1);
    if (book.getCopies() == 0) {
        book.setAvailable(false);
    }
    bookDAO.updateBook(book);


    BorrowRecord record = new BorrowRecord(personId,isbn);
    record.setBorrowDate(LocalDate.now());
    borrowDAO.save(record);
}

Transaction Behavior in Spring — What Actually Happens

When a method is annotated with @Transactional, it is invoked (assuming annotation-driven transactions are enabled and a PlatformTransactionManager is configured), Spring handles the transaction lifecycle automatically:

  • Transaction begins: Spring opens a transaction using the configured DataSource.

  • Connection binding: All JDBC operations (e.g., via JdbcTemplate) use the same connection tied to the transaction.

  • Commit on success: If the method completes without throwing an exception, Spring commits the transaction.

  • Rollback on runtime exception: If an unchecked (runtime) exception is thrown, Spring rolls back the transaction.

  • Checked exceptions: These do not trigger rollback by default, but you can override this using @Transactional(rollbackFor = Exception.class).

Testing: plain-JDBC vs Spring approach:

Plain JDBC testing:

  • Requires embedded databases: You often need an in-memory DB like H2 to simulate real database behavior.

  • Complex mocking: Mocking DriverManager, Connection, and PreparedStatement is complicated and fragile.

  • Heavy and brittle tests: Tests become heavy and break easily when the code changes.

Spring testing example (mocking JdbcTemplate)

// Use Mockito's extension to enable annotation-based mocking

@ExtendWith(MockitoExtension.class)
class BorrowServiceTest {

    @Mock private BorrowDAO borrowDAO;
    @Mock private BookDAO bookDAO;

    @InjectMocks
    private BorrowService borrowService;

  /**
     * Test that borrowing a book decreases its available copies
     * and triggers updates to both Book and BorrowRecord.
  */

    @Test
    void testBorrowBook_decreasesCopies() {
        int libraryId = 1001;
        int isbn = 1234;
        Book book = new Book(libraryId,"Atomic Habits", "Author", isbn, 2, true);

        when(bookDAO.getBookByIsbn(isbn)).thenReturn(book);

        borrowService.borrowBook(libraryId, isbn);

        assertEquals(1, book.getCopies());
        verify(bookDAO).updateBook(book);
        verify(borrowDAO).save(any(BorrowRecord.class));
    }

 /**
     * Test that returning a book increases its available copies
     * and updates both the Book and BorrowRecord.
 */
    @Test
    void testReturnBook_increasesCopies() {
        int libraryId = 1001;
        int isbn = 1234;
        Book book = new Book(libraryId,"Atomic Habits", "Author", isbn, 1, true);
        BorrowRecord record = new BorrowRecord(libraryId, isbn);

        when(borrowDAO.findByPersonAndBook(libraryId, isbn)).thenReturn(record);
        when(bookDAO.getBookByIsbn(isbn)).thenReturn(book);

        borrowService.returnBook(libraryId, isbn);

        assertEquals(2, book.getCopies());
        assertTrue(book.isAvailable());
        verify(bookDAO).updateBook(book);
        verify(borrowDAO).update(record);
    }
}
//Mocks let you test service logic without starting a DB. For integration tests, 
//use an embedded DB (H2) and @SpringJUnitConfig to load the Spring config.

Exception Translation & Declarative Transactions in Spring

  • Simplified error handling: Spring automatically converts SQLException into DataAccessException, a consistent runtime exception hierarchy.

  • No need for try-catch everywhere: You don’t have to manually catch and wrap checked SQL exceptions.

  • Smart exception types: You can catch specific exceptions like DuplicateKeyException to handle special cases.

  • Declarative transactions: With @Transactional, Spring uses AOP (Aspect-Oriented Programming) to manage transactions — no need to write manual commit or rollback code.

Summary — When to Use What

  • Plain JDBC: Best for tiny scripts or quick one-off tools where minimal dependencies matter. Be ready for more boilerplate and manual error handling.

  • Spring Framework: Ideal for production-grade backend apps. JdbcTemplate and @Transactional reduce code, improve testability, and offer clean transaction control.

Conclusion

Plain Java (Core + JDBC) is fine for small, simple applications. But as soon as your project grows — needing transactions, better testability, and cleaner architecture — it quickly becomes cumbersome. That’s where Spring shines. With features like Inversion of Control (IoC), dependency injection, JdbcTemplate, and declarative transaction management via @Transactional

GitHub Repository

Explore the full source code, including both plain JDBC and Spring-based implementations, unit tests, and Maven setup: Library Management System

To help others grasp the build setup behind this project, I’ve written a dedicated blog that explains the pom.xml in detail — including dependencies, plugins, Spring, and SpringBoot integration.

🔗 Read: pom.xml for Java Projects(Library Management System)