Entrevista Spring GraphQL: Resolvers, DataLoaders y Soluciones al Problema N+1

Prepárate para entrevistas Spring GraphQL con esta guía completa. Resolvers, DataLoaders, gestión del problema N+1, mutaciones y mejores prácticas para preguntas técnicas.

Entrevista técnica Spring GraphQL con resolvers y DataLoaders

Spring for GraphQL simplifica la integración de GraphQL en aplicaciones Spring Boot. Esta tecnología se ha vuelto indispensable para las APIs modernas, y las preguntas de entrevista sobre este tema son cada vez más frecuentes. Esta guía cubre los conceptos fundamentales: resolvers, DataLoaders, problema N+1 y patrones avanzados.

Foco de la entrevista

Los reclutadores evalúan especialmente la comprensión del problema N+1 y el uso de DataLoaders. Estos dos temas representan el 60% de las preguntas en entrevistas Spring GraphQL.

¿Qué es Spring for GraphQL?

Spring for GraphQL es el sucesor oficial de GraphQL Java Spring, integrado nativamente en el framework Spring desde la versión 2.7. Esta integración aporta varias ventajas: soporte de las anotaciones Spring, integración con Spring Security y uso transparente de WebFlux o MVC.

La configuración básica solo requiere la dependencia spring-boot-starter-graphql y un schema GraphQL.

build.gradle.ktsjava
dependencies {
    // Spring GraphQL starter con Spring MVC
    implementation("org.springframework.boot:spring-boot-starter-graphql")
    implementation("org.springframework.boot:spring-boot-starter-web")

    // Para los tests GraphQL
    testImplementation("org.springframework.graphql:spring-graphql-test")
}

El schema GraphQL se ubica en src/main/resources/graphql/ con la extensión .graphqls.

graphql
# schema.graphqls
type Query {
    # Recuperar un artículo por su identificador
    article(id: ID!): Article

    # Lista paginada de artículos
    articles(page: Int = 0, size: Int = 10): [Article!]!
}

type Article {
    id: ID!
    title: String!
    content: String!
    # Relación con el autor
    author: Author!
    # Lista de comentarios
    comments: [Comment!]!
    createdAt: String!
}

type Author {
    id: ID!
    name: String!
    email: String!
    # Artículos escritos por este autor
    articles: [Article!]!
}

type Comment {
    id: ID!
    content: String!
    author: Author!
    createdAt: String!
}

Esta arquitectura declara los tipos y sus relaciones. GraphQL genera automáticamente la documentación y valida las consultas del lado del servidor.

¿Cómo funcionan los Resolvers en Spring GraphQL?

Los resolvers constituyen el corazón de la ejecución GraphQL. Cada campo del schema puede tener un resolver dedicado. Spring utiliza la anotación @QueryMapping para las consultas raíz y @SchemaMapping para las relaciones.

ArticleController.javajava
@Controller
public class ArticleController {

    private final ArticleRepository articleRepository;
    private final AuthorRepository authorRepository;

    public ArticleController(ArticleRepository articleRepository,
                            AuthorRepository authorRepository) {
        this.articleRepository = articleRepository;
        this.authorRepository = authorRepository;
    }

    // Resolver para Query.article(id)
    @QueryMapping
    public Article article(@Argument Long id) {
        return articleRepository.findById(id)
            .orElseThrow(() -> new ArticleNotFoundException(id));
    }

    // Resolver para Query.articles(page, size)
    @QueryMapping
    public List<Article> articles(@Argument int page,
                                  @Argument int size) {
        Pageable pageable = PageRequest.of(page, size);
        return articleRepository.findAll(pageable).getContent();
    }

    // Resolver para Article.author - llamado para cada artículo
    @SchemaMapping(typeName = "Article", field = "author")
    public Author author(Article article) {
        return authorRepository.findById(article.getAuthorId())
            .orElseThrow(() -> new AuthorNotFoundException(article.getAuthorId()));
    }
}

El resolver author se ejecuta para cada artículo devuelto. Esta arquitectura flexible carga los datos bajo demanda pero introduce el problema N+1.

Trampa clásica de entrevista

Un candidato que implementa un resolver @SchemaMapping sin mencionar el problema N+1 demuestra una comprensión incompleta de GraphQL. Los reclutadores esperan sistemáticamente este análisis.

¿Qué es el Problema N+1 en GraphQL?

El problema N+1 surge cuando una consulta GraphQL desencadena N consultas adicionales para cargar las relaciones. Este patrón destructivo aparece sistemáticamente con resolvers básicos.

Considera una consulta que recupera 50 artículos con sus autores:

graphql
# Consulta GraphQL
query {
    articles(size: 50) {
        id
        title
        author {
            name
        }
    }
}

Con el resolver anterior, esta consulta ejecuta:

  • 1 consulta para los 50 artículos
  • 50 consultas para cargar cada autor individualmente
sql
-- Consulta 1: recuperación de artículos
SELECT * FROM articles LIMIT 50

-- Consultas 2-51: cada autor individualmente
SELECT * FROM authors WHERE id = 1
SELECT * FROM authors WHERE id = 2
SELECT * FROM authors WHERE id = 3
-- ... 47 consultas adicionales

Esta multiplicación de consultas degrada drásticamente el rendimiento. Un endpoint que responde en 50ms puede saltar a 2 segundos con N+1.

¿Cómo funcionan los DataLoaders?

Los DataLoaders agrupan las consultas individuales en peticiones por lotes. En lugar de cargar cada autor por separado, el DataLoader recopila todos los IDs solicitados y ejecuta una única consulta.

AuthorBatchLoader.javajava
@Component
public class AuthorBatchLoader {

    private final AuthorRepository authorRepository;

    public AuthorBatchLoader(AuthorRepository authorRepository) {
        this.authorRepository = authorRepository;
    }

    // Método de carga por lotes
    public Mono<Map<Long, Author>> loadAuthors(Set<Long> authorIds) {
        // Consulta única para todos los autores
        List<Author> authors = authorRepository.findAllById(authorIds);

        // Transformación a Map para búsqueda rápida
        Map<Long, Author> authorMap = authors.stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        return Mono.just(authorMap);
    }
}

El registro del DataLoader se realiza mediante BatchLoaderRegistry en una configuración dedicada.

GraphQLConfig.javajava
@Configuration
public class GraphQLConfig {

    @Bean
    public BatchLoaderRegistry batchLoaderRegistry(
            AuthorBatchLoader authorBatchLoader) {

        return registry -> {
            // Registro del DataLoader para los autores
            registry.forTypePair(Long.class, Author.class)
                .registerMappedBatchLoader((authorIds, env) ->
                    authorBatchLoader.loadAuthors(authorIds));
        };
    }
}

El resolver modificado utiliza ahora el DataLoader en lugar del acceso directo:

ArticleController.javajava
@Controller
public class ArticleController {

    private final ArticleRepository articleRepository;

    public ArticleController(ArticleRepository articleRepository) {
        this.articleRepository = articleRepository;
    }

    @QueryMapping
    public List<Article> articles(@Argument int page,
                                  @Argument int size) {
        Pageable pageable = PageRequest.of(page, size);
        return articleRepository.findAll(pageable).getContent();
    }

    // Resolver optimizado con DataLoader
    @SchemaMapping(typeName = "Article", field = "author")
    public CompletableFuture<Author> author(
            Article article,
            DataLoader<Long, Author> authorDataLoader) {

        // El DataLoader agrupa automáticamente las llamadas
        return authorDataLoader.load(article.getAuthorId());
    }
}

Con este enfoque, la misma consulta para 50 artículos ejecuta solo 2 consultas SQL:

sql
-- Consulta 1: recuperación de artículos
SELECT * FROM articles LIMIT 50

-- Consulta 2: todos los autores de una vez
SELECT * FROM authors WHERE id IN (1, 2, 3, 4, 5, ..., 50)

¿Listo para aprobar tus entrevistas de Spring Boot?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

¿Cómo implementar un DataLoader con contexto?

Los DataLoaders avanzados a veces requieren contexto adicional, como un identificador de tenant para aplicaciones multi-tenant o filtros de seguridad.

SecuredAuthorBatchLoader.javajava
@Component
public class SecuredAuthorBatchLoader {

    private final AuthorRepository authorRepository;
    private final SecurityContextHolder securityContext;

    public SecuredAuthorBatchLoader(AuthorRepository authorRepository,
                                    SecurityContextHolder securityContext) {
        this.authorRepository = authorRepository;
        this.securityContext = securityContext;
    }

    public Mono<Map<Long, Author>> loadAuthorsForTenant(
            Set<Long> authorIds,
            BatchLoaderEnvironment env) {

        // Recuperar el contexto GraphQL
        GraphQLContext context = env.getContext();
        String tenantId = context.get("tenantId");

        // Consulta filtrada por tenant
        List<Author> authors = authorRepository
            .findAllByIdInAndTenantId(authorIds, tenantId);

        Map<Long, Author> authorMap = authors.stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        return Mono.just(authorMap);
    }
}

La configuración del contexto se realiza mediante un interceptor WebGraphQL:

TenantInterceptor.javajava
@Component
public class TenantInterceptor implements WebGraphQlInterceptor {

    @Override
    public Mono<WebGraphQlResponse> intercept(
            WebGraphQlRequest request,
            Chain chain) {

        // Extraer el tenant de los headers
        String tenantId = request.getHeaders()
            .getFirst("X-Tenant-ID");

        // Añadir al contexto GraphQL
        request.configureExecutionInput((input, builder) ->
            builder.graphQLContext(ctx ->
                ctx.put("tenantId", tenantId)
            ).build()
        );

        return chain.next(request);
    }
}

¿Cuáles son las diferencias entre @QueryMapping y @SchemaMapping?

Esta pregunta clásica de entrevista verifica la comprensión de la jerarquía de resolvers.

| Anotación | Uso | Equivalente | |-----------|-----|-------------| | @QueryMapping | Campos raíz del tipo Query | @SchemaMapping(typeName = "Query") | | @MutationMapping | Campos raíz del tipo Mutation | @SchemaMapping(typeName = "Mutation") | | @SubscriptionMapping | Suscripciones en tiempo real | @SchemaMapping(typeName = "Subscription") | | @SchemaMapping | Todos los campos de cualquier tipo | Forma genérica |

EquivalenceDemo.javajava
@Controller
public class EquivalenceDemo {

    // Estas dos declaraciones son equivalentes
    @QueryMapping
    public Article article(@Argument Long id) {
        return findArticle(id);
    }

    @SchemaMapping(typeName = "Query", field = "article")
    public Article articleEquivalent(@Argument Long id) {
        return findArticle(id);
    }

    // Para los campos anidados, solo @SchemaMapping funciona
    @SchemaMapping(typeName = "Article", field = "author")
    public Author author(Article article) {
        return findAuthor(article.getAuthorId());
    }

    // Sintaxis alternativa con el tipo como parámetro
    @SchemaMapping
    public Author author(Article article) {
        // Spring infiere typeName = "Article" del parámetro
        return findAuthor(article.getAuthorId());
    }
}

¿Cómo gestionar las mutaciones con validación?

Las mutaciones GraphQL modifican los datos. Spring GraphQL se integra con Bean Validation para validar las entradas.

graphql
# schema.graphqls
type Mutation {
    createArticle(input: CreateArticleInput!): Article!
    updateArticle(id: ID!, input: UpdateArticleInput!): Article!
    deleteArticle(id: ID!): Boolean!
}

input CreateArticleInput {
    title: String!
    content: String!
    authorId: ID!
}

input UpdateArticleInput {
    title: String
    content: String
}

El controller utiliza @Valid para activar la validación:

ArticleMutationController.javajava
@Controller
public class ArticleMutationController {

    private final ArticleService articleService;

    public ArticleMutationController(ArticleService articleService) {
        this.articleService = articleService;
    }

    @MutationMapping
    public Article createArticle(@Argument @Valid CreateArticleInput input) {
        // La validación se ejecuta automáticamente
        return articleService.create(input);
    }

    @MutationMapping
    public Article updateArticle(@Argument Long id,
                                 @Argument @Valid UpdateArticleInput input) {
        return articleService.update(id, input);
    }

    @MutationMapping
    public boolean deleteArticle(@Argument Long id) {
        articleService.delete(id);
        return true;
    }
}
CreateArticleInput.javajava
public record CreateArticleInput(
    @NotBlank(message = "El título es obligatorio")
    @Size(min = 5, max = 200, message = "El título debe tener entre 5 y 200 caracteres")
    String title,

    @NotBlank(message = "El contenido es obligatorio")
    @Size(min = 100, message = "El contenido debe tener al menos 100 caracteres")
    String content,

    @NotNull(message = "El autor es obligatorio")
    Long authorId
) {}
Gestión de errores

Los errores de validación devuelven automáticamente un error GraphQL estructurado con la ruta del campo no válido. Spring GraphQL formatea estos errores según la especificación GraphQL.

¿Cómo optimizar las consultas con @BatchMapping?

La anotación @BatchMapping simplifica la creación de DataLoaders directamente en los controllers. Este enfoque evita la configuración explícita del BatchLoaderRegistry.

OptimizedArticleController.javajava
@Controller
public class OptimizedArticleController {

    private final ArticleRepository articleRepository;
    private final AuthorRepository authorRepository;
    private final CommentRepository commentRepository;

    public OptimizedArticleController(ArticleRepository articleRepository,
                                      AuthorRepository authorRepository,
                                      CommentRepository commentRepository) {
        this.articleRepository = articleRepository;
        this.authorRepository = authorRepository;
        this.commentRepository = commentRepository;
    }

    @QueryMapping
    public List<Article> articles(@Argument int page,
                                  @Argument int size) {
        return articleRepository.findAll(PageRequest.of(page, size))
            .getContent();
    }

    // BatchMapping para los autores - procesa todos los artículos a la vez
    @BatchMapping
    public Map<Article, Author> author(List<Article> articles) {
        // Recopilar los IDs únicos de autores
        Set<Long> authorIds = articles.stream()
            .map(Article::getAuthorId)
            .collect(Collectors.toSet());

        // Consulta única para todos los autores
        Map<Long, Author> authorsById = authorRepository
            .findAllById(authorIds)
            .stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        // Asociación artículo -> autor
        return articles.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                article -> authorsById.get(article.getAuthorId())
            ));
    }

    // BatchMapping para los comentarios
    @BatchMapping
    public Map<Article, List<Comment>> comments(List<Article> articles) {
        List<Long> articleIds = articles.stream()
            .map(Article::getId)
            .toList();

        // Recuperación por lotes de comentarios
        List<Comment> allComments = commentRepository
            .findByArticleIdIn(articleIds);

        // Agrupación por artículo
        Map<Long, List<Comment>> commentsByArticle = allComments.stream()
            .collect(Collectors.groupingBy(Comment::getArticleId));

        return articles.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                article -> commentsByArticle
                    .getOrDefault(article.getId(), List.of())
            ));
    }
}

@BatchMapping ofrece una sintaxis más concisa que la configuración manual de DataLoaders, manteniendo las mismas garantías de rendimiento.

¿Cómo probar los Resolvers GraphQL?

Spring GraphQL proporciona GraphQlTester para escribir tests expresivos y legibles.

ArticleControllerTest.javajava
@SpringBootTest
@AutoConfigureGraphQlTester
class ArticleControllerTest {

    @Autowired
    private GraphQlTester graphQlTester;

    @Test
    void shouldReturnArticleById() {
        // Given: documento GraphQL
        String query = """
            query {
                article(id: 1) {
                    id
                    title
                    author {
                        name
                    }
                }
            }
            """;

        // When & Then
        graphQlTester.document(query)
            .execute()
            .path("article.id").entity(String.class).isEqualTo("1")
            .path("article.title").entity(String.class).isNotEmpty()
            .path("article.author.name").entity(String.class).isNotEmpty();
    }

    @Test
    void shouldCreateArticle() {
        String mutation = """
            mutation {
                createArticle(input: {
                    title: "Nuevo título",
                    content: "Contenido de prueba lo suficientemente largo para validar las restricciones",
                    authorId: 1
                }) {
                    id
                    title
                }
            }
            """;

        graphQlTester.document(mutation)
            .execute()
            .path("createArticle.id").entity(String.class).isNotEmpty()
            .path("createArticle.title").entity(String.class)
                .isEqualTo("Nuevo título");
    }

    @Test
    void shouldReturnErrorForInvalidInput() {
        String mutation = """
            mutation {
                createArticle(input: {
                    title: "",
                    content: "corto",
                    authorId: 1
                }) {
                    id
                }
            }
            """;

        graphQlTester.document(mutation)
            .execute()
            .errors()
            .expect(error -> error.getMessage()
                .contains("El título es obligatorio"));
    }

    @Test
    void shouldHandleVariables() {
        String query = """
            query GetArticle($id: ID!) {
                article(id: $id) {
                    id
                    title
                }
            }
            """;

        graphQlTester.document(query)
            .variable("id", 42)
            .execute()
            .path("article").valueIsNull();
    }
}

¡Empieza a practicar!

Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.

¿Cómo gestionar las suscripciones en tiempo real?

Las suscripciones permiten enviar datos en tiempo real a los clientes a través de WebSocket.

graphql
# schema.graphqls
type Subscription {
    articleCreated: Article!
    commentAdded(articleId: ID!): Comment!
}
SubscriptionController.javajava
@Controller
public class SubscriptionController {

    private final Sinks.Many<Article> articleSink =
        Sinks.many().multicast().onBackpressureBuffer();

    private final Sinks.Many<Comment> commentSink =
        Sinks.many().multicast().onBackpressureBuffer();

    @SubscriptionMapping
    public Flux<Article> articleCreated() {
        return articleSink.asFlux();
    }

    @SubscriptionMapping
    public Flux<Comment> commentAdded(@Argument Long articleId) {
        return commentSink.asFlux()
            .filter(comment -> comment.getArticleId().equals(articleId));
    }

    // Método llamado al crear un artículo
    public void publishArticle(Article article) {
        articleSink.tryEmitNext(article);
    }

    // Método llamado al añadir un comentario
    public void publishComment(Comment comment) {
        commentSink.tryEmitNext(comment);
    }
}

La configuración WebSocket requiere una dependencia adicional:

build.gradle.ktsjava
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-websocket")
}
WebSocketConfig.javajava
@Configuration
public class WebSocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

¿Cuáles son las mejores prácticas de seguridad GraphQL?

La seguridad GraphQL requiere varias capas de protección contra los ataques de denegación de servicio y los accesos no autorizados.

SecurityConfig.javajava
@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                // Endpoint GraphQL protegido
                .requestMatchers("/graphql").authenticated()
                // GraphiQL accesible solo en dev
                .requestMatchers("/graphiql").permitAll()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
            .build();
    }
}
SecuredArticleController.javajava
@Controller
public class SecuredArticleController {

    private final ArticleRepository articleRepository;

    public SecuredArticleController(ArticleRepository articleRepository) {
        this.articleRepository = articleRepository;
    }

    // Acceso restringido a usuarios autenticados
    @QueryMapping
    @PreAuthorize("isAuthenticated()")
    public List<Article> articles(@Argument int page,
                                  @Argument int size) {
        return articleRepository.findAll(PageRequest.of(page, size))
            .getContent();
    }

    // Solo los administradores pueden eliminar
    @MutationMapping
    @PreAuthorize("hasRole('ADMIN')")
    public boolean deleteArticle(@Argument Long id) {
        articleRepository.deleteById(id);
        return true;
    }

    // Verificación granular: solo el autor puede modificar
    @MutationMapping
    @PreAuthorize("@articleSecurity.isAuthor(#id, authentication)")
    public Article updateArticle(@Argument Long id,
                                 @Argument UpdateArticleInput input) {
        return articleRepository.findById(id)
            .map(article -> {
                article.setTitle(input.title());
                article.setContent(input.content());
                return articleRepository.save(article);
            })
            .orElseThrow();
    }
}

Protección contra consultas complejas:

yaml
# application.yml
spring:
  graphql:
    schema:
      introspection:
        # Desactivar la introspección en producción
        enabled: false
QueryComplexityInstrumentation.javajava
@Component
public class QueryComplexityInstrumentation extends SimplePerformantInstrumentation {

    private static final int MAX_COMPLEXITY = 100;

    @Override
    public InstrumentationState createState(InstrumentationCreateStateParameters parameters) {
        return new ComplexityState();
    }

    @Override
    public InstrumentationContext<ExecutionResult> beginExecution(
            InstrumentationExecutionParameters parameters,
            InstrumentationState state) {

        int complexity = calculateComplexity(parameters.getDocument());

        if (complexity > MAX_COMPLEXITY) {
            throw new GraphQLException(
                "Consulta demasiado compleja: " + complexity + " (max: " + MAX_COMPLEXITY + ")"
            );
        }

        return super.beginExecution(parameters, state);
    }
}

Conclusión

Spring for GraphQL ofrece una integración elegante de GraphQL en el ecosistema Spring. El dominio de los resolvers, los DataLoaders y la gestión del problema N+1 constituye la base de conocimientos esperada en entrevistas.

Checklist de entrevista Spring GraphQL:

  • ✅ Explicar el rol de las anotaciones @QueryMapping, @MutationMapping y @SchemaMapping
  • ✅ Describir el problema N+1 y su impacto en el rendimiento GraphQL
  • ✅ Implementar un DataLoader con @BatchMapping o BatchLoaderRegistry
  • ✅ Validar las entradas de mutación con Bean Validation
  • ✅ Asegurar los resolvers con @PreAuthorize y Spring Security
  • ✅ Probar los resolvers con GraphQlTester
  • ✅ Configurar las suscripciones para funcionalidades en tiempo real
  • ✅ Proteger la API contra consultas demasiado complejas

Etiquetas

#spring graphql
#dataloaders
#n+1 problem
#interview
#graphql java

Compartir

Artículos relacionados