Séance 2 (4h)
Contexte : fil rouge e-commerce
À réaliser :
Order ↔ OrderItem (bidirectionnel)Order → User (unidirectionnel)User ↔ Category (preferences, Many-to-Many)@JsonIgnore / @JsonManagedReference pour éviter les bouclesPoints d'attention :
mappedBy)
// Contraintes métier à implémenter
// - Un Order doit toujours avoir au moins 1 OrderItem
// - Suppression d'un Order → suppression des OrderItems
// - totalAmount calculé automatiquement
// - Gestion du stock produit lors de la création
@Entity
@Table(name = "orders")
class Order(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
val user: User,
@Enumerated(EnumType.STRING)
@Column(nullable = false)
var status: OrderStatus = OrderStatus.PENDING,
@Column(nullable = false)
var totalAmount: BigDecimal = BigDecimal.ZERO,
@Column(nullable = false)
val createdAt: Instant = Instant.now()
) {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
var id: UUID? = null
@OneToMany(
mappedBy = "order",
cascade = [CascadeType.ALL],
orphanRemoval = true,
fetch = FetchType.LAZY
)
@JsonManagedReference
private val _items: MutableList<OrderItem> = mutableListOf()
val items: List<OrderItem>
get() = _items.toList()
fun addItem(item: OrderItem) {
require(_items.isEmpty() || _items.size < 100) {
"Cannot add more than 100 items to an order"
}
_items.add(item)
item.order = this
recalculateTotal()
}
fun removeItem(item: OrderItem) {
_items.remove(item)
item.order = null
recalculateTotal()
}
private fun recalculateTotal() {
totalAmount = _items.sumOf { it.unitPrice * it.quantity.toBigDecimal() }
}
init {
require(user.id != null) { "User must be persisted before creating an order" }
}
}
@Entity
@Table(name = "order_items")
class OrderItem(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
val product: Product,
@Column(nullable = false)
val quantity: Int,
@Column(nullable = false, precision = 10, scale = 2)
val unitPrice: BigDecimal
) {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
var id: UUID? = null
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
@JsonBackReference
var order: Order? = null
init {
require(quantity > 0) { "Quantity must be positive" }
require(unitPrice > BigDecimal.ZERO) { "Unit price must be positive" }
}
}
enum class OrderStatus {
PENDING,
CONFIRMED,
SHIPPED,
DELIVERED,
CANCELLED
}
Tests attendus :
@SpringBootTest
@Transactional
class OrderServiceTest {
@Autowired
private lateinit var orderService: OrderService
@Autowired
private lateinit var userRepository: UserRepository
@Autowired
private lateinit var productRepository: ProductRepository
@Test
fun `should create order with items and calculate total`() {
// Given
val user = userRepository.save(User("John Doe", "john@example.com"))
val product1 = productRepository.save(
Product("iPhone", BigDecimal("999.99"), 10, category)
)
val product2 = productRepository.save(
Product("MacBook", BigDecimal("1999.99"), 5, category)
)
val dto = CreateOrderDto(
userId = user.id!!,
items = listOf(
OrderItemDto(product1.id!!, 2),
OrderItemDto(product2.id!!, 1)
)
)
// When
val order = orderService.createOrder(dto)
// Then
assertThat(order.items).hasSize(2)
assertThat(order.totalAmount).isEqualByComparingTo("3999.97") // 2*999.99 + 1999.99
assertThat(product1.stock).isEqualTo(8) // 10 - 2
assertThat(product2.stock).isEqualTo(4) // 5 - 1
}
@Test
fun `should fail when insufficient stock`() {
// Given
val user = userRepository.save(User("Jane", "jane@example.com"))
val product = productRepository.save(
Product("Limited Item", BigDecimal("50.00"), 2, category)
)
val dto = CreateOrderDto(
userId = user.id!!,
items = listOf(OrderItemDto(product.id!!, 5))
)
// When & Then
assertThatThrownBy { orderService.createOrder(dto) }
.isInstanceOf(InsufficientStockException::class.java)
}
}
Pour recréer à moindre coût une relation (sans charger complètement l'instance depuis le repository)
val user = entityManager.getReference(User::class.java, userId)
Scénario :
// GET /users/{id}/orders
// Retourne les commandes avec leurs items et produits
Mission :
spring.jpa.show-sql=true)À implémenter et comparer :
| Solution | Cas d'usage | Avantages | Inconvénients |
|---|---|---|---|
@EntityGraph | Requêtes standards | Simple | Moins flexible |
JOIN FETCH | Requêtes complexes | Contrôle total | Code JPQL |
@BatchSize | Lazy loading | Transparent | Moins optimal |
| DTO Projection | Lecture seule | Performances max | Plus de code |
Exercices :
/users/{id}/orders avec JOIN FETCH/products (liste)
// ❌ PROBLÈME N+1 : Sans optimisation
interface OrderRepository : JpaRepository<Order, UUID> {
fun findByUserId(userId: UUID): List<Order>
// 1 requête pour les orders
// N requêtes pour charger les items de chaque order
// M requêtes pour charger les produits de chaque item
}
// ✅ SOLUTION 1 : JOIN FETCH
interface OrderRepository : JpaRepository<Order, UUID> {
@Query("""
SELECT DISTINCT o FROM Order o
JOIN FETCH o.items i
JOIN FETCH i.product
WHERE o.user.id = :userId
""")
fun findByUserIdWithItems(userId: UUID): List<Order>
}
// ✅ SOLUTION 2 : @EntityGraph
interface OrderRepository : JpaRepository<Order, UUID> {
@EntityGraph(attributePaths = ["items", "items.product"])
fun findByUserId(userId: UUID): List<Order>
}
// ✅ SOLUTION 3 : DTO Projection
data class OrderSummaryDto(
val id: UUID,
val totalAmount: BigDecimal,
val status: OrderStatus,
val itemCount: Long,
val createdAt: Instant
)
interface OrderRepository : JpaRepository<Order, UUID> {
@Query("""
SELECT new com.ecommerce.order.dto.OrderSummaryDto(
o.id, o.totalAmount, o.status, COUNT(i), o.createdAt
)
FROM Order o
LEFT JOIN o.items i
WHERE o.user.id = :userId
GROUP BY o.id, o.totalAmount, o.status, o.createdAt
""")
fun findOrderSummariesByUserId(userId: UUID): List<OrderSummaryDto>
}
Nouveau besoin métier :
Différencier 3 types de produits :
Tous partagent : id, name, price, stock, category
À explorer (au choix ou comparaison) :
// Option 1 : SINGLE_TABLE (par défaut)
@Entity
@Table(name = "products")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "product_type", discriminatorType = DiscriminatorType.STRING)
abstract class Product(
@Column(nullable = false)
open var name: String,
@Column(nullable = false, precision = 10, scale = 2)
open var price: BigDecimal,
@Column(nullable = false)
open var stock: Int,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id", nullable = false)
open var category: Category
) {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
open var id: UUID? = null
}
@Entity
@DiscriminatorValue("PHYSICAL")
class PhysicalProduct(
name: String,
price: BigDecimal,
stock: Int,
category: Category,
@Column(name = "weight_kg")
var weight: Double,
@Column(name = "dimensions")
var dimensions: String, // "30x20x10"
@Column(name = "shipping_cost", precision = 10, scale = 2)
var shippingCost: BigDecimal
) : Product(name, price, stock, category)
@Entity
@DiscriminatorValue("DIGITAL")
class DigitalProduct(
name: String,
price: BigDecimal,
stock: Int,
category: Category,
@Column(name = "file_size_mb")
var fileSize: Double,
@Column(name = "download_url")
var downloadUrl: String,
@Column(name = "file_format")
var format: String // PDF, MP4, ZIP...
) : Product(name, price, stock, category)
@Entity
@DiscriminatorValue("SERVICE")
class ServiceProduct(
name: String,
price: BigDecimal,
stock: Int,
category: Category,
@Column(name = "duration_hours")
var duration: Int,
@Column(name = "service_date")
var serviceDate: LocalDate?
) : Product(name, price, stock, category)
// Option 2 : JOINED (tables séparées)
@Entity
@Table(name = "products")
@Inheritance(strategy = InheritanceType.JOINED)
abstract class Product(
// ... mêmes champs
)
@Entity
@Table(name = "physical_products")
class PhysicalProduct(
// ... champs spécifiques
) : Product(...)
// Option 3 : TABLE_PER_CLASS (une table complète par classe concrète)
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
abstract class Product(
// ... mêmes champs
)
Exercice comparatif :
// Repository
interface ProductRepository : JpaRepository<Product, UUID> {
// Tous types confondus
override fun findAll(): List<Product>
// Seulement les produits physiques
@Query("SELECT p FROM PhysicalProduct p")
fun findPhysicalProducts(): List<PhysicalProduct>
// Filtrage par type
@Query("SELECT p FROM Product p WHERE TYPE(p) = :type")
fun findByType(type: Class<out Product>): List<Product>
}
// Controller
@RestController
@RequestMapping("/products")
class ProductController(private val repository: ProductRepository) {
@GetMapping
fun getProducts(@RequestParam(required = false) type: String?): List<Product> {
return when (type?.uppercase()) {
"PHYSICAL" -> repository.findByType(PhysicalProduct::class.java)
"DIGITAL" -> repository.findByType(DigitalProduct::class.java)
"SERVICE" -> repository.findByType(ServiceProduct::class.java)
else -> repository.findAll()
}
}
}
Hypersistence Utils est une bibliothèque créée par Vlad Mihalcea qui apporte :
<dependency>
<groupId>io.hypersistence</groupId>
<artifactId>hypersistence-utils-hibernate-63</artifactId>
<version>3.7.0</version>
</dependency>
Objectif : Détecter automatiquement les problèmes de performance sans analyse manuelle des logs
@Configuration
class HypersistenceConfiguration {
@Bean
fun queryStackTraceLogger() = QueryStackTraceLogger()
@EventListener
fun onApplicationReady(event: ApplicationReadyEvent) {
// Active la détection des problèmes N+1
QueryStackTraceLogger.INSTANCE.threshold = 10 // Alerte si > 10 requêtes
}
}
Exercice :
/users/{id}/ordersCas d'usage : Stocker des métadonnées flexibles sur les produits
@Entity
@Table(name = "products")
class Product(
@Column(nullable = false)
var name: String,
@Column(nullable = false, precision = 10, scale = 2)
var price: BigDecimal,
@Column(nullable = false)
var stock: Int,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
var category: Category,
// ✅ Stockage JSON pour attributs dynamiques
@Type(JsonType::class)
@Column(columnDefinition = "json")
var attributes: Map<String, Any> = emptyMap()
) {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
var id: UUID? = null
}
// Utilisation
val product = Product(
name = "iPhone 15 Pro",
price = BigDecimal("1199.99"),
stock = 25,
category = electronicsCategory,
attributes = mapOf(
"color" to "Titanium Blue",
"storage" to "256GB",
"warranty" to "2 years",
"features" to listOf("5G", "Face ID", "A17 Pro")
)
)
{
"id": "550e8400-e29b-41d4-a716-446655440020",
"name": "iPhone 15 Pro",
"price": 1199.99,
"stock": 25,
"categoryId": "550e8400-e29b-41d4-a716-446655440010",
"attributes": {
"color": "Titanium Blue",
"storage": "256GB",
"warranty": "2 years",
"features": ["5G", "Face ID", "A17 Pro"]
}
}
Exercice :
attributes à ProductGET /products/{id}/attributesGET /products?attr.color=BlueTsid (Time-Sorted Identifiers) :
// Avant (UUID)
@Entity
class Review(
@Id
@GeneratedValue(strategy = GenerationType.UUID)
var id: UUID? = null,
// ...
)
// Après (Tsid) - Pour nouvelles entités
@Entity
@Table(name = "reviews")
class Review(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
val product: Product,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
val author: User,
@Column(nullable = false)
val rating: Int, // 1-5
@Column(nullable = false, length = 200)
val title: String,
@Column(nullable = false, length = 2000)
val comment: String,
@Column(nullable = false)
var verified: Boolean = false,
@Column(nullable = false)
var helpfulCount: Int = 0,
@Column(nullable = false)
val createdAt: Instant = Instant.now(),
@Column(nullable = false)
var updatedAt: Instant = Instant.now()
) {
@Id
@TsidGenerator
var id: Long? = null
init {
require(rating in 1..5) { "Rating must be between 1 and 5" }
require(title.isNotBlank()) { "Title cannot be blank" }
require(comment.isNotBlank()) { "Comment cannot be blank" }
require(helpfulCount >= 0) { "Helpful count cannot be negative" }
}
}
Exercice optionnel :
Review avec Tsid
@Configuration
class DataSourceProxyConfiguration {
@Bean
fun dataSourceProxyBeanPostProcessor() = object : DataSourceProxyBeanPostProcessor() {
override fun createDataSourceProxy(dataSource: DataSource): DataSourceProxy {
return DataSourceProxy(dataSource, QueryCountHolder())
}
}
}
Exercice :
assertQueryCount(3) après un appel API
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Transactional
class OrderPerformanceTest {
@Autowired
private lateinit var mockMvc: MockMvc
@Test
fun `should not trigger N+1 queries when fetching user orders`() {
// Given
val userId = createUserWithOrders()
// When
SQLStatementCountValidator.reset()
mockMvc.perform(get("/users/$userId/orders"))
.andExpect(status().isOk)
// Then - Vérifier le nombre de requêtes SQL
assertSelectCount(2) // 1 pour User + 1 pour Orders avec items (JOIN FETCH)
}
}
Mission : Améliorer l'endpoint recommendations
// GET /users/{id}/recommendations
Avec Hypersistence :
Structure JSON recommandée :
@Entity
@Table(name = "users")
class User(
@Column(nullable = false)
var name: String,
@Column(nullable = false, unique = true)
var email: String,
// ✅ Préférences stockées en JSON
@Type(JsonType::class)
@Column(columnDefinition = "json")
var preferences: UserPreferences = UserPreferences()
) {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
var id: UUID? = null
}
data class UserPreferences(
val priceRange: PriceRange = PriceRange(),
val brands: List<String> = emptyList(),
val excludeCategories: List<UUID> = emptyList()
)
data class PriceRange(
val min: BigDecimal = BigDecimal.ZERO,
val max: BigDecimal = BigDecimal("10000")
)
# application.properties - Configuration complète Séance 2 # H2 Database spring.datasource.url=jdbc:h2:file:./data/ecommerce spring.datasource.driverClassName=org.h2.Driver spring.datasource.username=sa spring.datasource.password= # H2 Console spring.h2.console.enabled=true spring.h2.console.path=/h2-console # JPA/Hibernate spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true spring.jpa.properties.hibernate.use_sql_comments=true spring.jpa.properties.hibernate.generate_statistics=true # Logging SQL et statistiques logging.level.org.hibernate.SQL=DEBUG logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE logging.level.org.hibernate.stat=DEBUG logging.level.org.hibernate.orm.jdbc.bind=TRACE # Hypersistence Utils logging.level.io.hypersistence.utils=DEBUG
Must have :
Nice to have :
Conseils :
@ManyToOne LAZY par défautopen) nécessaires pour JPA en Kotlin