Aller au contenu
BackJavaquarkusDatabase

La Taverne Panache : persister les recettes avec Quarkus

Dans la Taverne Panache, recettes et ingrédients finissent enfin rangés dans un vrai grimoire persistant. Découvrez comment utiliser Hibernate Panache avec Quarkus pour mapper des entités, écrire des repositories, gérer les transactions et exposer une API REST plus concise.

Hibernate Panache avec Quarkus

Dans les caves de la taverne, tout finit toujours par se perdre si personne ne tient les registres.

Le tavernier sait encore préparer l'hydromel de l'Elfe, le cuisinier jure qu'il reste du basilic séché quelque part, et le mage de passage réclame une queue de Phénix pour terminer sa potion avant minuit. Mais sans inventaire fiable, sans grimoire consultable, sans lien clair entre les recettes et les ingrédients, la taverne devient vite un joyeux chaos.

Dans une application Java classique, cette partie du royaume s'appelle la persistance. Il faut représenter les données, les stocker, les retrouver, écrire des requêtes, gérer les transactions et éviter que les détails techniques n'envahissent toute l'application. Avec Quarkus et Hibernate Panache, cette mécanique devient plus concise : Panache ajoute une couche pratique au-dessus de JPA/Hibernate pour écrire moins de code répétitif et se concentrer davantage sur le modèle.

Dans ce tutoriel, la taverne conserve deux types de trésors : des ingrédients dans la réserve, et des recettes dans le grimoire.

Installer Hibernate Panache dans Quarkus

Avant de ranger les tonneaux, il faut équiper la cave. Dans ce module, la dépendance principale est quarkus-hibernate-orm-panache, accompagnée d'une base H2 en mémoire pour garder le tutoriel simple à lancer.

<dependencies>
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-hibernate-orm-panache</artifactId>
    </dependency>
    <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-jdbc-h2</artifactId>
    </dependency>
</dependencies>

La première dépendance apporte Hibernate ORM avec les facilités Panache. La seconde fournit une base H2, idéale pour un tutoriel : la taverne peut ouvrir, créer ses tables, charger quelques ingrédients, puis disparaître proprement quand l'application s'arrête.

La taverne ouvre alors son grimoire sur le port configuré dans l'application.

Préparer la cave : configurer la base H2

Dans une auberge de production, les réserves seraient probablement stockées dans PostgreSQL, MariaDB ou une autre base persistante. Ici, la cave est temporaire : elle existe en mémoire, juste le temps de la démonstration.

# Configuration de la base de données H2 (en mémoire pour le tuto)
quarkus.datasource.db-kind=h2
quarkus.datasource.jdbc.url=jdbc:h2:mem:tavern_db
quarkus.hibernate-orm.schema-management.strategy=drop-and-create

La stratégie drop-and-create demande à Hibernate de reconstruire le schéma au démarrage. Pour un tutoriel, c'est confortable : chaque nouvelle ouverture de la taverne remet les étagères dans un état connu. La console H2 est également activée, ce qui permet d'inspecter la cave pendant que l'application tourne, comme un tavernier qui descend vérifier ses stocks entre deux services.

Les ingrédients : le pattern Active Record

Dans Panache, il existe deux manières courantes d'organiser l'accès aux données. La première est le pattern Active Record : l'entité porte directement des méthodes de persistance héritées de PanacheEntity.
C'est une approche très concise, particulièrement adaptée aux modèles simples.

Dans notre taverne, un ingrédient est une entrée élémentaire de la réserve : un nom, une unité de mesure, un coût.
Chaque pot de basilic, chaque fiole d'eau elfique a sa fiche. Et avec Active Record, cette fiche sait d'elle-même où elle se range.

@Entity
public class Ingredient extends PanacheEntity {

    public String name;
    public String unit;
    public double cost;

    public Ingredient() {
    }

    public Ingredient(String name, String unit, double cost) {
        this.name = name;
        this.unit = unit;
        this.cost = cost;
    }
}

En héritant de PanacheEntity, Ingredient reçoit automatiquement un champ id de type Long, ainsi que des méthodes pratiques comme listAll, findAll, find, persist ou delete. L'ingrédient ne se contente plus d'être une ligne dans un tableau : il sait comment être retrouvé dans la réserve.

Cette approche donne un code très direct dans le service :

public List<IngredientDTO> consulterLaReserve() {
    return Ingredient.<Ingredient>listAll().stream()
            .map(this::mapToIngredientDTO)
            .collect(Collectors.toList());
}

public List<IngredientDTO> feuilleterLaReserve(int page, int size) {
    return Ingredient.<Ingredient>findAll().page(page, size).list().stream()
            .map(this::mapToIngredientDTO)
            .collect(Collectors.toList());
}

Le tavernier peut consulter toute la réserve avec listAll, ou feuilleter les étagères page par page avec findAll().page(page, size).list(). Panache retire une partie du cérémonial habituel de JPA, sans retirer Hibernate de l'équation.

Les recettes : le pattern Repository

La seconde approche consiste à séparer l'entité de sa logique d'accès aux données. C'est le pattern Repository.
Il devient intéressant quand le modèle est plus riche, quand les requêtes personnalisées se multiplient, ou quand on veut garder les entités plus proches de simples objets métier.

Une recette, c'est plus qu'une fiche d'ingrédient. C'est une page entière du grimoire : un titre, une description, et la liste des ingrédients à réunir avant d'allumer le feu. Cette richesse mérite un gardien dédié.

@Entity
public class Recipe {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    public Long id;

    public String title;
    public String description;

    @ManyToMany
    @JoinTable(
        name = "RECIPE_INGREDIENT",
        joinColumns = @JoinColumn(name = "recipe_id"),
        inverseJoinColumns = @JoinColumn(name = "ingredient_id")
    )
    public List<Ingredient> ingredients = new ArrayList<>();

    public Recipe() {
    }

    public Recipe(String title, String description) {
        this.title = title;
        this.description = description;
    }
}

La relation @ManyToMany représente le lien naturel entre le grimoire et la réserve : une recette utilise plusieurs ingrédients, et un ingrédient peut servir à plusieurs recettes.
La table de jointure RECIPE_INGREDIENT devient la page où le tavernier note que l'hydromel utilise du malt de Nain et de l'eau de source elfique.

L'accès aux recettes est ensuite confié à un repository dédié, qui joue le rôle d'archiviste du grimoire.

@ApplicationScoped
public class RecipeRepository implements PanacheRepository<Recipe> {

    /**
     * Exemple de recherche personnalisée
     */
    public List<Recipe> findByTitle(String title) {
        return find("from Recipe r left join fetch r.ingredients where r.title like ?1", "%" + title + "%").list();
    }

    /**
     * Récupère toutes les recettes avec leurs ingrédients en une seule requête (évite le N+1)
     */
    public List<Recipe> listAllWithIngredients() {
        return find("from Recipe r left join fetch r.ingredients").list();
    }
}

Le repository hérite des méthodes Panache grâce à PanacheRepository<Recipe>, mais il ajoute aussi des requêtes adaptées au grimoire. findByTitle cherche une recette par morceau de titre, comme feuilleter le grimoire à la recherche d'un mot clé. listAllWithIngredients récupère les recettes avec leurs ingrédients en une seule requête, grâce au left join fetch.

Ce détail est important : sans chargement adapté, ouvrir le grimoire pourrait déclencher une course entre la salle et la cave pour chaque recette affichée. Dans une petite taverne avec trois clients, personne ne s'en aperçoit. Dans une grande salle pleine d'aventuriers réclamant leur menu en même temps, le problème N+1 finit par faire attendre tout le monde, et un aventurier qui attend, c'est un aventurier qui renverse la table.

Un repository aussi pour la réserve

Même si Ingredient utilise PanacheEntity, le module montre aussi un repository pour les ingrédients. Cela permet de combiner les deux styles quand le besoin apparaît.

@ApplicationScoped
public class IngredientRepository implements PanacheRepository<Ingredient> {
    
    public Ingredient findByName(String name) {
        return find("name", name).firstResult();
    }
}

Panache n'impose donc pas un dogme unique. Une entité peut profiter d'Active Record pour les opérations simples, tandis qu'un repository peut centraliser certaines recherches nommées. Dans notre taverne, l'important est moins de choisir un camp que de garder un code lisible pour celui qui devra rouvrir le grimoire plus tard.

Les DTO : servir la salle sans exposer la cave

Le tavernier ne laisse pas les clients descendre eux-mêmes dans la réserve. Quand un aventurier commande l'hydromel de l'Elfe, on lui présente une coupe propre avec les informations utiles, le nom, le prix, la contenance, pas les détails de la cave, les numéros de lot ou l'état des tonneaux. Dans le code, ce rôle est tenu par les DTO.

@Schema(description = "Représentation d'un ingrédient magique")
public record IngredientDTO(
    @Schema(description = "Identifiant de l'ingrédient", examples = "1")
    Long id,
    @Schema(description = "Nom de l'ingrédient", examples = "Queue de Phénix")
    String name,
    @Schema(description = "Unité de mesure", examples = "unité")
    String unit,
    @Schema(description = "Coût en pièces d'or", examples = "150.0")
    double cost
) {}

La recette suit la même logique, mais inclut la liste des ingrédients nécessaires, comme une carte du menu qui détaille la composition du plat pour les clients les plus curieux.

@Schema(description = "Représentation d'une recette du grimoire")
public record RecipeDTO(
    @Schema(description = "Identifiant de la recette", examples = "1")
    Long id,
    @Schema(description = "Titre de la recette", examples = "Hydromel de l'Elfe")
    String title,
    @Schema(description = "Description de la recette", examples = "Une boisson rafraîchissante.")
    String description,
    @Schema(description = "Liste des ingrédients nécessaires")
    List<IngredientDTO> ingredients
) {}

Les annotations @Schema jouent le rôle de légendes sur la carte du menu : elles n'ajoutent pas d'ingrédient dans la coupe, mais elles rendent le contrat de l'API lisible pour quiconque consulte la documentation OpenAPI, qu'il s'agisse d'un humain ou d'un outil.

Le service : les cuisines de la taverne

Le comptoir reçoit les commandes, mais il ne décide pas seul de ce qui peut entrer dans le grimoire.
Les règles du chef, les transactions entre la réserve et la salle, et les transformations entre ce qu'on stocke et ce qu'on sert restent dans les cuisines, c'est-à-dire dans le service.

@ApplicationScoped
public class GrimoireService {

    @Inject
    RecipeRepository recipeRepository;

    @Inject
    IngredientRepository ingredientRepository;

    public List<IngredientDTO> consulterLaReserve() {
        return Ingredient.<Ingredient>listAll().stream()
                .map(this::mapToIngredientDTO)
                .collect(Collectors.toList());
    }

    public List<IngredientDTO> feuilleterLaReserve(int page, int size) {
        return Ingredient.<Ingredient>findAll().page(page, size).list().stream()
                .map(this::mapToIngredientDTO)
                .collect(Collectors.toList());
    }

    @Transactional
    public IngredientDTO acquerirNouvelIngredient(IngredientDTO dto) {
        validerIngredient(dto);
        
        Ingredient ingredient = new Ingredient(dto.name(), dto.unit(), dto.cost());
        ingredientRepository.persist(ingredient);
        
        return mapToIngredientDTO(ingredient);
    }
}

Les méthodes de lecture consultent la réserve sans rien modifier. Les méthodes d'écriture sont annotées avec @Transactional : inscrire un nouvel ingrédient dans le registre doit se faire en une seule opération cohérente.
Même dans une taverne médiévale, on évite de noter un achat à moitié, un tonneau à moitié inventorié, c'est un tonneau qu'on perd.

Le service porte aussi les validations, comme un chef qui refuse d'envoyer une assiette incomplète en salle :

private void validerIngredient(IngredientDTO dto) {
    if (dto.name() == null || dto.name().isBlank()) {
        throw new IllegalArgumentException("L'ingrédient doit avoir un nom.");
    }
    if (dto.cost() < 0) {
        throw new IllegalArgumentException("Le coût d'un ingrédient ne peut être négatif, même pour de la magie noire.");
    }
}

private void validerRecette(RecipeDTO dto) {
    if (dto.title() == null || dto.title().isBlank()) {
        throw new IllegalArgumentException("La recette doit avoir un titre.");
    }
}

Ces règles sont simples, mais elles montrent le bon emplacement : ni gravées dans la pierre de l'entité, ni criées directement depuis le comptoir. Le comptoir transmet, les cuisines décident.

Les conversions entre entités et DTO restent également dans les cuisines. Ce que Panache sort de la cave, le service le présente proprement avant d'envoyer en salle :

private IngredientDTO mapToIngredientDTO(Ingredient ingredient) {
    return new IngredientDTO(ingredient.id, ingredient.name, ingredient.unit, ingredient.cost);
}

private RecipeDTO mapToRecipeDTO(Recipe recipe) {
    List<IngredientDTO> ingredients = recipe.ingredients.stream()
            .map(this::mapToIngredientDTO)
            .collect(Collectors.toList());
    return new RecipeDTO(recipe.id, recipe.title, recipe.description, ingredients);
}

Le comptoir REST : ouvrir le grimoire aux clients

La taverne expose ensuite une API REST sous /grimoire.
Le comptoir reste volontairement simple : il reçoit les commandes, appelle les cuisines, et sait transformer un refus du chef en réponse compréhensible pour le client.

@Path("/grimoire")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Grimoire de la Taverne", description = "Gestion des ingrédients et des recettes magiques")
public class GrimoireResource {

    @Inject
    GrimoireService grimoireService;

    @GET
    @Path("/ingredients")
    @Operation(summary = "Lister tous les ingrédients", description = "Retourne la liste complète des ingrédients disponibles dans la réserve.")
    public List<IngredientDTO> allIngredients() {
        return grimoireService.consulterLaReserve();
    }

    @GET
    @Path("/ingredients/paged")
    @Operation(summary = "Lister les ingrédients avec pagination", description = "Retourne une page d'ingrédients.")
    public List<IngredientDTO> pagedIngredients(
            @QueryParam("page") @DefaultValue("0") int page,
            @QueryParam("size") @DefaultValue("2") int size) {
        return grimoireService.feuilleterLaReserve(page, size);
    }
}

Les routes de lecture donnent deux manières de parcourir la réserve : toute la liste d'un coup, ou étagère par étagère. Cette pagination repose directement sur Panache dans le service, sans qu'on ait besoin d'écrire une requête compliquée à la main.

L'ajout d'un ingrédient passe par un POST comme un fournisseur qui frappe à la porte de derrière et dépose sa livraison :

@POST
@Path("/ingredients")
@Operation(summary = "Ajouter un ingrédient", description = "Ajoute un nouvel ingrédient à la réserve.")
@APIResponse(responseCode = "201", description = "Ingrédient créé", 
             content = @Content(schema = @Schema(implementation = IngredientDTO.class)))
@APIResponse(responseCode = "400", description = "Données d'entrée invalides")
public Response addIngredient(IngredientDTO dto) {
    try {
        IngredientDTO created = grimoireService.acquerirNouvelIngredient(dto);
        return Response.status(Response.Status.CREATED).entity(created).build();
    } catch (IllegalArgumentException e) {
        return Response.status(Response.Status.BAD_REQUEST).entity(e.getMessage()).build();
    }
}

Le même principe s'applique au grimoire des recettes :

@GET
@Path("/recipes")
@Operation(summary = "Lister toutes les recettes", description = "Retourne la liste complète des recettes du grimoire.")
public List<RecipeDTO> allRecipes() {
    return grimoireService.consulterLeGrimoire();
}

@GET
@Path("/recipes/search")
@Operation(summary = "Rechercher des recettes", description = "Recherche des recettes par titre.")
public List<RecipeDTO> searchRecipes(@QueryParam("title") String title) {
    return grimoireService.chercherDansLeGrimoire(title);
}

@POST
@Path("/recipes")
@Operation(summary = "Ajouter une recette", description = "Ajoute une nouvelle recette au grimoire.")
@APIResponse(responseCode = "201", description = "Recette créée", 
             content = @Content(schema = @Schema(implementation = RecipeDTO.class)))
@APIResponse(responseCode = "400", description = "Données d'entrée invalides")
public Response addRecipe(RecipeDTO dto) {
    try {
        RecipeDTO created = grimoireService.inscrireNouvelleRecette(dto);
        return Response.status(Response.Status.CREATED).entity(created).build();
    } catch (IllegalArgumentException e) {
        return Response.status(Response.Status.BAD_REQUEST).entity(e.getMessage()).build();
    }
}

Le comptoir ne connaît pas les détails de Panache. Il ne sait pas si une recette est chargée par Active Record, par repository, par requête nommée ou par jointure. Il sait seulement à quelle cuisine passer la commande. C'est précisément ce qu'on attend d'un comptoir bien tenu.

Dans une taverne plus vaste, les try/catch locaux du comptoir pourraient céder leur place à une gestion d'erreurs centralisée, plus régulière et plus facile à maintenir quand les routes se multiplient.

Le même grimoire pourrait aussi être exposé autrement. Ici, REST suffit à ouvrir les étagères de la réserve, mais une salle où chaque client compose précisément sa réponse ferait naturellement écho à une taverne GraphQL posée au-dessus du même domaine.

Remplir les étagères au démarrage

Une taverne qui ouvre avec une cave vide inspire peu confiance. Pour que les aventuriers trouvent quelque chose à commander dès la première ouverture, le module fournit un fichier import.sql. Hibernate l'exécute au démarrage, après avoir construit le schéma, comme un intendant qui remplit les étagères avant que le premier client ne franchisse la porte.

-- Ingrédients
INSERT INTO Ingredient (id, name, unit, cost) VALUES (1, 'Queue de Phénix', 'unité', 150.0);
INSERT INTO Ingredient (id, name, unit, cost) VALUES (2, 'Malt de Nain', 'kg', 5.0);
INSERT INTO Ingredient (id, name, unit, cost) VALUES (3, 'Eau de source elfique', 'litre', 2.5);
INSERT INTO Ingredient (id, name, unit, cost) VALUES (4, 'Basilic séché', 'gramme', 0.5);

-- Ajustement de la séquence
ALTER SEQUENCE Ingredient_SEQ RESTART WITH 5;

-- Recettes
INSERT INTO Recipe (id, title, description) VALUES (1, 'Hydromel de l''Elfe', 'Une boisson rafraîchissante et légèrement magique.');
INSERT INTO Recipe (id, title, description) VALUES (2, 'Ragoût de Basilic', 'Un plat consistant pour les guerriers fatigués.');

-- Liaison Recettes / Ingrédients (utilisation de la JoinTable explicite)
INSERT INTO RECIPE_INGREDIENT (recipe_id, ingredient_id) VALUES (1, 2);
INSERT INTO RECIPE_INGREDIENT (recipe_id, ingredient_id) VALUES (1, 3);
INSERT INTO RECIPE_INGREDIENT (recipe_id, ingredient_id) VALUES (2, 4);

Les données racontent déjà le domaine : l'hydromel de l'Elfe réclame du malt de Nain et de l'eau de source elfique ; le ragoût de Basilic utilise du basilic séché.
La jointure entre recettes et ingrédients n'est pas abstraite, c'est une page concrète du grimoire, celle que le cuisinier consulte avant d'allumer les fourneaux.

L'ajustement de la séquence garantit que le prochain ingrédient ajouté recevra un numéro qui n'est pas déjà pris. Sans cela, la taverne risquerait d'inscrire deux fois le même identifiant dans ses registres, et un double dans le livre de comptes, même médiéval, finit toujours par causer des ennuis.

Vérifier que la taverne répond

Le module contient aussi des tests d'API avec RestAssured. Ils vérifient que les routes principales retournent bien les données chargées au démarrage, comme un contremaître qui passe derrière le comptoir avant l'ouverture pour s'assurer que tout est en place.

@QuarkusTest
public class GrimoireResourceTest {

    @Test
    public void testIngredientsEndpoint() {
        given()
          .when().get("/grimoire/ingredients")
          .then()
             .statusCode(200)
             .body("size()", is(4))
             .body("name", hasItems("Queue de Phénix", "Malt de Nain", "Eau de source elfique", "Basilic séché"));
    }

    @Test
    public void testIngredientsPagedEndpoint() {
        given()
          .queryParam("page", 0)
          .queryParam("size", 2)
          .when().get("/grimoire/ingredients/paged")
          .then()
             .statusCode(200)
             .body("size()", is(2));
    }
}

Les recettes sont testées de la même manière :

@Test
public void testRecipesEndpoint() {
    given()
      .when().get("/grimoire/recipes")
      .then()
         .statusCode(200)
         .body("size()", is(2))
         .body("title", hasItems("Hydromel de l'Elfe", "Ragoût de Basilic"));
}

@Test
public void testSearchRecipesEndpoint() {
    given()
      .queryParam("title", "Hydromel")
      .when().get("/grimoire/recipes/search")
      .then()
         .statusCode(200)
         .body("size()", is(1))
         .body("[0].title", is("Hydromel de l'Elfe"));
}

Ces tests ne cherchent pas à tout prouver. Ils confirment les comportements essentiels du tutoriel : la réserve est consultable, la pagination fonctionne, le grimoire expose ses recettes, et la recherche par titre trouve la bonne entrée.
La taverne est prête à ouvrir.

Quand la salle se remplit vraiment, ces tests fonctionnels ne suffisent plus à raconter toute l'histoire. On peut alors prolonger l'exercice avec des scénarios Gatling, pour voir comment le comptoir tient quand les commandes arrivent en masse.

Ce que Panache apporte à la taverne

Panache n'est pas une autre base de données. Ce n'est pas non plus une manière d'éviter JPA. C'est une façon plus expressive d'utiliser Hibernate dans Quarkus, comme un bon intendant qui connaît les caves par cœur et permet au tavernier de se concentrer sur ce qui compte vraiment : les recettes, les clients, et le contenu des tonneaux.

Dans ce module, on voit trois bénéfices concrets.

D'abord, PanacheEntity permet d'ouvrir la réserve sans construire une cave entière : l'ingrédient sait déjà où il range ses affaires. Pour Ingredient, pas besoin de déclarer l'identifiant ni de créer un repository complet pour lister ou paginer les données.

Ensuite, PanacheRepository offre un cellier séparé quand le grimoire devient trop complexe pour tenir dans une seule pièce. Pour Recipe, les requêtes personnalisées et le chargement des ingrédients trouvent naturellement leur place dans RecipeRepository.

Enfin, les deux cohabitent sans se marcher dessus : comme un tavernier qui sait quand déléguer à son cuisinier, et quand descendre lui-même à la cave.
Le résultat reste lisible : la réserve, le grimoire, les cuisines et le comptoir ont chacun leur rôle.

Au bout du compte, Hibernate Panache ne remplace pas l'architecture de la taverne. Il retire surtout de la poussière sur les étagères : moins de cérémonial, moins de plomberie, et plus de place pour écrire le vrai comportement de la maison.

Une fois la cave fiable, plusieurs portes s'ouvrent naturellement : afficher le grimoire avec Qute, protéger les réserves avec Basic Auth ou JWT, puis observer la taverne en production avec les outils d'observabilité Quarkus.

Dernier