I migrated a Spring Boot project from MongoDB to PostgreSQL. I knew the MongoDB side well. JPA was new to me, and most of the confusion came from carrying MongoDB concepts into a world where they don't apply.
What JPA actually is
JPA stands for Java Persistence API. It is a specification — a set of rules for how Java objects should map to SQL tables. Hibernate is the most common implementation. Spring Data JPA wraps Hibernate to make repositories easier.
The short version: JPA is the contract, Hibernate does the work, Spring Data wires it up.
@Document → @Entity
In MongoDB, @Document marks a class as a document stored in a collection. In JPA, the equivalent is @Entity paired with @Table:
// MongoDB
@Document(collection = "ballots")
public class Ballot { ... }
// JPA
@Entity
@Table(name = "ballots")
public class Ballot { ... }An entity is a Java class where each instance maps to one row in the table. The class defines the columns; each object is a row.
@Id ObjectId → @Id @GeneratedValue Long
MongoDB uses ObjectId — a 12-byte value generated by the driver. PostgreSQL uses auto-incremented integers or sequences. The JPA version:
// MongoDB
@Id
private ObjectId id;
// JPA
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;IDENTITY tells JPA to let the database assign the value on insert, using a serial column or sequence depending on the dialect.
@Indexed(unique=true) → @Column(unique=true)
Straightforward swap:
// MongoDB
@Indexed(unique = true)
private String code;
// JPA
@Column(unique = true)
private String code;Both enforce a uniqueness constraint. The difference is where — MongoDB creates an index on the collection, JPA adds a UNIQUE constraint to the column in the SQL table.
@DBRef → @ManyToOne + @JoinColumn
This one took the most adjustment.
In MongoDB, @DBRef stores a reference to another document — essentially a pointer to a document in a different collection. When you load the parent, MongoDB fetches the referenced document separately.
In SQL, references are expressed as foreign keys. A foreign key is a column in the child table that stores the id of a row in the parent table.
// MongoDB
@DBRef
private Ballot ballot;
// JPA
@ManyToOne
@JoinColumn(name = "ballot_id")
private Ballot ballot;@ManyToOne means: many Votes belong to one Ballot. @JoinColumn(name = "ballot_id") means: store the relationship as a column named ballot_id in the votes table.
| id | ballot_id | choice |
|---|---|---|
| 1 | 42 | Option A |
| 2 | 42 | Option B |
| 3 | 99 | Option C |
The "Many" and "One" in @ManyToOne describe the relationship from the child's perspective: many Votes, one Ballot.
List<String> (embedded) → @ElementCollection + @CollectionTable
This is where SQL's rigidity shows up most clearly.
In MongoDB, embedding a list is trivial:
{
"_id": "abc123",
"preferences": ["Cat Party", "Dog Party", "Neither"]
}The list lives inside the document. There's no limit on length and no separate storage needed.
SQL rows have fixed columns. A row in the ballots table has a defined set of columns — you can't add a variable-length list to one of them. Each item in the list needs to be its own row somewhere.
JPA handles this with @ElementCollection:
@ElementCollection
@CollectionTable(
name = "ballot_preferences",
joinColumns = @JoinColumn(name = "ballot_id")
)
@OrderColumn(name = "preference_order")
@Column(name = "preference")
private List<String> preferences;Four annotations, one field. Here's what each does:
@ElementCollection — tells JPA this field is a collection of simple values (strings, numbers) rather than a relationship to another entity. It needs its own table.
@CollectionTable — names that table and specifies the foreign key column (ballot_id) that links rows back to the parent ballot.
@OrderColumn — adds a column (preference_order) that stores the position of each item. Without this, the list order is not guaranteed when loaded from the database.
@Column — names the column that holds the actual string value.
The resulting table looks like this:
| ballot_id | preference_order | preference |
|---|---|---|
| 42 | 0 | Cat Party |
| 42 | 1 | Dog Party |
| 42 | 2 | Neither |
When you call ballot.getPreferences(), JPA runs the JOIN automatically:
SELECT bp.preference
FROM ballot_preferences bp
WHERE bp.ballot_id = 42
ORDER BY bp.preference_orderYou never write this query. You never think about the JOIN. You just access the field.
MongoRepository → JpaRepository
The repository interface swap is the most mechanical part:
// MongoDB
public interface BallotRepository
extends MongoRepository<Ballot, ObjectId> { ... }
// JPA
public interface BallotRepository
extends JpaRepository<Ballot, Long> { ... }The generic parameters change from ObjectId to Long to match the new ID type. The method signatures you define (findByCode, findByUserId, etc.) stay identical — Spring Data generates the implementation either way.
The tables are created automatically
I expected to write SQL CREATE TABLE statements. I didn't have to.
With spring.jpa.hibernate.ddl-auto=update, Hibernate reads the entity annotations and creates or alters tables to match. With Flyway, you write migration files and Flyway runs them in order — Hibernate handles the schema from the annotations and Flyway handles versioning.
Either way: the annotations are the schema. The two-table setup for @ElementCollection is created automatically, with the correct foreign key and order column, from the four annotations above.
The full mapping
| MongoDB | JPA |
|---|---|
@Document | @Entity + @Table |
@Id ObjectId | @Id @GeneratedValue Long |
@Indexed(unique=true) | @Column(unique=true) |
@DBRef | @ManyToOne + @JoinColumn |
List<String> (embedded) | @ElementCollection + @CollectionTable + @OrderColumn |
MongoRepository<T, ObjectId> | JpaRepository<T, Long> |
The hardest shift wasn't the syntax — it was accepting that a variable-length list can't live in a row. Once that clicked, the rest of the annotations made sense as the SQL workaround for something MongoDB does for free.