top of page
Search

ORM:Why We Need an ORM—and Where JDBC Still Fits (with Java examples)

If you write Java apps that talk to a database, you’ll eventually face a choice: write SQL by hand over JDBC, or use an ORM like JPA/Hibernate. The real world answer isn’t either/or—it’s both. ORMs sit on top of JDBC. You still configure JDBC, your ORM uses it under the hood, and you can drop down to JDBC when you need fine-grained control.

This post explains:

  • What problems ORMs solve

  • When plain JDBC is better

  • How JDBC fits inside an ORM setup

  • Concrete examples (JPA/Hibernate + Spring) and how to mix in JDBC

The Problem Space

What JDBC gives you

  • A low-level API for connecting to the DB, preparing statements, binding parameters, executing SQL, and streaming results.

  • Maximum control and explicitness.

  • But you must hand-roll boilerplate: mapping rows to objects, handling nulls, converting types, managing transactions, and dealing with resource cleanup.

What an ORM adds

  • Object–Relational Mapping: map Java classes to tables, fields to columns, and relationships to foreign keys.

  • Persistent context & identity map: track changes to entities and flush only what changed.

  • Lazy loading, caching, and batching: performance features out of the box.

  • Transaction + unit of work: simpler, declarative transaction boundaries.

  • Portability: write less vendor-specific SQL.

When you might not want full ORM

  • Simple, read-only/reporting queries where manual SQL is clearer.

  • Bulk operations needing raw speed (COPY/LOAD, batched inserts).

  • Very complex hand-tuned SQL (CTEs, window functions, hints) that’s hard to express via ORM criteria.

The Layer Cake: ORM Uses JDBC

Think of it like this:

Your Repository/DAO

        ↓

     ORM (JPA/Hibernate)

        ↓

      JDBC Driver

        ↓

   Database (PostgreSQL/MySQL/…)


You still configure a JDBC URL, username, password, and driver. Hibernate uses that connection to run SQL it generates for you.

Quick Start: Configure JDBC + JPA/Hibernate (Spring Boot)

Maven:

<dependencies>

  <dependency>

    <groupId>org.springframework.boot</groupId>

    <artifactId>spring-boot-starter-data-jpa</artifactId>

  </dependency>

  <dependency>

    <groupId>org.postgresql</groupId>

    <artifactId>postgresql</artifactId>

    <scope>runtime</scope>

  </dependency>

</dependencies>


application.yml:

spring:

  datasource:

    url: jdbc:postgresql://localhost:5432/appdb

    username: appuser

    password: secret

  jpa:

    hibernate:

      ddl-auto: validate   # or update/create-drop in dev only

    show-sql: true         # dev only

    properties:

      hibernate.format_sql: true


Note: The spring.datasource.* block is the

Define an Entity (ORM mapping)

import jakarta.persistence.*;


@Entity

@Table(name = "customers")

public class Customer {

  @Id

  @GeneratedValue(strategy = GenerationType.IDENTITY)

  private Long id;


  @Column(nullable = false, length = 120)

  private String name;


  @Column(unique = true, length = 255)

  private String email;


  // getters/setters/constructors omitted for brevity

}


  • Class ↔ table

  • Fields ↔ columns

  • Constraints via annotations

Write Data Access the ORM Way

Spring Data JPA repository:

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

import java.util.Optional;


public interface CustomerRepository extends JpaRepository<Customer, Long> {

  Optional<Customer> findByEmail(String email);

}


Service with transactions:

import org.springframework.stereotype.Service;

import org.springframework.transaction.annotation.Transactional;


@Service

public class CustomerService {

  private final CustomerRepository repo;


  public CustomerService(CustomerRepository repo) {

    this.repo = repo;

  }


  @Transactional

  public Customer register(String name, String email) {

    repo.findByEmail(email).ifPresent(c -> {

      throw new IllegalArgumentException("Email already taken");

    });

    Customer c = new Customer();

    c.setName(name);

    c.setEmail(email);

    return repo.save(c); // ORM generates INSERT via JDBC

  }

}


  • @Transactional opens a session + transaction; changes flush on commit.

  • You write zero JDBC boilerplate for inserts/selects.

When to Drop Down to JDBC (inside the same app)

Sometimes you need manual SQL. Two common options:

1) Use JdbcTemplate beside JPA

Add dependency (already included in spring-boot-starter-data-jpa via spring-jdbc).

import org.springframework.jdbc.core.JdbcTemplate;

import org.springframework.stereotype.Repository;


@Repository

public class ReportingDao {

  private final JdbcTemplate jdbc;


  public ReportingDao(JdbcTemplate jdbc) {

    this.jdbc = jdbc;

  }


  public int countCustomersByDomain(String domain) {

    String sql = "SELECT COUNT(*) FROM customers WHERE email LIKE ?";

    return jdbc.queryForObject(sql, Integer.class, "%@" + domain);

  }

}


  • Reuses the same JDBC datasource.

  • Great for read-only reports, aggregates, or vendor-specific SQL.

2) Use Hibernate’s native SQL

import jakarta.persistence.EntityManager;

import org.springframework.stereotype.Repository;


@Repository

public class BulkOps {

  private final EntityManager em;


  public BulkOps(EntityManager em) {

    this.em = em;

  }


  public int deactivateDormantCustomers() {

    return em.createNativeQuery("""

        UPDATE customers

        SET active = false

        WHERE last_login_at < NOW() - INTERVAL '365 days'

      """).executeUpdate();

  }

}


  • Stays inside the ORM transaction/context.

  • Useful for bulk DML without loading entities.

Performance & Pitfalls (and how JDBC helps)

  • N+1 query problem: Lazy relationships can cause many SELECTs.

    • Fix with JOIN FETCH, entity graphs, or a targeted JDBC query that returns exactly what you need.

  • Batching: Hibernate can batch inserts/updates; configure hibernate.jdbc.batch_size. For massive data loads, raw JDBC batch may be faster.

  • Streaming: For huge result sets, raw JDBC streaming (fetch size, forward-only cursors) can be more memory efficient.

  • Vendor features: Use JDBC/native SQL for DB-specific functions (window functions, hints) if ORM API feels limiting.

Transactions: Same Boundary, Different Tools

Whether you use ORM or JDBC, keep a single transaction boundary:

@Service

public class MixedService {

  private final CustomerRepository jpaRepo;

  private final ReportingDao reportingDao;


  public MixedService(CustomerRepository jpaRepo, ReportingDao reportingDao) {

    this.jpaRepo = jpaRepo;

    this.reportingDao = reportingDao;

  }


  @Transactional

  public void doWork() {

    // ORM

    Customer c = new Customer();

    c.setName("Asha");

    c.setEmail("asha@example.com");

    jpaRepo.save(c);


    // JDBC

    int count = reportingDao.countCustomersByDomain("example.com");

    if (count > 1000) {

      // …make another ORM change or call a native SQL update

    }

    // All committed together

  }

}


Spring wires both JPA and JDBC to the same DataSource; your unit of work stays consistent.

Testing Strategy

  • Repository tests (ORM):

    • Use @DataJpaTest with an in-memory DB or Testcontainers.

  • JDBC DAO tests:

    • Use plain Spring @JdbcTest or boot slice tests.

  • Seed test data via SQL scripts or programmatically to validate mappings and queries.

Choosing the Right Tool

Use ORM when:

  • You have many tables/entities and relationships.

  • You want to reduce boilerplate and let the framework manage state changes.

  • You value portability and productivity.

Use JDBC (or native SQL) when:

  • You need absolute control over SQL.

  • You’re doing bulk loads or specialized reporting queries.

  • You’re optimizing a hot path where ORM abstractions add overhead.

Best practice in real apps: Start with ORM for 80–90% of cases, and augment with JDBC/native SQL where it’s clearer, faster, or more expressive.

Minimal Non-Spring Example (pure JDBC)

import java.sql.*;


public class RawJdbcExample {

  public static void main(String[] args) throws Exception {

    String url = "jdbc:postgresql://localhost:5432/appdb";

    try (Connection conn = DriverManager.getConnection(url, "appuser", "secret")) {

      conn.setAutoCommit(false);

      try (PreparedStatement ps = conn.prepareStatement(

           "INSERT INTO customers(name, email) VALUES (?, ?)")) {

        ps.setString(1, "Ravi");

        ps.setString(2, "ravi@example.com");

        ps.executeUpdate();

      }

      try (PreparedStatement ps = conn.prepareStatement(

           "SELECT id, name, email FROM customers WHERE email = ?")) {

        ps.setString(1, "ravi@example.com");

        try (ResultSet rs = ps.executeQuery()) {

          while (rs.next()) {

            System.out.println(rs.getLong("id") + " " + rs.getString("name"));

          }

        }

      }

      conn.commit();

    }

  }

}


This is what your ORM is effectively doing—just with entity mapping, caching, and change tracking on top.

Note that:

  • ORMs (JPA/Hibernate) solve mapping, state management, transactions, and performance optimizations—built on top of JDBC.

  • You still configure JDBC; the ORM uses that connection pool.

  • Keep ORM as your default; sprinkle JDBC/native SQL for bulk ops, custom reports, or hand-tuned queries.

  • Share one transaction boundary and one datasource; test both layers properly.



 
 
 

Recent Posts

See All
Cloud : containerized applications?

A containerized application  is a software application that is packaged together with everything it needs to run—its code, runtime,...

 
 
 

Comments


bottom of page