Migrating from MongoDB to JPA: the mental model

What I had to unlearn about MongoDB to make sense of JPA — entities, foreign keys, and why a List<String> suddenly needs its own table.

javaspring-bootjpapostgresqlmongodb

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.

idballot_idchoice
142Option A
242Option B
399Option 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_idpreference_orderpreference
420Cat Party
421Dog Party
422Neither

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_order

You 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

MongoDBJPA
@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.

← Back to blog