Unveiling the “Magic” of Java Spring Repositories: Writing Our Own Repository Layer
Imagine this: you’re working on a complex Java application. You’ve got databases, tables, rows of data — it’s all SQL on the backend, right? But somehow, your code doesn’t look like SQL at all. It’s Java, with clean, intuitive methods that read like English: findById(), save(), deleteById(). All this is managed seamlessly by Spring Data JPA repositories. But have you ever wondered how the magic works behind the scenes?
Let’s dig deeper into how Java Spring hides away SQL complexities with annotations and abstractions — and how we can recreate some of this ourselves.
Setting the Scene: A Magical Repository
Take a look at the code below for a simple repository in a Spring Data JPA application. Here, we’re using an interface, StudentRepository, that extends JpaRepository. With minimal effort, we’re able to access, store, and manage Student objects in a relational database without writing a single SQL statement.
package com.example.restsimple.repository;
import com.example.restsimple.model.Student;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
public interface StudentRepository extends JpaRepository<Student, String> {
}At first glance, there’s no SQL in sight. Yet, the database only understands SQL — how does it happen? Let’s uncover the mechanics.
Recreating the Magic: Building Our Own Repository Layer
To truly understand the inner workings, let’s try to recreate this repository magic step by step. First, we need annotations to map our classes to database tables. Then, we’ll create an abstraction layer to turn our Java classes into SQL statements. Let’s walk through the components of our custom repository.
Step 1: Custom Annotations
Spring Data JPA uses annotations like @Entity and @Column to link Java classes with database tables and columns. Here, we’re defining our own basic versions of these annotations:
package org.lecture;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Column {
String name() default "";
}The @Column annotation will allow us to specify the database column name associated with a particular field. Next, let’s define an @Entity annotation to specify the database table name:
package org.lecture;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Entity {
String tableName() default "";
}The @Entity annotation will help us map Java classes to specific tables in the database. Now that we have the mappings, let’s move on to building our custom repository.
Step 2: Abstract Repository Layer
Here’s where things get interesting. The AbstractRepository interface is our custom repository layer that uses these annotations and performs basic CRUD (Create, Read, Update, Delete) operations.
Let’s look at some of the critical methods.
1. all(): Retrieve All Records
This method retrieves all rows from the associated table. It constructs a SQL query by using the entity’s table name and mapping the result set to Java objects.
default List<T> all() throws Exception {
List<T> results = new ArrayList<>();
String sql = "SELECT * FROM " + getClassType().getSimpleName();
try (Statement stmt = getConnection().createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
while (rs.next()) {
results.add(fromResultSet(rs, getClassType()));
}
}
return results;
}Using fromResultSet, this method converts each row of the result set into an instance of our Java class T, dynamically mapping fields to column values.
2. create(): Insert New Records
The create() method constructs an INSERT SQL statement by iterating over the fields of the entity object. Each field becomes a column in the database, and the values are set using PreparedStatement.
default void create(T entity) throws Exception {
StringBuilder sql = new StringBuilder("INSERT INTO ");
sql.append(entity.getClass().getSimpleName());
sql.append(" (");
Field[] fields = entity.getClass().getDeclaredFields();
for (int i = 0; i < fields.length; i++) {
fields[i].setAccessible(true);
sql.append(fields[i].getName());
if (i < fields.length - 1) {
sql.append(", ");
}
}
sql.append(") VALUES (");
for (int i = 0; i < fields.length; i++) {
sql.append("?");
if (i < fields.length - 1) {
sql.append(", ");
}
}
sql.append(")");
try (PreparedStatement pstmt = getConnection().prepareStatement(sql.toString())) {
for (int i = 0; i < fields.length; i++) {
pstmt.setObject(i + 1, fields[i].get(entity));
}
pstmt.executeUpdate();
}
}By dynamically constructing the SQL, this method allows us to insert any Java object that’s annotated as an @Entity.
3. update(): Modify Existing Records
The update() method is similar but constructs an UPDATE SQL statement and sets each field, excluding the primary key, which we assume is "id".
4. delete(): Remove Records
The delete() method simply deletes a row with a matching ID. It uses the getTableName() helper method to retrieve the table name specified in the @Entity annotation.
Step 3: Mapping ResultSet to Java Objects
The fromResultSet() method is a crucial part of the magic, turning each row in a SQL result set into a fully populated Java object.
default T fromResultSet(ResultSet rs, Class<T> clazz) throws Exception {
T instance = clazz.newInstance();
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
if (field.isAnnotationPresent(Column.class)) {
Column column = field.getAnnotation(Column.class);
Object value = rs.getObject(column.name());
field.setAccessible(true);
field.set(instance, value);
}
}
return instance;
}This method scans for fields annotated with @Column, retrieves the value from the result set, and sets it on the Java object. It’s what links SQL column names to Java fields, recreating some of the magic Spring provides.
Conclusion: Demystifying the Magic
Spring’s repository layer isn’t just an abstraction — it’s a well-oiled machine that converts high-level Java calls into efficient SQL operations. By creating our custom annotations and repository, we’ve taken a peek behind the scenes and learned how Spring does its magic. This exercise not only helps us understand Spring better but also makes us more conscious of the possibilities (and the challenges) that come with working at this layer of abstraction.



