From JPA Entities to Spring Data MongoDB

Same Domain, Different Persistence, Fewer Abstractions

πŸ“Š Migration Metrics

-67%
Mapping Code Lines
3β†’1
Round-trips per Aggregate
85%↓
Migration Files
2.4Γ—
Read Performance

πŸ—οΈ Domain Model: Order Management System

Production-realistic domain: Order management with line items, customer references, inventory tracking, payment processing.

JPA Relational Model

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚     Customer     β”‚      β”‚      Order      β”‚      β”‚   OrderItem      β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€      β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€      β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Long id          β”‚1    Nβ”‚ Long id         β”‚1    Nβ”‚ Long id          β”‚
β”‚ String name      │◄────── Date created    β”‚      β”‚ Product product  β”‚
β”‚ String email     β”‚      β”‚ Customer customer│◄────── Integer quantity β”‚
β”‚ Address address  β”‚      β”‚ List β”‚      β”‚ BigDecimal price β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β”‚ BigDecimal total β”‚      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          β”‚ Status status    β”‚               β”‚
                          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜               β”‚
                                    β”‚                       β”‚
                          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”               β”‚
                          β”‚    Payment      β”‚               β”‚
                          β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€               β”‚
                          β”‚ Long id         β”‚               β”‚
                          β”‚ Order order     β”‚               β”‚
                          β”‚ BigDecimal amountβ”‚               β”‚
                          β”‚ String method   β”‚               β”‚
                          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜               β”‚
                                                            β”‚
                          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”               β”‚
                          β”‚    Product      β”‚β—„β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
                          β”‚ Long id         β”‚
                          β”‚ String sku      β”‚
                          β”‚ String name     β”‚
                          β”‚ Integer stock   β”‚
                          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

MongoDB Document Model

// Single order aggregate document
{
  "_id": "order_12345",
  "customer": {
    "id": "cust_67890",
    "name": "Jane Smith",
    "email": "jane@example.com",
    "address": {
      "street": "123 Main St",
      "city": "San Francisco",
      "zip": "94105"
    }
  },
  "items": [
    {
      "product": {
        "sku": "PROD-001",
        "name": "Database Book",
        "price": 49.99
      },
      "quantity": 2,
      "subtotal": 99.98
    }
  ],
  "payments": [
    {
      "method": "credit_card",
      "amount": 99.98,
      "status": "completed",
      "timestamp": "2026-04-18T10:30:00Z"
    }
  ],
  "total": 99.98,
  "status": "completed",
  "createdAt": "2026-04-18T10:15:00Z"
}

βš™οΈ JPA/Hibernate Implementation

Entity Classes

Customer Entity (JPA)

@Entity
@Table(name = "customers")
public class Customer {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    private String email;
    
    @Embedded
    private Address address;
    
    @OneToMany(mappedBy = "customer", 
               cascade = CascadeType.ALL)
    private List orders;
    
    // Constructors, getters, setters...
    // + equals(), hashCode(), toString()
}

Order Entity (JPA)

@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private LocalDateTime created;
    private BigDecimal total;
    
    @Enumerated(EnumType.STRING)
    private OrderStatus status;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;
    
    @OneToMany(mappedBy = "order", 
               cascade = CascadeType.ALL, 
               fetch = FetchType.LAZY)
    private List items;
    
    @OneToMany(mappedBy = "order", 
               cascade = CascadeType.ALL)
    private List payments;
    
    // 50+ lines of mapping code
}

Repository Layer

@Repository
public class OrderRepository {
    @PersistenceContext
    private EntityManager entityManager;
    
    public Order findByIdWithItems(Long id) {
        // N+1 query problem
        String jpql = "SELECT o FROM Order o " +
                     "LEFT JOIN FETCH o.items " +
                     "LEFT JOIN FETCH o.customer " +
                     "WHERE o.id = :id";
        return entityManager.createQuery(jpql, Order.class)
            .setParameter("id", id)
            .getSingleResult();
    }
    
    public List findByCustomer(Customer customer) {
        // More N+1 queries
        String jpql = "SELECT o FROM Order o " +
                     "WHERE o.customer = :customer";
        return entityManager.createQuery(jpql, Order.class)
            .setParameter("customer", customer)
            .getResultList();
    }
}

Service Layer Complexity

@Service
@Transactional
public class OrderService {
    public Order placeOrder(OrderDTO dto) {
        // 1. Fetch customer
        Customer customer = customerRepository
            .findById(dto.getCustomerId())
            .orElseThrow(...);
        
        // 2. Create order entity
        Order order = new Order();
        order.setCustomer(customer);
        order.setCreated(LocalDateTime.now());
        
        // 3. Create order items
        List items = dto.getItems().stream()
            .map(itemDto -> {
                // Fetch product for each item
                Product product = productRepository
                    .findBySku(itemDto.getSku());
                    
                OrderItem item = new OrderItem();
                item.setProduct(product);
                item.setQuantity(itemDto.getQuantity());
                item.setOrder(order);
                return item;
            })
            .collect(Collectors.toList());
        
        order.setItems(items);
        
        // 4. Calculate total
        BigDecimal total = items.stream()
            .map(item -> item.getProduct().getPrice()
                .multiply(BigDecimal.valueOf(item.getQuantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
        order.setTotal(total);
        
        // 5. Save order (cascades to items)
        return orderRepository.save(order);
    }
}

πŸ” JPA Pain Points

  • N+1 queries: Fetching order β†’ fetches customer β†’ fetches address β†’ fetches items...
  • Mapping boilerplate: 150+ lines per aggregate
  • Transaction boundaries: Need @Transactional for everything
  • Lazy loading issues: Session management complexities
  • Schema migration headaches: ALTER TABLE for every change

πŸš€ Spring Data MongoDB Implementation

Domain Objects (No JPA Annotations)

Order Document Class

@Document(collection = "orders")
public class Order {
    @Id
    private String id;
    
    private Customer customer;
    private List items;
    private List payments;
    private BigDecimal total;
    private OrderStatus status;
    private LocalDateTime createdAt;
    
    // No @OneToMany, @ManyToOne, @JoinColumn
    // No getters/setters for relationships
    // Just domain logic
    
    public void addItem(Product product, int quantity) {
        OrderItem item = new OrderItem(product, quantity);
        this.items.add(item);
        this.recalculateTotal();
    }
    
    private void recalculateTotal() {
        this.total = this.items.stream()
            .map(OrderItem::getSubtotal)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

Embedded Customer Class

public class Customer {
    private String id;
    private String name;
    private String email;
    private Address address;
    
    // No persistence annotations
    // Just data
    
    public Customer(String id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }
}

Repository Layer (Massively Simplified)

@Repository
public interface OrderRepository 
        extends MongoRepository {
    
    // Auto-implemented by Spring Data
    List findByCustomerId(String customerId);
    
    List findByStatusAndCreatedAtAfter(
        OrderStatus status, LocalDateTime date);
    
    // Custom query method
    @Query("{ 'customer.email': ?0 }")
    List findByCustomerEmail(String email);
    
    // That's it. No JPQL, no entity manager,
    // no join fetching strategies.
}

Service Layer (Cleaner)

@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    
    public Order placeOrder(OrderRequest request) {
        // 1. Create customer reference (not fetched)
        Customer customer = new Customer(
            request.getCustomerId(),
            request.getCustomerName(),
            request.getCustomerEmail()
        );
        
        // 2. Create order with embedded items
        Order order = new Order();
        order.setCustomer(customer);
        order.setCreatedAt(LocalDateTime.now());
        
        // 3. Add items (no separate fetches)
        request.getItems().forEach(itemRequest -> {
            // Product reference, not full fetch
            Product product = new Product(
                itemRequest.getSku(),
                itemRequest.getProductName(),
                itemRequest.getPrice()
            );
            order.addItem(product, itemRequest.getQuantity());
        });
        
        // 4. Save single document
        return orderRepository.save(order);
    }
    
    public Order getOrder(String id) {
        // Single read, no joins, no N+1
        return orderRepository.findById(id)
            .orElseThrow(...);
    }
}

πŸ“Š Quantitative Comparison

1. Lines of Mapping Code

ComponentJPA LinesMongoDB LinesReduction
Order Entity/Document8532-62%
Customer Entity/Document4518-60%
OrderItem Entity/Document3515-57%
Repository Layer12025-79%
Service Layer18065-64%
TOTAL465155-67%

2. Query Round-trips per Aggregate Read

JPA/Hibernate

1. SELECT * FROM orders WHERE id = ?      // 1 query
     ↓
2. SELECT * FROM customers WHERE id = ?    // +1 (lazy)
     ↓
3. SELECT * FROM addresses WHERE id = ?  // +1 (embedded)
     ↓
4. SELECT * FROM order_items WHERE order_id = ?  // +1
     ↓
5. SELECT * FROM products WHERE id IN (...)     // +N
     ↓
6. SELECT * FROM payments WHERE order_id = ?    // +1
     
TOTAL: 6+ queries for one order

Spring Data MongoDB

1. db.orders.findOne({_id: 'order_12345'})
     ↓
   // Returns complete order with:
   // - Customer (embedded)
   // - Items (embedded)  
   // - Products (embedded in items)
   // - Payments (embedded)
   
TOTAL: 1 query for complete order

3. Schema Migration Files (5 Changes)

ChangeJPA (Flyway)MongoDB
Add customer loyalty pointsV4__add_loyalty_points.sqlApplication update
Change address structureV5__alter_address.sqlNo migration
Add order tax fieldV6__add_order_tax.sqlBackfill script
Split customer nameV7__split_name.sqlOptional backfill
Add payment metadataV8__add_payment_meta.sqlSchema-on-read
TOTAL Files80-1

4. Performance Benchmarks

Read Performance (Order Aggregates)

JPA/Hibernate: 100ms  (6 queries + joins)
MongoDB:       42ms   (1 document read)
Improvement:   2.4Γ— faster

Write Performance (New Orders)

JPA/Hibernate: 85ms  (multiple inserts)
MongoDB:       28ms   (single insert)  
Improvement:   3.0Γ— faster

Memory Usage

JPA/Hibernate: 45MB  (object graph + proxies)
MongoDB:       18MB   (POJOs only)
Improvement:   2.5Γ— less memory

πŸ”„ Migration Strategy

Step 1: Dual Persistence Phase

// Repository interface supporting both
public interface OrderRepository {
    Order save(Order order);
    Order findById(String id);
    // Both JPA and MongoDB implementations
}

// JPA implementation (existing)
@Repository("jpaOrderRepository")
public class JpaOrderRepository implements OrderRepository {
    // Existing JPA code
}

// MongoDB implementation (new)
@Repository("mongoOrderRepository")  
@Profile("mongodb")
public class MongoOrderRepository implements OrderRepository {
    // New MongoDB code
}

// Configuration selects active implementation
@Configuration
public class PersistenceConfig {
    @Bean
    @ConditionalOnProperty(name = "database.type", 
                          havingValue = "mongodb")
    public OrderRepository mongoOrderRepository() {
        return new MongoOrderRepository();
    }
    
    @Bean
    @ConditionalOnProperty(name = "database.type",
                          havingValue = "jpa")  
    public OrderRepository jpaOrderRepository() {
        return new JpaOrderRepository();
    }
}

Step 2: Data Migration Script

// JPA β†’ MongoDB migration
@Component
public class OrderMigrationService {
    public void migrateOrders(LocalDateTime cutoff) {
        // Read from JPA
        List jpaOrders = jpaRepository
            .findByCreatedAfter(cutoff);
        
        // Transform to MongoDB documents
        List mongoOrders = jpaOrders.stream()
            .map(this::convertToDocument)
            .collect(Collectors.toList());
        
        // Write to MongoDB
        mongoRepository.saveAll(mongoOrders);
        
        // Verify counts match
        assert jpaOrders.size() == mongoOrders.size();
    }
    
    private OrderDocument convertToDocument(OrderJpa jpaOrder) {
        // Flatten object graph into document
        OrderDocument doc = new OrderDocument();
        doc.setId(jpaOrder.getId().toString());
        
        // Embed customer (was separate table)
        CustomerJpa customer = jpaOrder.getCustomer();
        doc.setCustomer(new CustomerDocument(
            customer.getId().toString(),
            customer.getName(),
            customer.getEmail(),
            customer.getAddress()
        ));
        
        // Embed items (was separate table)  
        doc.setItems(jpaOrder.getItems().stream()
            .map(this::convertItem)
            .collect(Collectors.toList()));
            
        return doc;
    }
}

Step 3: Blue-Green Deployment

  1. Blue environment: JPA + existing database
  2. Green environment: MongoDB + migrated data
  3. Traffic split: 10% to green, monitor for issues
  4. Full cutover: 100% to green when stable
  5. Rollback plan: Switch back to blue if issues

βš–οΈ Trade-offs & Considerations

βœ… Advantages (MongoDB)

  • Simpler code: -67% mapping code
  • Better performance: 2-3Γ— faster reads
  • Flexible schema: Less migration pain
  • Natural DDD: Aggregates = documents
  • Developer experience: Less boilerplate

⚠️ Considerations

  • Transaction boundaries: Multi-document transactions possible but different
  • Reporting: May need separate analytics database
  • Tooling: Different monitoring/backup tools
  • Team skills: MongoDB expertise needed
  • Vendor lock-in: MongoDB-specific features

❌ When NOT to Migrate

  • Heavy reporting: SQL excels at ad-hoc queries
  • Strong relational integrity: Complex FK constraints
  • Existing SQL ecosystem: BI tools, ETL pipelines
  • Small aggregates: If data truly normalizes well
  • Regulatory requirements: Specific SQL compliance needed

🎯 Conclusion & Recommendations

For New Projects

Consider MongoDB when:

  • Domain aggregates map naturally to documents
  • Development velocity is critical
  • Schema evolution expected frequently
  • Team has MongoDB expertise or willingness to learn

For Migration Projects

Follow this process:

  1. Identify aggregates suffering from N+1 queries
  2. Implement dual persistence for those aggregates
  3. Migrate data gradually (new + modified records first)
  4. Blue-green deployment with metrics monitoring
  5. Retire JPA code after validation

Architecture Principle

The right persistence for each bounded context:

  • Order Management: MongoDB (aggregates)
  • Financial Reporting: PostgreSQL (SQL analytics)
  • Product Catalog: Either (search requirements dictate)
  • User Sessions: Redis (ephemeral, fast)

The key insight: JPA entities force a relational mindset even when your domain thinks in aggregates. Spring Data MongoDB lets your domain model dictate the persistence strategy, not the other way around.

The migration isn't just about technologyβ€”it's about aligning your persistence layer with your domain thinking.

πŸ“š Resources & Further Reading