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
| Component | JPA Lines | MongoDB Lines | Reduction |
|---|---|---|---|
| Order Entity/Document | 85 | 32 | -62% |
| Customer Entity/Document | 45 | 18 | -60% |
| OrderItem Entity/Document | 35 | 15 | -57% |
| Repository Layer | 120 | 25 | -79% |
| Service Layer | 180 | 65 | -64% |
| TOTAL | 465 | 155 | -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)
| Change | JPA (Flyway) | MongoDB |
|---|---|---|
| Add customer loyalty points | V4__add_loyalty_points.sql | Application update |
| Change address structure | V5__alter_address.sql | No migration |
| Add order tax field | V6__add_order_tax.sql | Backfill script |
| Split customer name | V7__split_name.sql | Optional backfill |
| Add payment metadata | V8__add_payment_meta.sql | Schema-on-read |
| TOTAL Files | 8 | 0-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
- Blue environment: JPA + existing database
- Green environment: MongoDB + migrated data
- Traffic split: 10% to green, monitor for issues
- Full cutover: 100% to green when stable
- 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:
- Identify aggregates suffering from N+1 queries
- Implement dual persistence for those aggregates
- Migrate data gradually (new + modified records first)
- Blue-green deployment with metrics monitoring
- 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
- GitHub Repository - Complete working example
- Spring Data MongoDB Documentation
- Previous: DDD Aggregates as Documents
- Next: Multi-Document ACID Transactions Comparison (Coming soon)