Skip to content

บทที่ 14 — GraphQL (ภาษา query API ที่ client เลือก field เองได้) with Spring — Spring for GraphQL

← บทที่ 13: Batch + Scheduling | สารบัญ | บทที่ 15: Native Image →

REST มีปัญหา:

  • Over-fetching/users/1 คืน 30 field, frontend ใช้แค่ 3
  • Under-fetching — ต้องเรียกหลาย endpoint สำหรับ 1 หน้า (/users/1 + /users/1/orders + /orders/.../items)
  • Versioning hell (ปัญหา version ที่จัดการยาก) — เพิ่ม field → break consumer เก่า

GraphQL แก้:

  • Client บอกว่า "อยากได้ field อะไร" → server คืนแค่นั้น
  • 1 endpoint = 1 round trip
  • Schema ระบุ type ชัดเจน + introspection (ดูโครงสร้าง API ได้เลยโดยไม่ต้องอ่าน doc)

ใช้เวลา 3-4 ชั่วโมง

🚧 โซนขั้นสูง — ข้ามได้

บทนี้เป็น บทอ้างอิงระดับลึก มือใหม่ ข้ามไปก่อนได้REST API (บทที่ 1) เพียงพอและเรียนง่ายกว่าสำหรับเริ่มต้น GraphQL เหมาะกับงานที่ frontend ต้องการดึงข้อมูลหลากหลายรูปแบบ กลับมาอ่านเมื่อทีม frontend ขอ หรือเจอปัญหา over/under-fetching จริง

📋 ก่อนเริ่ม + ศัพท์ปูพื้น

  • ✅ ต้องผ่าน: Spring Boot บทที่ 1–2 (REST + JPA) และเข้าใจ N+1 problem (บทที่ 2 ข้อ 10)
  • ⚠️ หัวข้อ Subscription (3.3) ต้องเข้าใจ Flux จากบท 10 — ข้ามได้ถ้ายังไม่ผ่าน
  • ⚠️ Part 6 Security ต้องผ่านบท Security ก่อน — ข้ามได้ถ้ายังไม่ผ่าน

🔑 คอนเซ็ปต์หลัก:

  • GraphQL (กราฟ-คิวแอล) = ภาษา query API ที่ client เลือกได้เองว่าจะเอา field ไหน
  • schema (สคี-มา) = "พิมพ์เขียว" บอกว่ามีข้อมูลอะไร หน้าตาแบบไหน

🔑 ปัญหาที่ GraphQL แก้:

  • over-fetching = ได้ข้อมูลเกินที่ใช้ (REST /users/1 คืน 30 field แต่ใช้ 3)
  • under-fetching = ได้ไม่พอ ต้องยิงซ้ำหลายรอบ

🔑 ขั้นสูง (เจอตอนเขียน resolver):

  • resolver (รี-โซลฟ์-เวอร์) = ฟังก์ชันที่ดึงค่าของแต่ละ field
  • DataLoader = ตัวช่วยรวม query หลายอันเป็นก้อนเดียว แก้ N+1

Part 1: GraphQL คืออะไร

1.1 ภาษาสำหรับ Query API

GraphQL ต่างจาก REST ตรงที่ client เป็นคนบอกว่าต้องการ field อะไรบ้างในคำขอเดียว วิธีนี้แก้ปัญหา over-fetching (ได้ field เกิน) และ under-fetching (ต้องยิงหลาย request) ของ REST server มี schema เดียวเท่านั้น แต่ client query ได้ยืดหยุ่น:

graphql
# Client ส่ง query
query {
  user(id: 1) {
    name
    email
    orders(limit: 5) {
      id
      total
      items {
        product { name }
        quantity
      }
    }
  }
}

Server คืน JSON ที่ตรง shape ของ query:

json
{
  "data": {
    "user": {
      "name": "Alice",
      "email": "a@x.com",
      "orders": [
        {"id": 1, "total": 99.50, "items": [...]}
      ]
    }
  }
}

1.2 Operation 3 ชนิด

Operationใช้ทำอะไร
queryอ่านข้อมูล (เหมือน GET)
mutationเปลี่ยนแปลง (POST/PUT/DELETE)
subscriptionstreaming (WebSocket)

1.3 ความต่างกับ REST

RESTGraphQL
Endpointหลาย1 (/graphql)
HTTP methodGET/POST/PUT/DELETEPOST เกือบทั้งหมด
Response shapeกำหนดโดย serverกำหนดโดย client
CachingHTTP cache ช่วยต้องทำเอง (persisted query)
Type systemผ่าน OpenAPIมี type system ในตัว (schema บังคับ)
VersioningURL versioning, headerSchema evolution (deprecate field)
Learning curveต่ำสูง

🔑 ศัพท์ในตาราง:

  • persisted query (เพอร์-ซิส-ทิด-เคียวรี่) = query ที่ register ไว้ก่อน server เก็บ → client ส่ง hash แทน query เต็ม (cache ได้ + ลด payload)
  • introspection (อิน-โทร-สเป็ก-ชั่น) = การส่อง schema — client query schema ของ server ได้เอง
  • Schema evolution = พัฒนา/เปลี่ยน schema ทีละน้อย (ไม่ break client เก่า)
  • deprecate field = มาร์ค field ว่าจะเลิกใช้ — ยังคืนค่าได้แต่บอก client ให้เลิกขอ

Part 2: Setup Spring for GraphQL

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-graphql</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
yaml
# application-dev.yml
# GraphiQL (สังเกตตัว i) = หน้าเว็บ playground สำหรับลองพิมพ์ query ต่างจาก GraphQL (ตัว L)
# เปิดได้ที่ http://localhost:8080/graphiql หลัง start แอป — ใช้ทดสอบ query ได้เลย
spring:
  graphql:
    graphiql:
      enabled: true                     # ← เปิด GraphiQL UI (dev only)
      path: /graphiql
    schema:
      printer.enabled: true             # /graphql/schema
      introspection:
        enabled: true                   # dev เปิดเพื่อให้ GraphiQL ส่อง schema ได้
    cors:
      allowed-origins:
        - "http://localhost:3000"       # ระบุ origin ชัด ๆ — ห้ามใช้ "*"
        - "http://localhost:5173"
yaml
# application-prod.yml — production lock down
spring:
  graphql:
    graphiql:
      enabled: false                    # ปิด GraphiQL UI ใน prod
    schema:
      introspection:
        enabled: false                  # ⭐ ปิด introspection ใน prod (ดู security note ข้างล่าง)
    cors:
      allowed-origins:
        - "https://app.example.com"     # production origin ที่ trust จริง ๆ เท่านั้น

🔴 CORS wildcard เป็นบั๊กความปลอดภัย: allowed-origins: "*" กับ GraphQL endpoint ที่ถือ auth token = ใครก็ยิงจาก origin ไหนก็ได้ และถ้าวันหนึ่งเปิด allowCredentials: true preflight จะ fail ทันทีตาม CORS spec — ระบุ origin เป็น list ตรง ๆ เหมือนตัวอย่าง prod ข้างบน

🔴 ปิด introspection ใน production: introspection เปิดอยู่ = attacker query schema ทั้ง API ได้ทันที (ทุก type, ทุก mutation, ทุก field) → map attack surface ได้ง่ายมาก เปิดใน dev เพื่อใช้ GraphiQL ค้น schema สะดวก แต่ใน prod ต้องปิด

src/main/resources/graphql/schema.graphqls:

graphql
type Query {
    user(id: ID!): User
    users(limit: Int = 10, offset: Int = 0): [User!]!
}

type Mutation {
    createUser(input: CreateUserInput!): User!
    updateUser(id: ID!, input: UpdateUserInput!): User!
    deleteUser(id: ID!): Boolean!
}

type Subscription {
    orderCreated: Order!
}

type User {
    id: ID!
    name: String!
    email: String!
    age: Int
    orders(limit: Int = 5): [Order!]!
}

type Order {
    id: ID!
    user: User!
    items: [OrderItem!]!
    total: Float!
    status: OrderStatus!
}

enum OrderStatus {
    PENDING
    PAID
    SHIPPED
    DELIVERED
}

input CreateUserInput {
    name: String!
    email: String!
    age: Int
}

input UpdateUserInput {
    name: String
    email: String
    age: Int
}

Part 3: Controllers

3.1 @QueryMapping / @MutationMapping

Spring for GraphQL ผูก resolver กับ schema ผ่าน annotation คล้าย Spring MVC@QueryMapping สำหรับ query (อ่าน), @MutationMapping สำหรับ mutation (เปลี่ยนข้อมูล), @Argument ดึง argument จาก query เขียนเหมือน controller ปกติแต่ map กับ field ใน GraphQL schema Spring จับคู่ argument ให้เองโดยดูจาก ชื่อ parameter — เช่น id ใน schema.graphqls (user(id: ID!)) จะจับคู่กับ parameter ชื่อ id ใน method อัตโนมัติ ต้องตั้งชื่อให้ตรงกัน:

java
@Controller
public class UserGraphQLController {

    private final UserService userService;

    public UserGraphQLController(UserService userService) {
        this.userService = userService;
    }

    @QueryMapping
    public User user(@Argument Long id) {  // GraphQL ID! → Spring for GraphQL coerce (แปลงชนิดข้อมูลให้ตรงกันอัตโนมัติ) เป็น Long ให้
        // id: ID! เป็น non-null ใน schema — GraphQL engine ปฏิเสธ request ก่อนถึง resolver นี้ถ้า id ไม่มา ไม่ต้องเช็ค null เอง
        // ⚠️ ถ้า id จริง ๆ เป็น UUID/string (ไม่ใช่ตัวเลข) ให้ใช้ @Argument String id แทน Long — ID! ไม่ได้การันตีว่าเป็นตัวเลขเสมอไป
        return userService.findById(id);
    }

    @QueryMapping
    public List<User> users(
            @Argument Integer limit,
            @Argument Integer offset) {
        return userService.findAll(limit, offset);
    }

    @MutationMapping
    public User createUser(@Argument @Valid CreateUserInput input) {  // CreateUserInput/UpdateUserInput เป็น record — ดูนิยามใน Part 5.4
        return userService.create(input);
    }

    @MutationMapping
    public User updateUser(@Argument Long id, @Argument UpdateUserInput input) {  // UpdateUserInput เป็น record เช่นกัน — ดูนิยามใน Part 5.4; Spring map GraphQL input → record ให้อัตโนมัติ
        return userService.update(id, input);
    }

    @MutationMapping
    public Boolean deleteUser(@Argument Long id) {
        userService.delete(id);
        return true;
    }
}

3.2 @SchemaMapping (nested resolver)

field ที่ซ้อนกัน (เช่น user.orders) ไม่ควรดึงมาทุกครั้ง @SchemaMapping กำหนด resolver แยกสำหรับ nested field ที่จะถูกเรียก "เฉพาะเมื่อ client ขอ field นั้น" ทำให้ไม่ join/query สิ่งที่ไม่ได้ใช้ แต่ระวัง — วิธีนี้เปิดช่องให้เกิดปัญหา N+1 ได้ (ดูหัวข้อ DataLoader ถัดไป):

graphql
type User {
    orders(limit: Int = 5): [Order!]!
}
java
// หมายเหตุ: นี่เป็น controller คนละตัว/คนละ snippet จากข้อ 3.1 (เพื่อโฟกัสแค่ nested resolver)
// ในโค้ดจริงต้องมี constructor ฉีด OrderService เข้ามาเหมือนกับ UserService ในข้อ 3.1
@Controller
public class UserGraphQLController {

    private final OrderService orderService;

    public UserGraphQLController(OrderService orderService) {
        this.orderService = orderService;
    }

    @SchemaMapping(typeName = "User", field = "orders")
    public List<Order> orders(User user, @Argument Integer limit) {
        // user.getId() — ถ้า User เป็น class ปกติ
        // user.id()    — ถ้า User เป็น Java record (accessor ของ record ไม่มี get prefix)
        return orderService.findByUserId(user.getId(), limit);
    }
}

→ ตอน client query user { orders { ... } } → Spring เรียก method นี้

3.3 Subscription

นอกจาก query/mutation GraphQL มี subscription สำหรับ real-time — client subscribe แล้ว server push ข้อมูลผ่าน WebSocket เมื่อมี event (เช่น order ใหม่) ใน Spring ใช้ @SubscriptionMapping ที่คืน Flux (สายข้อมูลแบบ reactive ที่ส่งค่าได้หลายตัวต่อเนื่อง — ดูรายละเอียดบท 10) ข้ามหัวข้อนี้ได้ถ้ายังไม่ผ่านบท 10:

java
@Controller
public class OrderSubscriptionController {

    // Sinks.Many = ตัวช่วยสร้าง Flux ที่เราสั่ง emit ค่าเองได้จากโค้ดปกติ (ต่างจาก Flux ที่มาจาก DB query)
    private final Sinks.Many<Order> sink = Sinks.many().multicast().onBackpressureBuffer();

    public void emit(Order o) { sink.tryEmitNext(o); }

    @SubscriptionMapping
    public Flux<Order> orderCreated() {
        return sink.asFlux();
    }
}

Client ผ่าน WebSocket:

graphql
subscription { orderCreated { id total } }

ตั้ง:

yaml
spring.graphql.websocket.path: /graphql

⚠️ dependency เพิ่มเติม (ถ้าใช้ Spring MVC + starter-web): ต้องเพิ่ม spring-boot-starter-websocket ด้วย ไม่งั้น subscription จะไม่ทำงาน — ถ้าใช้ WebFlux (starter-webflux) ไม่ต้องเพิ่ม


Part 4: ปัญหา N+1 + DataLoader

4.1 ปัญหา

N+1 คือปัญหาที่ขึ้นชื่อที่สุดของ GraphQL เกิดเพราะ nested resolver ถูกเรียกแยกต่อ parent แต่ละตัว ตัวอย่าง: ถ้าดึง 100 user แล้วขอ orders ของแต่ละคน จะกลายเป็น 1 + 100 query ต้องแก้ด้วย DataLoader (หัวข้อถัดไป):

graphql
query {
  users {
    name
    orders { id }       # ← จะเรียก findByUserId ทีละ user
  }
}

ถ้ามี 100 user → query DB 101 ครั้ง (1 ค้น users + 100 ค้น orders)

4.2 DataLoader Pattern

DataLoader แก้ N+1 ด้วยการ "รวบ" request — แทนที่จะ query ทีละ user มันสะสม user id ทั้งหมดในรอบเดียวแล้วยิง query เดียว (WHERE user_id IN (...)) resolver คืน CompletableFuture (object ที่แทนผลลัพธ์ที่จะมาทีหลัง — async result) จาก loader แล้ว Spring batch ให้ ทำให้ 100 user เหลือ 2 query:

💡 @BatchMapping คืออะไร: เวอร์ชันที่ Spring ทำ DataLoader registration ให้อัตโนมัติ — ไม่ต้องยุ่งกับ DataLoader<K,V> เอง รับ List<User> ทั้งก้อนมาครั้งเดียว แล้วคืน Map ที่ key คือ user แต่ละคน, value คือ orders ของเขา

java
@Controller
public class UserGraphQLController {

    @QueryMapping
    public List<User> users() {
        return userService.findAll();
    }

    // วิธี idiomatic (แนะนำ): @BatchMapping ทำ DataLoader registration ให้อัตโนมัติ
    // Spring for GraphQL รวบ user ทั้งหมดจาก request เดียวแล้วส่งเป็น List มาให้ครั้งเดียว
    // ตัวอย่างนี้ใช้ Map (blocking) เพราะ orderService.findByUsers เรียก JPA ธรรมดา (ไม่ reactive)
    // Mono<Map<K,V>> ควรใช้เฉพาะตอนต่อ WebFlux/R2DBC แบบ reactive จริง ๆ เท่านั้น
    @BatchMapping(typeName = "User", field = "orders")
    public Map<User, List<Order>> orders(List<User> users) {
        return orderService.findByUsers(users); // Spring จัดการ batch ให้
    }
    // ⚠️ ถ้า User เป็น JPA @Entity (ไม่ใช่ record) ต้อง override equals()/hashCode() ตาม id เอง
    // ไม่งั้นการใช้ User เป็น key ใน Map ข้างบนอาจจับคู่ผิด เพราะ equals() default เทียบ object reference

}

DataLoader batch request — ส่ง user_ids เป็น set แล้ว query เดียว → 100 user = 2 query (users + orders WHERE user_id IN (...))

💡 วิธีที่ manual กว่า (ถ้าต้องการควบคุม DataLoader โดยตรง): ใช้ DataLoader<K,V> ใน @SchemaMapping + ลงทะเบียน BatchLoader ผ่าน BatchLoaderRegistry ใน @Configuration:

java
// ใน controller: inject DataLoader ผ่าน parameter
@SchemaMapping(typeName = "User", field = "orders")
public CompletableFuture<List<Order>> orders(User user,
        DataLoader<Long, List<Order>> ordersByUserLoader) {
    return ordersByUserLoader.load(user.getId());  // user.getId() หรือ user.id() ขึ้นอยู่กับว่า User เป็น class หรือ record
}

// ใน @Configuration: ลงทะเบียน batch loader
@Configuration
public class DataLoaderConfig {
    @Bean
    public BatchLoaderRegistry ordersBatchLoader(OrderService orderService, BatchLoaderRegistry registry) {
        registry.<Long, List<Order>>forName("ordersByUserLoader")
            .registerMappedBatchLoader((userIds, env) -> {
                Map<Long, List<Order>> byUser = orderService.findByUserIds(userIds);
                return Mono.just(byUser);
            });
        return registry;
    }
}

หมายเหตุ: pattern แบบ manual มีประโยชน์เมื่อ batch loader ต้องการ context พิเศษ (เช่น DataFetchingEnvironment) หรือต้อง share ข้าม controller หลายตัว แต่สำหรับกรณีทั่วไป @BatchMapping สั้นกว่าและอ่านง่ายกว่า

4.3 ใน Hibernate

ฝั่ง DB ที่ DataLoader เรียกต้องเป็น batch query จริง — เขียน repository method ที่รับ list ของ id (WHERE userId IN :ids) แล้ว group ผลตาม userId เป็น Map ให้ loader กระจายกลับไปแต่ละ user นี่คือชิ้นที่ทำให้ batch loading ทำงานได้:

java
// OrderRepository (interface)
@Query("SELECT o FROM Order o WHERE o.userId IN :userIds")
List<Order> findByUserIds(@Param("userIds") List<Long> userIds);

// OrderService
public Map<Long, List<Order>> findByUserIds(Set<Long> ids) {
    // DataLoader ส่ง Set<Long> มา → แปลงเป็น List เพื่อใช้กับ JPA query ข้างบน
    return orderRepo.findByUserIds(new ArrayList<>(ids))
        .stream()
        .collect(Collectors.groupingBy(Order::getUserId));
}

Part 5: Error Handling

5.1 Default

GraphQL ต่างจาก REST ตรงที่ error ไม่ใช้ HTTP status (รหัสสถานะ เช่น 200 = สำเร็จ, 404 = ไม่พบ, 500 = server error) response มักเป็น 200 เสมอ แต่ใส่ error ไว้ใน array errors (พร้อม path + classification) เคียงข้าง data ที่อาจเป็น null บางส่วน ต้องเข้าใจ format นี้ก่อนค่อยปรับแต่ง:

GraphQL spec ระบุ:

json
{
  "data": { "user": null },
  "errors": [{
    "message": "...",
    "path": ["user"],
    "extensions": { "classification": "INTERNAL_ERROR" }
  }]
}

5.2 Custom Exception

วิธีหนึ่งในการคืน error ที่มีความหมาย — ให้ exception implement GraphQLError พร้อมระบุ classification (NOT_FOUND, FORBIDDEN) เอง จากนั้น GraphQL จะแปลงเป็น error format ให้ client รู้ประเภทของ error ไม่ใช่แค่ message ดิบ:

java
public class UserNotFoundException extends RuntimeException
        implements GraphQLError {

    public UserNotFoundException(Long id) {
        super("User not found: " + id);
    }

    @Override
    public ErrorClassification getErrorType() {
        return ErrorType.NOT_FOUND;
    }

    @Override
    public List<SourceLocation> getLocations() { return null; }
}

5.3 ExceptionResolver

วิธีที่ดีกว่าคือจัดการ error รวมศูนย์ด้วย DataFetcherExceptionResolverAdapter — map exception ทุกชนิด (รวมที่ไม่ได้ implement GraphQLError) เป็น GraphQL error ที่ classification ถูก คล้าย @RestControllerAdvice ของ MVC ทำให้ business code ไม่ต้องรู้เรื่อง GraphQL error format:

java
@Component
public class GraphQLExceptionResolver extends DataFetcherExceptionResolverAdapter {

    @Override
    protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
        if (ex instanceof UserNotFoundException) {
            return GraphqlErrorBuilder.newError(env)
                .message(ex.getMessage())
                .errorType(ErrorType.NOT_FOUND)
                .build();
        }
        if (ex instanceof AccessDeniedException) {
            return GraphqlErrorBuilder.newError(env)
                .message("Access denied")
                .errorType(ErrorType.FORBIDDEN)
                .build();
        }
        return null;   // fallback default
    }
}

5.4 Validation

input ของ mutation ก็ validate ได้เหมือน REST — ใส่ @Valid ที่ @Argument แล้วใช้ Bean Validation annotation (@NotBlank, @Email) บน input record validation error จะถูกแปลงเป็น GraphQL error format ให้อัตโนมัติ:

⚠️ dependency ที่ต้องเพิ่ม: @Valid ต้องมี spring-boot-starter-validation ใน pom.xml ก่อน ถ้าไม่มี annotation จะเป็น no-op เงียบ ๆ (ไม่ validate, ไม่ error):

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
java
@MutationMapping
public User createUser(@Argument @Valid CreateUserInput input) {
    ...
}

public record CreateUserInput(
    @NotBlank String name,
    @Email String email,
    @Min(0) @Max(150) Integer age
) {}

→ validation error คืนเป็น GraphQL error format


Part 6: Security

⚠️ prerequisite: หัวข้อนี้ใช้ SecurityFilterChain, JWT, @PreAuthorize จากบท Security — ถ้ายังไม่ผ่านบท Security ข้ามไปก่อนได้

java
@Bean
public SecurityFilterChain graphql(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(a -> a
            // dev: permitAll; production: /graphql ควรเป็น authenticated() และ /graphiql ควรปิดไปเลย
            .requestMatchers("/graphql").authenticated()
            .requestMatchers("/graphiql").permitAll()  // เปิดแค่ dev — prod ปิดด้วย application-prod.yml
        )
        // ⚠️ ปิด CSRF ได้เฉพาะ stateless authentication (JWT/API key via Authorization header) เท่านั้น
        // ถ้าแอปใช้ session cookie ห้ามปิด CSRF หรือต้องตั้ง CsrfTokenRepository ให้ถูกต้อง
        .csrf(c -> c.disable())
        .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
    return http.build();
}

🔴 ปิด CSRF เมื่อไหร่ได้?: .csrf(c -> c.disable()) ปลอดภัยก็ต่อเมื่อ API ใช้ stateless authentication เท่านั้น (JWT Bearer token ผ่าน Authorization header) — ถ้าแอปมี session cookie อยู่ด้วย การปิด CSRF เปิดช่องโหว่ CSRF attack ทันที อย่าปิดถ้าไม่แน่ใจ

Field-level security:

java
@Controller
public class UserGraphQLController {
    
    @QueryMapping
    @PreAuthorize("hasRole('ADMIN')")
    public List<User> users() { ... }

    @SchemaMapping
    @PreAuthorize("#user.id == authentication.principal.id or hasRole('ADMIN')")
    public String email(User user) {
        return user.getEmail();
    }
}

Part 7: Performance + Production

7.1 Query Complexity

ความยืดหยุ่นของ GraphQL มาพร้อมความเสี่ยง — client เขียน query ที่ซ้อนลึกหรือกว้างมากจน server ทำงานหนัก จนกลายเป็น DoS (Denial of Service: ทำให้ server รับ request อื่นไม่ได้ โดยไม่ตั้งใจหรือตั้งใจ) ต้องจำกัดด้วย max query depth/complexity เพื่อปฏิเสธ query ที่แพงเกินก่อนรัน:

GraphQL อันตราย — client query deep ได้:

graphql
query {
  user(id: 1) {
    orders {
      user {
        orders {
          user { ... }    # วนซ้ำไม่สิ้นสุด!
        }
      }
    }
  }
}

→ DoS attack (การโจมตีให้ server ล้มเหลวด้วยการยิง query ที่แพงมาก) potential

แก้:

java
// imports ที่ต้องวางไว้บนสุดของไฟล์ (มาจาก graphql-java ที่อยู่ใน classpath อยู่แล้วผ่าน spring-boot-starter-graphql):
import graphql.analysis.MaxQueryDepthInstrumentation;
import graphql.analysis.MaxQueryComplexityInstrumentation;
import graphql.execution.instrumentation.Instrumentation;
@Bean
public Instrumentation maxDepthInstrumentation() {
    return new MaxQueryDepthInstrumentation(5);
}

@Bean
public Instrumentation maxComplexityInstrumentation() {
    return new MaxQueryComplexityInstrumentation(100);
}

⚠️ ตรวจ constructor ตาม version: graphql-java เปลี่ยน constructor ของ instrumentation พวกนี้ไปตาม version — บาง version ต้องการ FieldComplexityCalculator เพิ่มเติม ตรวจ version ของ graphql-java ที่ Spring Boot ของคุณดึงมาก่อนใช้ตัวอย่างนี้ตรง ๆ (ถ้ามี Instrumentation bean อื่นด้วย เช่น tracing/metrics, Spring Boot auto-configuration จะ chain ให้ผ่าน ChainedInstrumentation อัตโนมัติ)

7.2 Persisted Queries (APQ) — ลด network + whitelist

แทนที่ client จะส่ง query string เต็มทุกครั้ง — ส่ง SHA-256 hash (รหัสสรุปขนาดคงที่ 64 ตัวอักษร ที่แทน query string ยาว ๆ ได้) ของ query แทน extensions คือ field พิเศษใน GraphQL request body (นอกเหนือจาก query/variables) ที่ client library (เช่น Apollo Client) เติมให้อัตโนมัติ ไม่ต้องเขียนเอง:

text
[ครั้งแรก] Client → POST /graphql { query, extensions: { persistedQuery: { sha256Hash: "abc..." } } }
           Server → cache query string ภายใต้ hash → execute
[ครั้งถัดไป] Client → POST /graphql { extensions: { persistedQuery: { sha256Hash: "abc..." } } }
           Server → lookup → execute (query ไม่ต้องส่งซ้ำ)

แนวคิด APQ (Spring for GraphQL ไม่มี built-in class สำเร็จรูป — ต้อง implement เอง):

⚠️ หมายเหตุ: Spring for GraphQL 1.x/2.x ไม่มี class PersistedQuerySupport, InMemoryPersistedQuerySupport, หรือ ApqInterceptor ใน public API — ต้อง implement WebGraphQlInterceptor เอง หรือใช้ Apollo Router ที่ handle APQ ฝั่ง gateway แทน

java
// แนวทาง: implement WebGraphQlInterceptor เองเพื่อทำ APQ
@Component
public class ApqInterceptor implements WebGraphQlInterceptor {

    private final Map<String, String> queryCache = new ConcurrentHashMap<>(); // หรือ Redis

    @Override
    public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {
        // 1. ดึง hash จาก extensions.persistedQuery.sha256Hash
        // 2. ถ้ามี hash → lookup ใน cache → inject query กลับเข้า request
        // 3. ถ้าไม่มีใน cache → รับ query เต็ม + cache ไว้
        // (implement logic ตาม APQ spec: https://www.apollographql.com/docs/apollo-server/performance/apq/)
        return chain.next(request);
    }
}

ข้อดี:

  • Network payload เล็กลง 5-10 เท่า (query ยาวๆ ไม่ส่งซ้ำ)
  • Server cache execution plan ได้
  • Whitelist mode — ใน production ปิดรับ query ใหม่ → block ad-hoc query → security ดีขึ้น (ต้อง implement check ใน interceptor เอง)

Build pipeline (กระบวนการ build ของ frontend project ที่มี npm/TypeScript): npm run extract-queries → push hash map ไป Redis → server โหลดเข้า cache ตอน startup

7.3 Caching

REST cache ด้วย HTTP cache. GraphQL ทำได้ยาก (POST + body ต่างกันทุกครั้ง)

ใช้ผสม:

  • CDN persisted query (key = query hash + variables) — Cloudflare/Fastly cache ได้
  • DataLoader cache (per request) — แก้ N+1
  • Application-level cache (Caffeine/Redis) — field-level (เช่น @Cacheable บน DataLoader's batch function)

7.4 Apollo Federation / Subgraph — graph ข้าม service

ปัญหาปี 2026: บริษัทใหญ่มี 50+ microservices แต่ละบริการมี GraphQL ของตัวเอง → frontend ต้องเรียก 5 graph แยกกัน

Apollo Federation v2 = standard สำหรับรวม subgraph หลาย service เป็น supergraph เดียวให้ frontend

แต่ละ subgraph own type ของตัวเอง แต่ extend type ของ subgraph อื่นได้:

graphql
# user-service schema
type User @key(fields: "id") {
  id: ID!
  name: String!
  email: String!
}

# order-service schema — extend User
type User @key(fields: "id") {
  id: ID! @external
  orders: [Order!]!         # field นี้ resolved โดย order-service
}

type Order {
  id: ID!
  total: Float!
}

Frontend query ผ่าน Apollo Router → router วาง plan → เรียก user-service สำหรับ name/email + เรียก order-service สำหรับ orders → รวม response

Spring for GraphQL + Federation

Spring มี spring-graphql-federation (จาก Apollo) ที่เพิ่ม @key directive + _entities resolver:

xml
<dependency>
    <groupId>com.apollographql.federation</groupId>
    <artifactId>federation-graphql-java-support</artifactId>
    <!-- ตรวจ version ล่าสุดที่ https://github.com/apollographql/federation-jvm/releases
         ตัวอย่าง: 4.1.0 (ตรวจสอบ compatibility กับ graphql-java version ของ Spring Boot ที่ใช้ก่อน) -->
    <version>4.1.0</version>
</dependency>

⚠️ federation-jvm version ต้องตรงกับ graphql-java version ที่ Spring Boot ใช้อยู่ ตรวจ compatibility matrix ก่อนเสมอ

java
// Entity ที่ federation gateway จะมา resolve ผ่าน _entities
@Controller
public class UserSubgraphController {

    @QueryMapping
    public User user(@Argument Long id) { ... }
}

// federation-jvm 5.x: Entity resolver ลงทะเบียนผ่าน Federation.transform(runtimeWiring)
// TypeResolutionEnvironment ไม่ใช่ interface ที่ implement ได้ — มันเป็น object argument ที่ส่งเข้า TypeResolver
// ดูตัวอย่างที่ถูกต้องที่: https://github.com/apollographql/federation-jvm/tree/main/samples

⚠️ ระวัง: federation-graphql-java-support ไม่มี annotation ชื่อ @EntityMapping — ตัวอย่างที่อ้างถึง pattern นี้บนอินเทอร์เน็ตหลายแห่งเข้าใจผิดกับ Spring for GraphQL annotation อื่น Federation resolver ต้องลงทะเบียนผ่าน Federation.transform(schema) หรือ implement EntityResolver/TypeResolver ตาม Apollo spec อ่านโครงเต็มที่ federation-jvm README

📚 อ่านต่อ: Apollo Federation v2 spec · Spring Boot + Federation tutorial · ทางเลือก: GraphQL Mesh, Hot Chocolate Fusion (.NET — GraphQL framework สำหรับ .NET)

💡 ไม่ต้องรีบใช้ federation — ถ้ามี 2-3 service ใช้ schema stitching หรือ BFF (Backend For Frontend) ที่ aggregate ใน Spring ยังพอเอาอยู่ Federation จะคุ้มเมื่อมี 5+ subgraph และทีมหลายทีม


Part 8: Testing

java
@SpringBootTest
@AutoConfigureGraphQlTester
class UserGraphQLTest {

    @Autowired GraphQlTester tester;
    // @MockitoBean ใช้ได้เฉพาะ Spring Boot 3.4+ — ถ้าใช้เวอร์ชันต่ำกว่าให้ใช้ @MockBean แทน
    // ตรวจ version ของคุณได้ใน pom.xml: ดูที่ <parent><version>X.Y.Z</version></parent>
    // ถ้า Y (ตัวเลขหลักกลาง) น้อยกว่า 4 → ใช้ @MockBean แทน (@MockBean จะถูก deprecated ในอนาคต)
    @MockitoBean UserService userService;

    @Test
    void getUser() {
        // User เป็น record เช่น: record User(Long id, String name, String email) { ... }
        when(userService.findById(1L)).thenReturn(new User(1L, "Alice", "a@x.com"));

        tester.document("""
                query {
                  user(id: 1) {
                    name
                    email
                  }
                }
                """)
            .execute()
            .path("user.name").entity(String.class).isEqualTo("Alice");
    }
}

Part 9: GraphQL vs REST — เลือกเมื่อไหร่

GraphQL ดีเมื่อ:

  • Frontend ต่าง platform ดึงข้อมูลต่างกัน (web ต้องการเยอะ, mobile ต้องการน้อย)
  • Multiple data source ที่อยาก compose
  • Frequent UI change ที่ปรับ query ได้โดยไม่ต้อง backend
  • BFF (Backend For Frontend — server กลางที่รวมข้อมูลจากหลาย service มาให้ frontend โดยเฉพาะ) layer

GraphQL ไม่ดีเมื่อ:

  • Simple CRUD
  • File upload (มี complication)
  • ต้องการ HTTP cache เต็มที่
  • Team ใหม่ไม่เคยใช้
  • Public API (client query ลึก = อันตราย)

Part 10: Checkpoint

  1. ความต่างหลักของ GraphQL vs REST?
  2. Operation 3 ชนิดคือ?
  3. N+1 ใน GraphQL เกิดยังไง? DataLoader แก้ยังไง?
  4. Subscription ใช้ protocol อะไร?
  5. Max query depth ใช้ทำอะไร?
  6. Persisted query แก้ปัญหาอะไร?
  7. SchemaMapping ต่างกับ QueryMapping?
  8. Federation คือ?
  9. GraphQL caching ทำไมยาก?
  10. ตัดสินใจใช้ GraphQL เมื่อไหร่?

Part 11: สรุปบทนี้

  • GraphQL = client บอก shape ของ response — แก้ over/under-fetching
  • Schema-first ใน Spring for GraphQLschema.graphqls + Controller
  • @QueryMapping, @MutationMapping, @SubscriptionMapping, @SchemaMapping
  • DataLoader = แก้ N+1 ด้วย batch
  • Security: max depth/complexity, persisted queries, field-level auth
  • Not silver bullet — เลือกตาม use case

บทถัดไป (สุดท้ายของหมวด) — Native Image (GraalVM + Spring AOT)


← บทที่ 13 | สารบัญ | บทที่ 15: Native Image →