ORM:Why We Need an ORM—and Where JDBC Still Fits (with Java examples)
- TechPro
- Sep 1
- 5 min read
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.

Comments