GraphQL comme Port d’Entrée
Cette page présente l’utilisation de GraphQL comme port d’entrée dans une architecture hexagonale avec Micronaut.
Pourquoi GraphQL ?
GraphQL est une alternative à REST qui offre plus de flexibilité pour les clients.
REST traditionnel
API REST
// Récupérer un utilisateur
GET /api/users/1
{
"id": 1,
"email": "john@example.com",
"name": "John Doe",
"createdAt": "2025-01-01T00:00:00Z"
}
// Récupérer ses commandes
GET /api/users/1/orders
[
{ "id": 101, "total": 50.0, "status": "delivered" },
{ "id": 102, "total": 30.0, "status": "pending" }
]
// Récupérer les détails d'une commande
GET /api/orders/101
{
"id": 101,
"total": 50.0,
"items": [...]
}Problèmes :
- Multiple requests : 3 appels pour avoir user + orders + items
- Over-fetching : on reçoit tous les champs même si on n’a besoin que du
name - Under-fetching : besoin de plusieurs appels pour avoir toutes les données
GraphQL = Flexibilité + Performance + Documentation automatique
Configuration Micronaut GraphQL
Ajouter la dépendance
<!-- pom.xml -->
<dependency>
<groupId>io.micronaut.graphql</groupId>
<artifactId>micronaut-graphql</artifactId>
</dependency>Configurer GraphQL
# application.yml
graphql:
enabled: true
path: /graphql
graphiql:
enabled: true
path: /graphiql
schema-generation:
enabled: trueEndpoints disponibles :
POST /graphql: API GraphQLGET /graphiql: Interface GraphiQL (développement)
Vérifier l’installation
# Démarrer l'application
./mvnw mn:run
# Ouvrir GraphiQL
http://localhost:8080/graphiqlGraphiQL est un IDE interactif pour tester vos queries GraphQL avec autocomplétion et documentation.
Schéma GraphQL
Le schéma GraphQL définit les types et opérations disponibles.
Schema (graphql)
Définir le schéma
# src/main/resources/schema.graphqls
type User {
id: ID!
email: String!
name: String!
createdAt: String!
orders: [Order!]!
}
type Order {
id: ID!
userId: ID!
total: Float!
status: OrderStatus!
items: [OrderItem!]!
}
type OrderItem {
product: String!
quantity: Int!
price: Float!
}
enum OrderStatus {
PENDING
DELIVERED
CANCELLED
}
type Query {
user(id: ID!): User
users: [User!]!
order(id: ID!): Order
}
type Mutation {
createUser(email: String!, name: String!): User!
placeOrder(userId: ID!, items: [OrderItemInput!]!): Order!
}
input OrderItemInput {
product: String!
quantity: Int!
price: Float!
}Placez le schéma GraphQL dans src/main/resources/schema.graphqls pour qu’il soit automatiquement chargé par Micronaut.
GraphQL Resolvers (Ports d’Entrée)
Les resolvers sont les adapters d’entrée qui font le pont entre GraphQL et les Use Cases.
- UserQueryResolver.java
- UserMutationResolver.java
- OrderQueryResolver.java
- UserDataFetcher.java
Query Resolver
Query Resolver
package com.example.infrastructure.adapters.graphql;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import io.micronaut.graphql.annotation.GraphQLRepository;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
@Singleton
@GraphQLRepository
@RequiredArgsConstructor
public class UserQueryResolver {
private final GetUserUseCase getUserUseCase;
private final GetAllUsersUseCase getAllUsersUseCase;
public DataFetcher<User> user() {
return env -> {
Long id = Long.parseLong(env.getArgument("id"));
return getUserUseCase.execute(id);
};
}
public DataFetcher<List<User>> users() {
return env -> getAllUsersUseCase.execute();
}
}Attention : les resolvers sont des adapters d’entrée (infrastructure) qui appellent les Use Cases (application).
Architecture Hexagonale avec GraphQL
Voici l’architecture complète avec GraphQL comme port d’entrée.
┌─────────────────────────────────────────────────────────┐
│ Infrastructure │
├─────────────────────────────────────────────────────────┤
│ │
│ GraphQL Resolvers (Adapters IN) │
│ ├─ UserQueryResolver │
│ ├─ UserMutationResolver │
│ └─ UserDataFetcher │
│ ↓ │
├──────────────────────────────────────────────────────────┤
│ Application │
├──────────────────────────────────────────────────────────┤
│ │
│ Use Cases (Ports IN) │
│ ├─ GetUserUseCase │
│ ├─ CreateUserUseCase │
│ └─ GetUserOrdersUseCase │
│ ↓ │
├──────────────────────────────────────────────────────────┤
│ Domain │
├──────────────────────────────────────────────────────────┤
│ │
│ Entities │
│ ├─ User │
│ └─ Order │
│ ↓ │
├──────────────────────────────────────────────────────────┤
│ Application │
├──────────────────────────────────────────────────────────┤
│ │
│ Repositories (Ports OUT) │
│ ├─ UserRepository │
│ └─ OrderRepository │
│ ↓ │
├──────────────────────────────────────────────────────────┤
│ Infrastructure │
├──────────────────────────────────────────────────────────┤
│ │
│ Repository Adapters (Adapters OUT) │
│ ├─ JpaUserRepository │
│ └─ JpaOrderRepository │
│ │
└──────────────────────────────────────────────────────────┘GraphQL Resolver = Adapter d’entrée au même titre qu’un Controller REST.
Exemple Complet
Use Case
Use Case
package com.example.application.usecases;
import com.example.domain.model.User;
import com.example.application.ports.out.UserRepository;
import jakarta.inject.Singleton;
import lombok.RequiredArgsConstructor;
@Singleton
@RequiredArgsConstructor
public class GetUserUseCase {
private final UserRepository userRepository;
public User execute(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User not found: " + userId));
}
}Problème N+1 et DataLoader
Le problème N+1 se produit quand une query déclenche N requêtes en base pour chaque élément.
❌ Problème N+1
Problème N+1
query {
users { # 1 requête SQL
id
name
orders { # N requêtes SQL (1 par user)
id
total
}
}
}Flux d’exécution :
-- Requête 1 : récupérer tous les users
SELECT * FROM users;
-- Requête 2 : récupérer les orders du user 1
SELECT * FROM orders WHERE user_id = 1;
-- Requête 3 : récupérer les orders du user 2
SELECT * FROM orders WHERE user_id = 2;
-- Requête N : récupérer les orders du user N
SELECT * FROM orders WHERE user_id = N;Résultat : 1 + N requêtes SQL (très inefficace)
DataLoader est crucial pour éviter le problème N+1 dans les queries GraphQL avec relations.
Gestion des Erreurs
GraphQL a un format d’erreur standardisé.
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import graphql.schema.DataFetcher;
@Singleton
@RequiredArgsConstructor
public class UserQueryResolver {
private final GetUserUseCase getUserUseCase;
public DataFetcher<User> user() {
return env -> {
try {
Long id = Long.parseLong(env.getArgument("id"));
return getUserUseCase.execute(id);
} catch (ResourceNotFoundException e) {
throw GraphqlErrorBuilder.newError()
.message(e.getMessage())
.path(env.getExecutionStepInfo().getPath())
.errorType(ErrorType.NOT_FOUND)
.build();
}
};
}
}Réponse d’erreur :
{
"data": {
"user": null
},
"errors": [
{
"message": "User not found: 999",
"path": ["user"],
"errorType": "NOT_FOUND"
}
]
}Tests
Tester un Resolver
@MicronautTest
class UserQueryResolverTest {
@Inject
UserQueryResolver resolver;
@MockBean(GetUserUseCase.class)
GetUserUseCase getUserUseCase() {
return mock(GetUserUseCase.class);
}
@Test
void shouldReturnUser() throws Exception {
// Given
User user = new User(1L, "john@example.com", "John Doe", Instant.now());
when(getUserUseCase.execute(1L)).thenReturn(user);
DataFetchingEnvironment env = mock(DataFetchingEnvironment.class);
when(env.getArgument("id")).thenReturn("1");
// When
User result = resolver.user().get(env);
// Then
assertThat(result.email()).isEqualTo("john@example.com");
verify(getUserUseCase).execute(1L);
}
}Tester une Query GraphQL
@MicronautTest
class GraphQLIntegrationTest {
@Inject
@Client("/graphql")
HttpClient client;
@Test
void shouldGetUserById() {
// Given
String query = """
query {
user(id: "1") {
id
email
name
}
}
""";
Map<String, Object> request = Map.of("query", query);
// When
HttpResponse<Map> response = client.toBlocking().exchange(
HttpRequest.POST("/", request),
Map.class
);
// Then
assertThat(response.status()).isEqualTo(HttpStatus.OK);
Map<String, Object> data = (Map<String, Object>) response.body().get("data");
Map<String, Object> user = (Map<String, Object>) data.get("user");
assertThat(user.get("email")).isEqualTo("john@example.com");
}
}Comparaison REST vs GraphQL
| Critère | REST | GraphQL |
|---|---|---|
| Endpoints | Multiples (/users, /orders) | Un seul (/graphql) |
| Over-fetching | Oui (tous les champs) | Non (client choisit) |
| Under-fetching | Oui (N requêtes) | Non (une query) |
| Versioning | /v1/users, /v2/users | Évolution du schéma |
| Caching | Facile (HTTP cache) | Plus complexe |
| Complexité | Simple | Plus complexe (N+1, DataLoader) |
| Documentation | Swagger/OpenAPI | Introspection automatique |
| Cas d’usage | CRUD simple, public APIs | Apps complexes, mobile |
Recommandation :
- REST : APIs publiques, CRUD simple, besoin de caching HTTP
- GraphQL : Apps frontend complexes, mobile, micro-frontends
Bonnes Pratiques
Règles d’or pour GraphQL :
- Schéma first : définir le schéma GraphQL avant le code
- DataLoader : toujours utiliser pour les relations (éviter N+1)
- Pagination : implémenter la pagination pour les listes (
first,after) - Error handling : utiliser le format GraphQL standard
- Validation : valider les inputs dans les Use Cases (pas dans les resolvers)
- Depth limiting : limiter la profondeur des queries (éviter les queries malveillantes)
- Query complexity : calculer la complexité des queries et rejeter les plus coûteuses
- Monitoring : logger les queries lentes avec Micrometer
Pagination avec GraphQL
type Query {
users(first: Int, after: String): UserConnection!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}Resolver avec pagination :
public DataFetcher<UserConnection> users() {
return env -> {
Integer first = env.getArgument("first");
String after = env.getArgument("after");
Page<User> page = getAllUsersUseCase.execute(first, after);
return new UserConnection(
page.users().stream()
.map(user -> new UserEdge(user, encodeCursor(user.id())))
.toList(),
new PageInfo(page.hasNextPage(), encodeCursor(page.lastUserId()))
);
};
}Références
Prochaine étape : Stratégies de Migration