As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
Let’s talk about how Java applications talk to databases. For a long time, if you said "Java persistence," most developers would think of one thing: JPA, or Java Persistence API, often using Hibernate. It’s like the default highway for data—you define your objects, annotate them, and a framework handles the translation to SQL tables. It works, and it works well for many situations.
But I’ve found that modern applications are asking for different things. They might be spread across multiple services, need to handle thousands of users simultaneously without blocking, or require absolute certainty that a query won’t fail at runtime because someone misspelled a column name. The old highway is still there, but now we have a whole network of smaller, specialized roads. These are techniques that either work alongside traditional JPA or, in some cases, offer a completely different route.
This isn't about saying one way is wrong. It’s about having the right tool for the job. I want to walk you through five specific approaches that have changed how I build data layers. We'll look at how they solve real problems, and I’ll show you what the code actually looks like.
First, let's consider the problem of writing queries. In standard JPA, you often write JPQL, which is a string.
String queryString = "SELECT c FROM Customer c WHERE c.lastName = 'Smith'";
TypedQuery<Customer> query = entityManager.createQuery(queryString, Customer.class);
You run your application, and it’s fine. Then, six months later, someone renames the lastName field in the Customer class to familyName. Your code still compiles. Everything looks good. But when that query runs, it crashes because the string "lastName" no longer matches anything in the database. The error happens at runtime, possibly for a user.
This is where type-safe queries come in. The idea is simple: what if your query was made of Java code that the compiler could check? Libraries like QueryDSL or jOOQ do exactly this. They generate special classes that mirror your entities or your database schema. Your queries are built using methods and fields from these classes.
// Using QueryDSL with JPA entities
QCustomer customer = QCustomer.customer;
JPAQuery<Customer> query = new JPAQuery<>(entityManager);
List<Customer> results = query.from(customer)
.where(customer.familyName.eq("Smith"))
.fetch();
See what happens here? If I try to write customer.lastName, my code won't even compile. The IDE’s autocomplete shows me only the real fields: id, familyName, firstName. The safety is moved from a runtime surprise to a compile-time error, which is where I want to catch my mistakes. jOOQ works similarly but often starts from the database schema itself, generating code that represents your actual tables and columns.
// Using jOOQ (assuming code generation from a 'customers' table)
List<CustomerRecord> records = ctx.select()
.from(CUSTOMERS)
.where(CUSTOMERS.FAMILY_NAME.eq("Smith"))
.fetchInto(CustomerRecord.class);
The mental shift is significant. You're no longer constructing fragile text sentences. You're building queries with compiler-verified Lego blocks. For complex, dynamic queries that are assembled based on user filters, this approach is a lifesaver. It makes the code more predictable and much easier to refactor.
The second major shift I've seen is driven by the need for efficiency under load. Traditional database drivers are blocking. When your thread asks for data, it sits and waits—it’s blocked—until the database responds. For a server handling many requests, this means threads are tied up, just waiting. If you have more requests than threads, your application starts to slow down or reject users.
Reactive programming offers a different model. Instead of waiting, you say, "Go fetch this data, and let me know when you have it." Your thread is free to handle other work in the meantime. To make this work end-to-end, you need a reactive stack all the way down to the database. This is where libraries like Spring Data R2DBC come in.
R2DBC provides a reactive driver for SQL databases. Spring Data wraps it with a repository pattern you might recognize, but the return types are different: Mono for a single result and Flux for multiple results.
public interface ReactiveCustomerRepository
extends ReactiveCrudRepository<Customer, Long> {
Flux<Customer> findByFamilyName(String name);
Mono<Customer> findFirstByEmail(String email);
}
Using it feels like working with a stream of data that you can transform.
public Mono<OrderSummary> getCustomerOrderSummary(Long customerId) {
return reactiveCustomerRepository.findById(customerId)
.flatMap(customer ->
reactiveOrderRepository.findByCustomerId(customerId)
.collectList()
.map(orders -> createSummary(customer, orders))
);
}
The key here is flatMap. It says: "First, get the customer. Then, using that customer’s ID, go find their orders. When both those async operations are done, take the results and map them into an OrderSummary." No threads are blocked while waiting for the database. This model is perfect for applications that need to handle a very high number of concurrent connections with limited resources.
The third technique addresses a fundamental tension in how we use data. The operations that change data—the commands like "place an order" or "update an address"—have very different needs from the operations that read data—the queries like "show me my order history" or "display the dashboard."
In a traditional JPA model, we often use the same object, the Order entity, for both. This forces compromises. The write side needs strong transactional consistency and complex business rules. The read side often needs data from several tables joined and formatted in a specific way for a UI, which can lead to complex joins or multiple queries.
Command Query Responsibility Segregation suggests we separate these two jobs completely. Have a write-optimized model for commands (the "Command" side) and separate, read-optimized models for queries (the "Query" side).
For writing, my domain model is rich with behavior.
@Entity
public class Order {
@Id
private String orderId;
@OneToMany(cascade = ALL)
private List<OrderItem> items;
private OrderStatus status;
public void cancel() {
if (this.status != OrderStatus.PROCESSING) {
throw new IllegalStateException("Cannot cancel order in " + this.status + " state.");
}
this.status = OrderStatus.CANCELLED;
// Potentially trigger domain events here
}
public void addItem(Product product, int quantity) {
// Validate business rules
this.items.add(new OrderItem(product, quantity));
}
}
This Order class is concerned with ensuring the correctness of state changes. When I save it, JPA manages the transaction. For reading, I don't use this object at all. I might have a completely separate OrderSummary class.
public class OrderSummary {
private String orderNumber;
private String customerName;
private BigDecimal totalAmount;
private LocalDateTime orderDate;
private String statusLabel; // A formatted string for the UI
// Flat list of items, pre-formatted
private List<OrderSummaryItem> items;
}
This class has no behavior. It's just data, shaped perfectly for a specific screen. It might be populated by a dedicated SQL query that joins the orders, customers, and order_items tables once and formats the data.
@Repository
public class OrderSummaryReadRepository {
@PersistenceContext
private EntityManager entityManager;
public List<OrderSummary> findSummariesForCustomer(Long customerId) {
String sql = """
SELECT o.id as orderNumber,
c.name as customerName,
o.total as totalAmount,
o.created as orderDate,
o.status,
json_agg(json_build_object('productName', p.name, 'qty', oi.quantity)) as items
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.customer_id = :customerId
GROUP BY o.id, c.name, o.total, o.created, o.status
""";
Query query = entityManager.createNativeQuery(sql, "OrderSummaryMapping");
query.setParameter("customerId", customerId);
return query.getResultList();
}
}
I've defined a SQL ResultSet mapping named "OrderSummaryMapping" to tell JPA how to map the SQL columns to my OrderSummary fields. The write model and the read model are decoupled. They can even live in different databases—the write model in a transactional SQL store, and the read model in a denormalized form in a cache or a read-optimized NoSQL store. The synchronization between them is handled asynchronously, often via events. This separation is powerful for scaling complex domains.
The fourth technique is about cleaning up the glue code. Even in a simple application, you have layers. You have your Order entity for the database layer, but you don't want to send all its internal fields over the network. So you create an OrderDTO (Data Transfer Object) for your API. Converting between the Order and the OrderDTO is boilerplate.
public OrderDTO toDto(Order entity) {
OrderDTO dto = new OrderDTO();
dto.setId(entity.getId());
dto.setCustomerName(entity.getCustomer().getName());
dto.setTotal(entity.calculateTotal());
// ... 15 more lines for various fields and nested lists
return dto;
}
This code is tedious to write, easy to forget to update, and clutters your service classes. Annotation processors like MapStruct solve this by generating this mapping code for you at compile time. You define an interface describing the mapping, and MapStruct creates the implementation.
@Mapper(componentModel = "spring", uses = {AddressMapper.class})
public interface OrderMapper {
OrderMapper INSTANCE = Mappers.getMapper(OrderMapper.class);
@Mapping(source = "customer.fullName", target = "customerName")
@Mapping(source = "lines", target = "items")
@Mapping(target = "formattedTotal",
expression = "java(java.text.NumberFormat.getCurrencyInstance().format(order.getTotal()))")
OrderDTO orderToDto(Order order);
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdDate", expression = "java(java.time.Instant.now())")
Order createOrderRequestToEntity(CreateOrderRequest request);
}
The @Mapper annotation tells MapStruct to create a Spring component. The @Mapping annotations give it instructions: "Take the customer.fullName property from the source and put it in the customerName property of the target." You can even write small Java expressions for custom formatting.
After compilation, MapStruct generates a class called OrderMapperImpl.
@Component
public class OrderMapperImpl implements OrderMapper {
@Autowired
private AddressMapper addressMapper;
@Override
public OrderDTO orderToDto(Order order) {
if (order == null) {
return null;
}
OrderDTO orderDTO = new OrderDTO();
orderDTO.setCustomerName( orderCustomerFullName( order ) );
orderDTO.setItems( orderLineListToOrderItemDtoList( order.getLines() ) );
orderDTO.setFormattedTotal( NumberFormat.getCurrencyInstance().format( order.getTotal() ) );
// ... more generated mapping
return orderDTO;
}
// ... more generated methods
}
It’s clean, fast (because it's plain Java code), and type-safe. If I change a field name in my Order entity, the mapper generation will fail at compile time, pointing me directly to the broken @Mapping annotation. This tool doesn't change your architecture, but it removes a significant source of verbosity and error.
Finally, let's talk about the database itself. How does its structure evolve as your application grows? For years, the process was informal: a developer would write an SQL script, send it to a DBA, and hope it was run correctly in testing and production. It was a manual, error-prone process.
Today, we treat database migrations like application code. We version control them and apply them automatically. Two major tools are Flyway and Liquibase. The principle is the same: you write small, incremental scripts that change your schema from one version to the next. These scripts are checked into git alongside your Java code.
With Flyway, you might have a directory src/main/resources/db/migration with files named in a specific order.
V1__Initial_schema.sql
V2__Add_customer_email_index.sql
V3__Create_order_audit_table.sql
Each file contains the SQL needed to move forward.
-- V2__Add_customer_email_index.sql
CREATE UNIQUE INDEX idx_customer_email ON customers(email);
-- V3__Create_order_audit_table.sql
CREATE TABLE order_audit (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL,
old_status VARCHAR(50),
new_status VARCHAR(50),
changed_by VARCHAR(100),
changed_at TIMESTAMP DEFAULT NOW()
);
ALTER TABLE order_audit ADD CONSTRAINT fk_order_audit_order
FOREIGN KEY (order_id) REFERENCES orders(id);
When your application starts, Flyway checks a special table it creates in your database (usually flyway_schema_history) to see which migrations have been applied. It then runs any new migration files in order. This ensures every environment—from a developer's laptop to the production server—has the exact same schema. It makes deployments repeatable and safe. You can even write "undo" migrations for rollback if you use Liquibase.
In a Spring Boot application, integration is often just a dependency.
# application.yml
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
The first time your app runs, Flyway sets up its history table and applies all the scripts. It’s a simple, reliable way to manage one of the most critical parts of your application: its data structure.
So, where does this leave traditional JPA? It’s not obsolete. It’s a foundational tool that solves a core problem—object-relational mapping—very well. What these five techniques represent is the maturation of the Java ecosystem around that foundation.
We now have specialized tools for specific jobs: type-safe querying for developer safety, reactive access for scalable IO, CQRS for complex domain performance, annotation mappers for clean code, and migration tools for reliable operations. You might use one, two, or all five in a single project. They are less about replacing JPA and more about building a more robust, deliberate, and effective data access layer around it. The evolution is towards choice, precision, and control, letting us build systems that are not just functional, but also resilient and maintainable.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)