WL
Java Full Stack Developer
Wassim Lagnaoui

Lesson 14: Spring Boot Data Access with JPA and Hibernate part 2

Master advanced JPA concepts: entity relationships, custom queries, performance optimization, and sophisticated data modeling for complex applications.

Introduction

Real-world applications rarely work with isolated data - customers have orders, orders contain products, students enroll in courses, and blog posts have comments. Building on the JPA fundamentals you learned in part 1, this lesson explores the advanced features that make JPA truly powerful for complex applications. You'll learn to model relationships between entities, write custom queries that go beyond simple finders, optimize performance through lazy loading and fetch strategies, and handle large datasets with pagination. These advanced techniques are essential for building scalable applications that can handle real-world data complexity. From one-to-one relationships like users and profiles, to many-to-many associations like students and courses, you'll master the art of expressing complex business relationships in clean, maintainable code. This lesson transforms you from someone who can store and retrieve individual objects into a developer who can architect sophisticated, high-performance data models.


Entity Relationships

Definition

Entity relationships define how different database tables connect to each other, mirroring real-world associations between objects. JPA provides annotations to map these relationships: @OneToOne for exclusive pairings, @OneToMany for parent-child hierarchies, @ManyToOne for the reverse direction, and @ManyToMany for complex associations. Understanding relationships is crucial because most business domains involve interconnected data that needs to be navigated efficiently and consistently.

Analogy

Think of entity relationships like connections in a large family tree or organizational chart. A person has one birth certificate (one-to-one), a parent can have many children (one-to-many), many children belong to one parent (many-to-one), and people can belong to multiple clubs while clubs have multiple members (many-to-many). Just as you can trace relationships in a family tree - finding someone's siblings, parents, or cousins - JPA relationships let you navigate between related data objects. The database acts like a sophisticated filing system that automatically maintains these connections, so when you look up a person, you can instantly access their family members, and when family structures change, all the connections update accordingly.

Examples

Understanding relationship types:

// One-to-One: User ↔ Profile (each user has exactly one profile)
// One-to-Many: Author → Books (one author writes many books)
// Many-to-One: Books → Author (many books belong to one author)
// Many-to-Many: Students ↔ Courses (students take multiple courses, courses have multiple students)

Bidirectional vs Unidirectional:

// Bidirectional: Can navigate both ways (Author → Books, Book → Author)
// Unidirectional: Navigate only one way (Author → Books only)

Owning side vs Non-owning side:

// Owning side: Contains foreign key, controls relationship
// Non-owning side: Uses mappedBy attribute, mirrors the relationship

One-to-One Mapping

Definition

One-to-one relationships connect exactly one entity to exactly one other entity, like a user and their profile, or a person and their passport. The @OneToOne annotation establishes this connection, typically with one side owning the foreign key. This relationship type is perfect for splitting large entities into logical pieces or for optional extensions where not every record needs the additional data.

Analogy

Think of a one-to-one relationship like the connection between a person and their driver's license. Each person can have at most one valid driver's license, and each driver's license belongs to exactly one person. You can't share a license, and you can't have multiple valid licenses at the same time. In a government database, the person's basic information (name, address, birth date) might be stored in one table, while driving-specific information (license number, restrictions, expiration date) is stored in a separate but connected table. This separation keeps the main person record clean while allowing detailed driving information for those who need it, just like how user profiles extend basic user accounts with additional personal details.

Examples

User and Profile relationship:

@Entity
public class User {
    @Id @GeneratedValue
    private Long id;

    @OneToOne(cascade = CascadeType.ALL)
    @JoinColumn(name = "profile_id")
    private Profile profile;
}

Profile side of relationship:

@Entity
public class Profile {
    @Id @GeneratedValue
    private Long id;

    @OneToOne(mappedBy = "profile")
    private User user;  // Non-owning side
}

Creating and linking entities:

User user = new User("alice@example.com");
Profile profile = new Profile("Alice", "Smith", "Software Developer");
user.setProfile(profile);
userRepository.save(user);  // Saves both user and profile

Querying with joins:

@Query("SELECT u FROM User u JOIN FETCH u.profile WHERE u.email = ?1")
Optional<User> findUserWithProfile(String email);

One-to-Many Mapping

Definition

One-to-many relationships connect one entity to multiple related entities, like an author having many books, or a blog post having many comments. The @OneToMany annotation on the parent side pairs with @ManyToOne on the child side. The child entity typically holds the foreign key that points back to the parent. This relationship type is fundamental for modeling hierarchical data and parent-child structures that appear throughout business applications.

Analogy

Imagine a library where each author can write multiple books, but each book has only one primary author. The library's computer system links all of Stephen King's novels to his author record, so when you look up Stephen King, you can see his entire catalog. Conversely, when you pick up any Stephen King book, you can immediately see who wrote it. This is exactly how one-to-many relationships work in databases - the author entity connects to multiple book entities, while each book connects back to its one author. The relationship is like invisible threads connecting related items, maintained automatically by the database so you can easily navigate from parents to children and back again.

Examples

Author with many Books:

@Entity
public class Author {
    @Id @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "author", cascade = CascadeType.ALL)
    private List<Book> books = new ArrayList<>();

Book belongs to one Author:

@Entity
public class Book {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "author_id")
    private Author author;  // Owning side with foreign key
}

Adding books to author:

Author author = new Author("Stephen King");
Book book1 = new Book("The Shining");
Book book2 = new Book("It");

author.addBook(book1);  // Helper method sets both sides
author.addBook(book2);
authorRepository.save(author);

Helper method for bidirectional consistency:

public void addBook(Book book) {
    books.add(book);
    book.setAuthor(this);  // Keep both sides in sync
}

Many-to-Many Mapping

Definition

Many-to-many relationships allow multiple entities on both sides to be associated with each other, like students enrolling in courses, or products belonging to multiple categories. JPA uses a join table (junction table) to store these relationships, with the @ManyToMany annotation managing the complexity. One side must be designated as the owning side using the @JoinTable annotation, while the other side uses mappedBy to indicate it's the non-owning side.

Analogy

Think of many-to-many relationships like the connection between actors and movies. Each actor can appear in multiple movies, and each movie features multiple actors. To track these relationships, Hollywood maintains detailed records (like a join table) that list every actor-movie combination. When you look up Tom Hanks, you see all his movies; when you look up Forrest Gump, you see all its actors. This cross-referencing system lets you navigate relationships in both directions without storing duplicate information. The join table acts like a sophisticated address book that maintains all the connections, automatically updated when actors join new projects or leave old ones, ensuring the relationships stay accurate and current.

Examples

Student and Course relationship:

@Entity
public class Student {
    @Id @GeneratedValue
    private Long id;

    @ManyToMany
    @JoinTable(name = "student_course",
               joinColumns = @JoinColumn(name = "student_id"),
               inverseJoinColumns = @JoinColumn(name = "course_id"))
    private Set<Course> courses = new HashSet<>();

Course side of relationship:

@Entity
public class Course {
    @Id @GeneratedValue
    private Long id;

    @ManyToMany(mappedBy = "courses")
    private Set<Student> students = new HashSet<>();  // Non-owning side
}

Enrolling students in courses:

Student alice = studentRepository.findByName("Alice");
Course java = courseRepository.findByName("Java Programming");
alice.getCourses().add(java);
studentRepository.save(alice);  // Updates join table

Query for courses with student count:

@Query("SELECT c, SIZE(c.students) FROM Course c")
List<Object[]> findCoursesWithEnrollmentCount();

Custom Queries

Definition

Custom queries let you write specific database operations that go beyond the automatic query methods provided by Spring Data JPA. Using the @Query annotation, you can write JPQL (Java Persistence Query Language) or native SQL queries directly in your repository interfaces. Custom queries are essential when you need complex filtering, joins across multiple tables, aggregate functions, or specific performance optimizations that simple method names can't express.

Analogy

Think of custom queries like having a conversation with a librarian who speaks your language fluently. While the library's catalog system can handle simple requests like "find books by author X" or "show me all science fiction books," sometimes you need to ask more complex questions: "Show me all mystery novels published after 2000 by authors who have written at least three books, ordered by publication date." For these sophisticated requests, you need to speak the librarian's specialized language (JPQL or SQL) to get exactly what you want. The librarian understands both casual English (method names) and technical library science terminology (custom queries), using whichever approach best serves your specific information needs.

Examples

JPQL query with parameters:

@Query("SELECT u FROM User u WHERE u.age BETWEEN :minAge AND :maxAge")
List<User> findUsersByAgeRange(@Param("minAge") int min, @Param("maxAge") int max);

Complex join query:

@Query("SELECT DISTINCT a FROM Author a JOIN a.books b WHERE b.category = :category")
List<Author> findAuthorsByBookCategory(@Param("category") String category);

Aggregate query with grouping:

@Query("SELECT a.name, COUNT(b) FROM Author a JOIN a.books b GROUP BY a.name")
List<Object[]> findAuthorBookCounts();

Update query:

@Modifying
@Query("UPDATE User u SET u.lastLogin = CURRENT_TIMESTAMP WHERE u.id = :id")
int updateLastLogin(@Param("id") Long userId);

JPQL Queries

Definition

JPQL (Java Persistence Query Language) is an object-oriented query language that works with entity objects rather than database tables. It looks similar to SQL but operates on your Java entity classes and their relationships. JPQL queries are database-independent and automatically translate to the appropriate SQL dialect for your database. This abstraction lets you write queries that focus on your business objects while JPA handles the database-specific details.

Analogy

JPQL is like having a universal translator that speaks "business language" instead of "database language." When you want to find information, you speak in terms of the business concepts you understand - customers, orders, products - rather than having to think about foreign keys, table joins, and column names. You say "find all customers who have placed orders worth more than $100" instead of "SELECT c.* FROM customers c INNER JOIN orders o ON c.id = o.customer_id WHERE o.total > 100." The translator (JPQL) understands your business-focused request and converts it into whatever technical database language is needed, whether it's MySQL, PostgreSQL, or Oracle, letting you focus on what you want rather than how the database stores it.

Examples

Basic JPQL syntax:

@Query("SELECT u FROM User u WHERE u.email LIKE :pattern")
List<User> findByEmailPattern(@Param("pattern") String pattern);

JPQL with relationships:

@Query("SELECT o FROM Order o JOIN o.customer c WHERE c.name = :customerName")
List<Order> findOrdersByCustomerName(@Param("customerName") String name);

JPQL functions and expressions:

@Query("SELECT u FROM User u WHERE UPPER(u.name) = UPPER(:name) AND u.createdDate > :date")
List<User> findRecentUsersByName(@Param("name") String name, @Param("date") LocalDate date);

JPQL with subqueries:

@Query("SELECT u FROM User u WHERE u.id IN (SELECT o.customer.id FROM Order o WHERE o.total > :amount)")
List<User> findCustomersWithLargeOrders(@Param("amount") BigDecimal amount);

Native Queries

Definition

Native queries use raw SQL specific to your database vendor, giving you access to database-specific features, optimizations, or complex operations that JPQL can't express. While they sacrifice database independence, native queries are powerful for performance-critical operations, complex reporting, or when you need database-specific functions. Use native queries sparingly and only when JPQL isn't sufficient for your needs.

Analogy

Native queries are like speaking directly to a specialist in their technical language instead of using a translator. While the universal translator (JPQL) handles most conversations perfectly, sometimes you need to communicate complex, specialized concepts that require the precision and power of speaking the expert's native language. A database specialist might understand advanced optimization techniques, specific performance tricks, or specialized functions that don't translate well through the universal system. You lose the convenience of automatic translation to other languages, but you gain access to the full depth and sophistication that comes with direct, expert-level communication.

Examples

Native SQL query:

@Query(value = "SELECT * FROM users WHERE created_date > NOW() - INTERVAL 30 DAY",
       nativeQuery = true)
List<User> findRecentUsers();

Native query with complex joins:

@Query(value = """
    SELECT u.name, COUNT(o.id) as order_count, SUM(o.total) as total_spent
    FROM users u LEFT JOIN orders o ON u.id = o.customer_id
    GROUP BY u.id, u.name HAVING COUNT(o.id) > :minOrders
    """, nativeQuery = true)
List<Object[]> findTopCustomers(@Param("minOrders") int minOrders);

Database-specific functions:

@Query(value = "SELECT * FROM products WHERE MATCH(name, description) AGAINST(:search)",
       nativeQuery = true)
List<Product> fullTextSearch(@Param("search") String searchTerm);

Pagination & Sorting

Definition

Pagination breaks large result sets into smaller, manageable chunks, while sorting organizes data in meaningful order. Spring Data JPA provides Pageable and Sort parameters that automatically generate LIMIT/OFFSET clauses and ORDER BY statements. This is essential for applications that handle large datasets, providing better performance and user experience by loading only the data currently needed rather than overwhelming users with thousands of results at once.

Analogy

Pagination and sorting are like organizing a massive library catalog into manageable sections with a logical order. Instead of presenting visitors with a overwhelming dump of all 50,000 books at once, the library creates a user-friendly browsing system: books are sorted alphabetically by title or author, then divided into pages showing 20 books each. Visitors can easily navigate through "Page 1 of 2,500" to find what they want, jumping to specific sections or browsing systematically. The library's computer system handles the complex work of organizing and dividing the collection, while visitors enjoy a smooth, responsive experience that doesn't overwhelm them with information or make them wait for massive lists to load.

Examples

Repository with pagination support:

public interface BookRepository extends JpaRepository<Book, Long> {
    Page<Book> findByAuthorName(String authorName, Pageable pageable);
    Page<Book> findByCategory(String category, Pageable pageable);
}

Using pagination in service:

Pageable pageable = PageRequest.of(0, 10, Sort.by("title"));
Page<Book> bookPage = bookRepository.findByCategory("Fiction", pageable);
List<Book> books = bookPage.getContent();  // Current page items

Sorting with multiple criteria:

Sort sort = Sort.by("category").ascending().and(Sort.by("publicationDate").descending());
Pageable pageable = PageRequest.of(0, 20, sort);

Getting pagination information:

Page<Book> page = bookRepository.findAll(pageable);
int totalPages = page.getTotalPages();
long totalElements = page.getTotalElements();
boolean hasNext = page.hasNext();

Lazy vs Eager Loading

Definition

Loading strategies control when related entities are fetched from the database. Lazy loading (default for collections) fetches related data only when accessed, while eager loading fetches everything immediately. The choice affects performance significantly: lazy loading reduces initial query time but can cause N+1 query problems, while eager loading may fetch unnecessary data but guarantees everything is available. Understanding and controlling fetch strategies is crucial for application performance.

Analogy

Think of lazy vs eager loading like two different approaches to packing for a family vacation. Eager loading is like packing everyone's suitcases completely before leaving the house - you grab all the clothes, toiletries, and accessories for every family member right away. This takes longer upfront, and you might pack things you won't need, but once you're at your destination, everything is immediately available. Lazy loading is like packing just the essentials initially, then going back to get specific items only when someone asks for them. This gets you out the door faster, but you might end up making multiple trips back home when different family members realize they need their favorite shirt or special shoes. The best approach depends on your trip length, packing space, and how often people will need their extra items.

Examples

Default lazy loading for collections:

@Entity
public class Author {
    @OneToMany(mappedBy = "author", fetch = FetchType.LAZY)  // Default
    private List<Book> books;  // Loaded only when accessed
}

Eager loading for immediate access:

@Entity
public class Order {
    @ManyToOne(fetch = FetchType.EAGER)
    private Customer customer;  // Always loaded with order
}

Solving N+1 query problem with JOIN FETCH:

@Query("SELECT a FROM Author a JOIN FETCH a.books WHERE a.id = :id")
Optional<Author> findAuthorWithBooks(@Param("id") Long id);

Entity graph for complex fetching:

@EntityGraph(attributePaths = {"books", "profile"})
@Query("SELECT a FROM Author a WHERE a.name = :name")
List<Author> findAuthorWithBooksAndProfile(@Param("name") String name);

N+1 Query Problem

Definition

The N+1 query problem occurs when your application executes one query to fetch N parent records, then executes N additional queries to fetch related data for each parent record, resulting in N+1 total database queries instead of a more efficient approach. This happens commonly with lazy loading when you iterate over a collection of entities and access their lazy-loaded relationships. The problem severely impacts performance as the number of parent records grows, turning what should be 2 queries into potentially hundreds or thousands of queries.

Analogy

The N+1 query problem is like a inefficient mail delivery system in a large apartment building. Instead of the mail carrier making one trip to collect all mail for the building (1 query) and then delivering everything in a single efficient route through all floors (1 more query), imagine if the carrier made one trip to get the list of residents (1 query), then made a separate round trip back to the post office for each individual resident's mail (N additional queries). So for a 100-unit building, instead of 2 trips total, the carrier makes 101 trips! This becomes exponentially more wasteful as the building gets larger. The residents get their mail eventually, but the system is incredibly inefficient, causing traffic jams, wasted fuel, and frustrated postal workers. A smart mail system would gather all the information needed in just a couple of efficient trips, just like how proper database query optimization should work.

Examples

N+1 Problem Example - The Bad Way:

// This triggers N+1 queries!
List<Author> authors = authorRepository.findAll();  // 1 query to get authors
for (Author author : authors) {
    System.out.println(author.getBooks().size());   // N queries (one per author)
}
// Result: 1 + N queries instead of 2 queries

Solution 1: JOIN FETCH in JPQL:

@Query("SELECT DISTINCT a FROM Author a JOIN FETCH a.books")
List<Author> findAllAuthorsWithBooks();

// Usage - only 1 query total!
List<Author> authors = authorRepository.findAllAuthorsWithBooks();
for (Author author : authors) {
    System.out.println(author.getBooks().size());  // No additional queries
}

Solution 2: @EntityGraph annotation:

@EntityGraph(attributePaths = {"books"})
@Query("SELECT a FROM Author a")
List<Author> findAllWithBooks();

// This eagerly fetches books with authors in 1 query

Solution 3: @BatchSize for collections:

@Entity
public class Author {
    @OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
    @BatchSize(size = 10)  // Fetches up to 10 collections at once
    private List<Book> books;
}
// Reduces N+1 to 1 + (N/10) queries

Transaction Management

Definition

Transaction management in JPA ensures a sequence of operations either complete successfully as a group or have no effect at all, maintaining data integrity. It involves defining boundaries for a transaction, typically at the service layer, and using annotations like @Transactional to manage the commit or rollback of changes. Proper transaction management is crucial for applications with complex data changes, ensuring that all parts of a operation succeed or fail together.

Analogy

Think of transaction management like a safety net for a high-wire performer. The performer (your application) carefully walks the tightrope (executes operations), knowing that if they stumble (an error occurs), the safety net (transaction management) will catch them, preventing a dangerous fall (data corruption). Just as the safety net ensures the performer can complete their act safely, transaction management ensures your application's operations complete successfully, preserving the integrity and consistency of your data.

Examples

Basic transaction management:

@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;

    @Transactional
    public void registerUser(User user) {
        userRepository.save(user);
        // Other operations...
    }
}

Rollback on runtime exception:

@Service
public class OrderService {
    @Autowired
    private OrderRepository orderRepository;

    @Transactional
    public void processOrder(Order order) {
        orderRepository.save(order);
        if (order.getTotal() > 1000) {
            throw new RuntimeException("Order too expensive");  // Triggers rollback
        }
    }
}

Read-only transactions:

@Transactional(readOnly = true)
public List<User> getAllUsers() {
    return userRepository.findAll();
}

Connection Management

Definition

Connection management in JPA involves configuring and optimizing how your application connects to and communicates with the database. This includes managing connection pools, setting connection timeouts, configuring connection validation, and monitoring connection health. A connection pool maintains a set of reusable database connections to avoid the overhead of creating and destroying connections for each database operation. Proper connection management is crucial for application performance, scalability, and resource utilization.

Analogy

Connection management is like managing a fleet of delivery trucks for a busy restaurant. Instead of buying a new truck every time you need to make a delivery (creating a new connection), you maintain a pool of trucks that drivers can use and return when finished (connection pool). You keep enough trucks available to handle peak hours without having too many sitting idle during slow periods. Each truck is regularly inspected and maintained (connection validation), and you monitor their usage to optimize your fleet size. If a truck breaks down, you remove it from service and potentially add a replacement (connection health monitoring). This system ensures fast, reliable deliveries while managing costs and resources efficiently.

Examples

HikariCP connection pool configuration:

# Connection pool settings
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.idle-timeout=300000
spring.datasource.hikari.max-lifetime=1200000
spring.datasource.hikari.connection-timeout=20000

Connection validation:

# Test connections before use
spring.datasource.hikari.connection-test-query=SELECT 1
spring.datasource.hikari.validation-timeout=3000

Multiple datasource configuration:

@Configuration
public class DatabaseConfig {
    @Primary
    @Bean
    @ConfigurationProperties("spring.datasource.primary")
    public DataSource primaryDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    @ConfigurationProperties("spring.datasource.secondary")
    public DataSource secondaryDataSource() {
        return DataSourceBuilder.create().build();
    }
}

Connection pool monitoring:

@Component
public class ConnectionPoolMonitor {
    @Autowired
    private HikariDataSource dataSource;

    @EventListener
    public void logPoolStats() {
        HikariPoolMXBean poolBean = dataSource.getHikariPoolMXBean();
        logger.info("Active connections: {}, Idle connections: {}",
                   poolBean.getActiveConnections(), poolBean.getIdleConnections());
    }
}

Performance Optimization

Definition

JPA performance optimization involves reducing database queries, minimizing data transfer, and using appropriate caching strategies. Key techniques include using projections to fetch only needed fields, enabling query caching, optimizing fetch strategies, using batch operations for bulk updates, and monitoring query execution with SQL logging. Good performance requires understanding when and how your application accesses data, then optimizing those access patterns.

Analogy

Performance optimization is like optimizing your grocery shopping routine. Instead of making separate trips for each item (N+1 queries), you plan ahead and get everything in one efficient trip (JOIN FETCH). You use a shopping list with only what you need (projections) rather than wandering every aisle. You buy frequently used items in bulk (caching) and organize your route through the store for maximum efficiency (query optimization). You might even coordinate with neighbors to share trips for common items (batch operations). The goal is to get everything you need with minimal time and effort, using smart planning and efficient strategies rather than just working harder.

Examples

Using projections for specific fields:

@Query("SELECT u.name, u.email FROM User u WHERE u.active = true")
List<Object[]> findActiveUserSummary();

// Or with interface projections
interface UserSummary {
    String getName();
    String getEmail();
}

Batch operations for performance:

@Modifying
@Query("UPDATE User u SET u.active = false WHERE u.lastLogin < :cutoffDate")
int deactivateInactiveUsers(@Param("cutoffDate") LocalDate cutoffDate);

Hibernate-specific optimizations:

// Enable query cache
spring.jpa.properties.hibernate.cache.use_query_cache=true

// Show SQL for debugging
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

Using @BatchSize for collection loading:

@Entity
public class Author {
    @OneToMany(mappedBy = "author")
    @BatchSize(size = 10)  // Load up to 10 collections at once
    private List<Book> books;
}

Summary

You've now mastered advanced JPA and Hibernate concepts that are essential for building sophisticated, high-performance data-driven applications. From modeling complex entity relationships and writing custom queries to optimizing performance and managing database connections, these skills form the foundation of professional Java development. You've learned to navigate the trade-offs between lazy and eager loading, solve the N+1 query problem, implement proper transaction management, and optimize connection pools for scalability. The custom query techniques with JPQL and native SQL give you the flexibility to handle any data access requirement, while the performance optimization strategies ensure your applications can handle real-world loads efficiently. These advanced JPA features prepare you to architect data layers that are both powerful and maintainable, setting the stage for building secure, production-ready applications in the upcoming lessons on Spring Security and deployment strategies.


Programming Challenge

Challenge: Advanced E-commerce Data Model

Task: Build a comprehensive e-commerce data model with complex relationships, custom queries, and performance optimizations.

Requirements:

  1. Create entity relationships:
  2. CustomerOrder (one-to-many)
  3. OrderOrderItem (one-to-many)
  4. ProductCategory (many-to-many)
  5. CustomerAddress (one-to-many, billing/shipping)
  6. ProductReview (one-to-many)
  7. Implement custom queries:
  8. Find top-selling products with total quantities sold
  9. Find customers who spent more than a certain amount
  10. Find products by category with average rating
  11. Monthly sales report with totals and order counts
  12. Add performance optimizations:
  13. Use appropriate fetch strategies
  14. Implement pagination for product listings
  15. Create projections for summary views
  16. Use JOIN FETCH to avoid N+1 queries
  17. Include advanced features:
  18. Audit fields (created/modified timestamps)
  19. Soft deletion for products and customers
  20. Calculated fields (order totals, average ratings)
  21. Custom validation annotations

Bonus features:

  • Implement product search with full-text search
  • Add inventory tracking with stock levels
  • Create customer loyalty points system
  • Implement product recommendations based on purchase history
  • Add comprehensive test suite for all custom queries

Learning Goals: Practice advanced entity modeling, complex relationship mapping, custom query development, performance optimization techniques, and building a realistic, scalable data architecture for a complex business domain.