GraphQL với Spring Boot
Tích hợp Spring for GraphQL

Kiến trúc Spring for GraphQL

Giải thích các lớp chính của Spring for GraphQL và luồng xử lý một request trong Spring Boot.

Spring for GraphQL là lớp tích hợp giữa Spring Boot và GraphQL Java. Spring Boot lo việc khởi tạo ứng dụng, quản lý bean và kết nối transport; Spring for GraphQL nạp schema, đăng ký runtime wiring và đưa các handler của Spring vào GraphQL Java; GraphQL Java parse, validate, execute và hoàn tất kết quả theo schema.

Nếu chỉ nhớ một câu, hãy nhớ: schema là contract, resolver là registration, service là business boundary, còn transport chỉ là đường vận chuyển. Một annotation như @QueryMapping không tạo schema, không mở endpoint và không thay thế transaction hoặc authorization.

Phạm vi, version và trạng thái ví dụ

Các sơ đồ, SDL, JSON, cấu hình và Java trong bài là minh họa tĩnh để đọc kiến trúc. Repository này là docs-only, không có backend Spring Boot, database, broker hoặc WebSocket server để chạy kiểm chứng. Các tên API như GraphQlSource, adapter reactive, instrumentation và interceptor cần được đối chiếu với version Spring Boot/Spring for GraphQL/GraphQL Java thật trước khi sao chép. Bài này cố ý tập trung vào boundary và request lifecycle; binding chi tiết, context API và exception resolver có bài riêng.

Mục lục

Bức tranh tổng thể

Mental model một request

Một GraphQL request không đi thẳng từ HTTP vào database. Nó đi qua transport, context, schema và execution engine; sau đó field được nối vào code ứng dụng và kết quả mới được serialize về transport.

direction: down
client: Client
boot: Spring Boot application {
  transport: Transport adapter {
    http: HTTP handler
    ws: WebSocket handler
  }
  spring: Spring for GraphQL {
    context: request context / metadata
    source: GraphQlSource
    schema: schema resources + runtime wiring
    controller: annotated controller
    fetcher: DataFetcher
    controller -> fetcher
  }
  java: GraphQL Java execution {
    icon: ../../assets/d2/java.svg
    parse: parse, validate, coerce
    instrumentation: instrumentation
    fields: root và nested field execution
    complete: complete value, nullability, errors
    parse -> fields -> complete
  }
  transport -> spring -> java
}
service: Service / use case
repo: repository / gateway
data: database, API, cache, broker
result: "Execution result: data + errors + extensions"
httpResult: HTTP JSON response
wsResult: WebSocket next / error / complete
client -> boot.transport.http: "HTTP POST: query + variables + operationName"
client -> boot.transport.ws: "WebSocket: Upgrade, protocol messages, subscribe"
boot.java -> service -> repo -> data
service -> result
result -> httpResult
result -> wsResult
ClientSpring Boot applicationService / use caserepository / gatewaydatabase, API, cache, brokerExecution result: data + errors + extensionsHTTP JSON responseWebSocket next / error / completeTransport adapterSpring for GraphQLGraphQL Java executionHTTP handlerWebSocket handlerrequest context / metadataGraphQlSourceschema resources + runtime wiringannotated controllerDataFetcherparse, validate, coerceinstrumentationroot và nested field executioncomplete value, nullability, errors HTTP POST: query + variables + operationName WebSocket: Upgrade, protocol messages, subscribe
ClientSpring Boot applicationService / use caserepository / gatewaydatabase, API, cache, brokerExecution result: data + errors + extensionsHTTP JSON responseWebSocket next / error / completeTransport adapterSpring for GraphQLGraphQL Java executionHTTP handlerWebSocket handlerrequest context / metadataGraphQlSourceschema resources + runtime wiringannotated controllerDataFetcherparse, validate, coerceinstrumentationroot và nested field executioncomplete value, nullability, errors HTTP POST: query + variables + operationName WebSocket: Upgrade, protocol messages, subscribe
Spring Boot: transport, runtime và data boundary

Trong một ứng dụng Spring Boot thông thường, các lớp trên có thể cùng nằm trong một process. Sơ đồ mô tả boundary trách nhiệm, không khẳng định mỗi lớp là một service hoặc một network hop riêng.

Ba câu hỏi để đọc kiến trúc

Khi đọc code hoặc debug, hãy tách ba câu hỏi sau:

  1. Contract có cho phép operation này không? Câu trả lời nằm ở SDL, schema đã merge, field, argument, selection set và nullability.
  2. Field đã được đăng ký với code nào chưa? Câu trả lời nằm ở annotated controller, DataFetcher, runtime wiring và component scan.
  3. Code có thực hiện use case đúng và an toàn không? Câu trả lời nằm ở service, domain, transaction, repository, downstream policy và observability.

Một lỗi ở câu hỏi thứ nhất không được chữa bằng cách sửa SQL. Một lỗi ở câu hỏi thứ hai không được chữa bằng cách đổi @Transactional. Một timeout ở câu hỏi thứ ba không biến thành lỗi schema chỉ vì nó được trả trong GraphQL errors.

Các thành phần và boundary

HTTP và WebSocket transport

Transport là lớp đưa message giữa client và server. Nó không quyết định schema có field gì và cũng không tự thực thi business rule.

TransportBoundary chínhHình dạng lifecycleĐiều cần nhớ
HTTPRoute, method, headers, body JSON, statusMột request thường tạo một execution resultPOST /graphql là route minh họa, không phải tên của field hay schema.
WebSocketHTTP Upgrade, subprotocol, connection, messageMột connection có thể mang nhiều operation và nhiều result101 chỉ xác nhận Upgrade; chưa chứng minh connection_init, auth hoặc subscription thành công.

Với HTTP, request envelope thường có query, variables và operationName:

HTTP envelope — minh họa tĩnh
{
  "query": "query Books($limit: Int) { books(limit: $limit) { id title } }",
  "operationName": "Books",
  "variables": {
    "limit": 20
  }
}

Với WebSocket, operation GraphQL nằm trong payload của một protocol message. Nếu stack dùng protocol hiện đại graphql-transport-ws, message subscribe có thể có dạng:

WebSocket subscribe — minh họa tĩnh
{
  "id": "sub-1",
  "type": "subscribe",
  "payload": {
    "operationName": "OnBookAdded",
    "query": "subscription OnBookAdded($tenantId: ID!) { bookAdded(tenantId: $tenantId) { id title } }",
    "variables": {
      "tenantId": "tenant-7"
    }
  }
}

sub-1 là ID của operation trong connection, không phải ID của sách và không phải operation name. WebSocket endpoint, protocol, auth handshake, timeout và reverse proxy là các quyết định riêng. Đọc Subscription và WebSocket trước khi chọn handler hoặc công bố delivery guarantee.

Không suy ra WebSocket path từ HTTP path. Một ứng dụng có HTTP endpoint /graphql vẫn có thể dùng WebSocket path khác, hoặc không hỗ trợ WebSocket nếu chưa cấu hình web stack và handler phù hợp.

Spring Boot auto-configuration

Auto-configuration (tự cấu hình) là cơ chế Spring Boot tạo các bean mặc định khi dependency, classpath và property phù hợp. Với Spring for GraphQL, có thể hình dung startup theo chuỗi sau:

spring-boot-starter-graphql + web starterSpring Boot tạo các thành phần tích hợp cần thiếtschema resource loaderGraphQlSource builder/runtimeannotated controller configurerHTTP GraphQL handlerWebSocket handler nếu transport được bậtApplicationContext sẵn sàng nhận request
spring-boot-starter-graphql + web starterSpring Boot tạo các thành phần tích hợp cần thiếtschema resource loaderGraphQlSource builder/runtimeannotated controller configurerHTTP GraphQL handlerWebSocket handler nếu transport được bậtApplicationContext sẵn sàng nhận request
Auto-configuration khởi tạo ứng dụng

Tên bean, điều kiện tạo bean và điểm thay thế cụ thể phụ thuộc version. Ý nghĩa kiến trúc ổn định hơn: Boot cung cấp đường nối mặc định từ classpath resources và Spring beans đến GraphQL runtime. Khi ứng dụng tự định nghĩa GraphQlSource, RuntimeWiringConfigurer hoặc transport handler, cần kiểm tra điều kiện auto-configuration để không vô tình tạo hai pipeline hoặc tắt pipeline mặc định.

Auto-configuration không làm các việc sau:

  • không sinh SDL từ entity JPA;
  • không tạo resolver chỉ vì có một method Java tên books;
  • không biết service nào là owner của Book;
  • không mở WebSocket chỉ vì schema có Subscription;
  • không biến một database transaction thành transaction của toàn request.

Bài Thêm dependency và cấu hình đi sâu vào starter, schema location và endpoint HTTP. Bài này chỉ dùng chúng để đặt vị trí của Boot trong toàn pipeline.

GraphQlSource và executable schema

GraphQlSource là abstraction (lớp trừu tượng) quan trọng của Spring for GraphQL để xây dựng và cung cấp GraphQL runtime từ schema resource cùng runtime wiring. Có thể đọc nó như một điểm hợp nhất giữa:

  • SDL đã được đọc và merge;
  • type system đã được kiểm tra;
  • DataFetcher hoặc resolver đã được đăng ký;
  • instrumentation và các cấu hình execution liên quan.

Executable schema (schema có thể thực thi) không chỉ là văn bản SDL. Nó là schema GraphQL đã được parse, validate và nối với cách lấy giá trị cho field:

SDL resourcesGraphQL schemaExecutable schema / GraphQL runtimeCó thể nhận execution input và tạo execution result đọc, parse, merge, kiểm tra type system runtime wiring: type resolver, DataFetcher, scalar, instrumentation
SDL resourcesGraphQL schemaExecutable schema / GraphQL runtimeCó thể nhận execution input và tạo execution result đọc, parse, merge, kiểm tra type system runtime wiring: type resolver, DataFetcher, scalar, instrumentation
Khởi tạo executable schema

Đừng gọi GraphQlSource là database hoặc một endpoint. Nó nằm ở boundary xây runtime; HTTP/WebSocket handler mới nhận message, còn service/repository mới sở hữu dữ liệu và use case.

Khi cần custom wiring, code thật có thể dùng các extension point mà version Spring for GraphQL cung cấp. Trong tài liệu kiến trúc, chỉ nên cam kết quan hệ sau: schema resource được đưa vào source builder, runtime configurer đăng ký field, rồi GraphQL Java nhận executable schema. Tên constructor, builder method và thứ tự callback cần được kiểm tra từ reference đúng version.

Schema resources trên classpath

Spring Boot thường đọc SDL từ classpath dưới graphql/. Ở source tree, convention có dạng:

src/main/resources/
└── graphql/
    ├── catalog/
    │   └── catalog.graphqls
    ├── review/
    │   └── review.graphqls
    └── shared/
        └── common.graphqls

Đường dẫn source src/main/resources/graphql/ khác với location runtime classpath:graphql/**/. Nhiều file vẫn tạo một schema sau khi merge; folder không tạo namespace catalog.Book hay endpoint riêng cho Catalog.

Ví dụ property schema có thể dùng trong một project đã chốt convention:

application.yml — minh họa schema location
spring:
  graphql:
    schema:
      locations:
        - "classpath:graphql/**/"
      file-extensions:
        - ".graphqls"

Nếu dùng extend type Query, definition nền của Query phải nằm trong tập resource được nạp. Một extension bị bỏ sót vì location sai có thể làm startup/schema check thất bại; một extension trùng field có thể làm schema merge thất bại. Đọc Tách schema theo module để phân biệt file organization với federation và để đặt ownership của từng type/field.

Schema resource được kiểm tra lúc startup, còn query cụ thể được parse/validate lúc request. Vì vậy, “application đã khởi động” và “operation này hợp lệ” là hai bằng chứng khác nhau.

GraphQL Java execution

GraphQL Java là execution engine (bộ máy thực thi) phía dưới Spring for GraphQL. Ở mức kiến trúc, nó làm các việc sau:

  1. Parse document GraphQL thành cấu trúc thực thi.
  2. Validate field, argument, variable, selection set và type theo executable schema.
  3. Coerce (chuẩn hóa và kiểm tra) literal/variables theo kiểu GraphQL.
  4. Chọn DataFetcher cho root và nested field.
  5. Chờ hoặc kết hợp giá trị sync/async/reactive mà adapter hỗ trợ.
  6. Hoàn tất object, list, scalar, interface/union và nullability.
  7. Ghi lỗi với vị trí hoặc path, rồi tạo execution result.

GraphQL Java không phải database planner. Nó không tự biết nên dùng SQL join hay cache nào, không bảo đảm một selection set chỉ tạo một backend call và không tự sửa N+1. Việc tối ưu nằm ở resolver/service/repository/DataLoader và phải được đo trong ứng dụng thật.

GraphQL execution cũng không nên được đọc như một vòng lặp “mỗi field một lần theo đúng thứ tự file”. Query field có thể được hoàn tất độc lập; nested field, async completion và DataLoader có thể làm thứ tự quan sát được khác với thứ tự query. Không viết business logic phụ thuộc vào thứ tự gọi resolver. Với mutation, semantics GraphQL bảo đảm các top-level mutation field được thực thi tuần tự, nhưng điều đó vẫn không tạo một database transaction chung.

Instrumentation

Instrumentation là hook (điểm móc) quanh quá trình parse, validate, execution hoặc data fetching của GraphQL Java. Nó phù hợp để đo và gắn metadata, chẳng hạn:

  • thời gian parse/validation/execution;
  • operation name, query hash, depth hoặc cost;
  • thời gian của Query.books và Book.author;
  • số lần gọi DataLoader, kích thước batch;
  • lỗi theo path, timeout và downstream;
  • trace/span liên kết với request ID.

Mental model đơn giản:

request contextinstrumentation bắt đầuparse / validateexecutionroot DataFetchernested DataFetcherhoàn tất / lỗitelemetry + execution result
request contextinstrumentation bắt đầuparse / validateexecutionroot DataFetchernested DataFetcherhoàn tất / lỗitelemetry + execution result
Instrumentation quanh execution

Instrumentation không thay thế authentication hoặc authorization. Nó có thể quan sát và từ chối theo một guardrail đã thiết kế, nhưng quyền đọc Book theo tenant vẫn phải được bảo vệ ở policy/service phù hợp. Cũng không nên dùng instrumentation để chứa business rule vì nó làm rule phụ thuộc GraphQL execution và khó tái sử dụng từ REST, job hoặc message consumer.

API đăng ký instrumentation và context của từng callback có thể thay đổi theo version. Hãy kiểm tra reference trước khi tạo bean hoặc custom implementation; trong tài liệu này, Instrumentation chỉ là tên khái niệm của GraphQL Java.

DataFetcher và controller có annotation

Trong GraphQL Java, DataFetcher là thành phần được execution engine gọi để lấy giá trị của một field. Trong Spring for GraphQL, annotated controller method có thể được adapter đăng ký thành DataFetcher thông qua runtime wiring.

Một controller GraphQL thường là Spring bean có @Controller. Class phải nằm trong component scan, method phải có annotation mapping phù hợp và tên field phải khớp SDL hoặc mapping tường minh. Method đó không phải một HTTP endpoint riêng và không cần @GetMapping/@PostMapping.

Có hai đường đăng ký chính:

Cách đăng kýKhi phù hợpBoundary cần giữ
Annotated controllerRoot/nested field gắn với method Spring, cần dependency injection và argument resolverSpring adapter → DataFetcher → application service
Explicit runtime wiringDataFetcher đặc biệt, integration với library khác hoặc custom scalar/type resolverRuntime wiring → DataFetcher; business logic vẫn ở service/domain

Annotation mapping chỉ trả lời “field này gọi handler nào”. Nó không trả lời “user có quyền gì”, “transaction bao phủ gì” hoặc “repository nào là nguồn sự thật”. Đọc Query, mutation và subscription mapping khi cần syntax root mapping; đọc DataFetcher và GraphQL context khi cần API context cụ thể.

Service repository và data source

Một phân lớp thực dụng là:

GraphQL transport adapterGraphQL resolver / DataFetcherApplication service / use caseRepository / gateway portDatabase / REST / gRPC / cache / broker envelope, auth boundary, context bind input, map field, gọi use case invariant, authorization, transaction, composition abstraction cho persistence hoặc downstream
GraphQL transport adapterGraphQL resolver / DataFetcherApplication service / use caseRepository / gateway portDatabase / REST / gRPC / cache / broker envelope, auth boundary, context bind input, map field, gọi use case invariant, authorization, transaction, composition abstraction cho persistence hoặc downstream
Các boundary xử lý request
  • Service/application layer thực hiện use case và quy tắc nghiệp vụ. Đây là nơi phù hợp cho invariant, authorization theo resource, idempotency và transaction boundary.
  • Repository che chi tiết truy cập dữ liệu bền vững. Nó không nên quyết định field nào được công khai trong GraphQL.
  • Gateway gọi hệ thống bên ngoài và sở hữu timeout, retry, circuit breaker hoặc mapping lỗi ở boundary đó.
  • Data source là nguồn dữ liệu rộng hơn, có thể là database, API, cache, message store hoặc bộ nhớ.

Một GraphQL field có thể được tổng hợp từ nhiều data source. Điều đó không biến chúng thành một transaction và cũng không làm downstream biết schema GraphQL. Nếu một field chậm hoặc bị lỗi, owner của field phải biết service và nguồn dữ liệu nào chịu trách nhiệm.

Response và error

Sau execution, GraphQL tạo execution result. Response thường có:

  • data: shape theo selection set của client;
  • errors: lỗi parse/validation/execution/serialization theo policy;
  • extensions: metadata tùy implementation, chẳng hạn error code, request ID hoặc telemetry đã chọn lọc.

Một response có cả data và errors là hợp lệ khi có partial data. Ví dụ dưới đây chỉ là minh họa tĩnh:

Execution result — minh họa tĩnh
{
  "data": {
    "books": [
      {
        "id": "book-1",
        "title": "Effective Java"
      }
    ],
    "featuredBook": null
  },
  "errors": [
    {
      "message": "Không thể đọc sách nổi bật.",
      "path": ["featuredBook"],
      "extensions": {
        "code": "UPSTREAM_UNAVAILABLE",
        "requestId": "request-example-123"
      }
    }
  ]
}

HTTP status thuộc transport, còn data/errors thuộc GraphQL execution. HTTP 200 không chứng minh mọi field thành công; ngược lại, lỗi 404 hoặc 401 có thể xảy ra trước khi có execution result. Với WebSocket, next mang execution result của một event, còn error/complete thuộc lifecycle operation. Đọc Mô hình request/response và Xử lý lỗi trong mutation để phân biệt các lớp lỗi sâu hơn.

Schema-first và resolver registration

SDL là contract

Schema-first nghĩa là team chốt contract bằng SDL trước khi chọn Java class, resolver hoặc database. SDL khai báo root operation, type, field, argument, input/output và nullability:

type Query {
  books(limit: Int = 20): [Book!]!
}

SDL không khai báo:

  • method Java nào sẽ được gọi;
  • table hoặc API nào là nguồn dữ liệu;
  • transaction kéo dài bao lâu;
  • user nào được đọc field;
  • field có được tối ưu bằng DataLoader hay không.

Schema hợp lệ chỉ chứng minh contract có thể được parse/validate. Để execution trả dữ liệu, runtime wiring phải có DataFetcher phù hợp hoặc object source có property fetcher mặc định phù hợp.

Registration là wiring

Resolver registration (đăng ký resolver) là bước nối cặp parent type + field trong schema với code thực thi. Ví dụ, Query.books có thể được đăng ký bởi @QueryMapping, explicit DataFetcher hoặc cơ chế khác của runtime wiring.

SDL: Query.books: [Book!]!DataFetchermethod controllerCatalogService.listBooks() runtime wiring
SDL: Query.books: [Book!]!DataFetchermethod controllerCatalogService.listBooks() runtime wiring
Runtime wiring nối SDL tới controller

Nếu thêm method books() nhưng SDL không có Query.books, client vẫn không thể query field đó. Nếu SDL có Query.books nhưng không có registration hoặc default fetcher phù hợp, operation có thể hợp lệ ở validation nhưng thất bại/nhận giá trị không mong đợi ở execution. Đừng dùng annotation để “sửa” một contract chưa được review.

Schema resources có thể chia theo module và được merge thành một graph; resolver Java cũng có thể chia theo nhiều controller. Folder hoặc class name không tạo namespace GraphQL. Bài SDL và nguyên tắc thiết kế schema giải thích ownership và contract-first; bài Tách schema theo module giải thích type và extend type.

Một field một owner một registration

Một nguyên tắc vận hành hữu ích là mỗi field public có:

  1. một owner chịu trách nhiệm về semantics, quyền, chi phí và vòng đời;
  2. một contract SDL canonical trong schema hợp nhất;
  3. một registration chính cho parent type + field.

Hai controller khác class nhưng cùng đăng ký Query.books không tạo overload GraphQL. Thứ tự component scan không phải policy chọn handler đáng tin cậy; mapping collision nên được sửa bằng cách chọn một owner hoặc đổi contract có chủ đích.

Tương tự, hai file SDL cùng khai báo type Book hoặc cùng thêm Query.books không tạo catalog.Book và review.Book. Chúng tạo duplicate definition/field trong một graph. Ownership phải được review ở cả SDL và Java wiring.

Ví dụ xuyên suốt với catalog sách

Các ví dụ trong phần này cùng dùng catalog sách. Chúng là code minh họa tĩnh, không phải source code đang tồn tại trong repository.

SDL contract

schema.graphqls — minh họa tĩnh
schema {
  query: Query
  mutation: Mutation
  subscription: Subscription
}

type Query {
  book(id: ID!): Book
  books(limit: Int = 20): [Book!]!
}

type Mutation {
  createBook(input: CreateBookInput!): CreateBookPayload!
}

type Subscription {
  bookAdded(tenantId: ID!): Book!
}

input CreateBookInput {
  tenantId: ID!
  title: String!
  authorId: ID!
}

type CreateBookPayload {
  book: Book
  userErrors: [UserError!]!
}

type UserError {
  code: String!
  message: String!
  inputPath: [String!]!
}

type Book {
  id: ID!
  tenantId: ID!
  title: String!
  author: Author!
}

type Author {
  id: ID!
  displayName: String!
}

Có thể đọc ownership của SDL như sau:

  • Query.book và Query.books là root field của Catalog.
  • Book.author là nested field; nó có thể đọc từ property có sẵn hoặc cần resolver riêng.
  • Mutation.createBook công khai outcome CreateBookPayload; transaction vẫn thuộc service.
  • Subscription.bookAdded công khai stream; schema không tự tạo event source hoặc WebSocket.
  • [UserError!]! cam kết dùng list rỗng cho không có lỗi và không có phần tử lỗi null trong outcome hợp lệ.

Cấu hình runtime

Endpoint HTTP phải dùng key theo dòng Spring Boot của project:

Dòng Spring BootProperty endpoint HTTP
3.5+spring.graphql.http.path
3.4 trở xuốngspring.graphql.path

Cấu hình minh họa cho Boot 3.5+:

application.yml — Boot 3.5+
spring:
  graphql:
    http:
      path: /graphql
    schema:
      locations:
        - "classpath:graphql/**/"
      file-extensions:
        - ".graphqls"

Với Boot 3.4 trở xuống, thay riêng phần endpoint bằng:

application.yml — Boot 3.4 trở xuống
spring:
  graphql:
    path: /graphql

Không cấu hình đồng thời hai key endpoint cho cùng project. spring.graphql.http.path/spring.graphql.path chỉ nói về HTTP; không suy ra WebSocket path từ đó. WebSocket path, dependency và protocol phải được kiểm tra theo web stack/version, như phần Subscription và WebSocket đã lưu ý.

Controller service và repository

Java dưới đây chỉ là pseudo-code có chủ đích: tên kiểu, parameter resolver và return adapter cần được đối chiếu trước khi chạy.

GraphQL adapter — pseudo-code minh họa tĩnh
@Controller
final class CatalogGraphQlController {

    private final CatalogService catalogService;

    CatalogGraphQlController(CatalogService catalogService) {
        this.catalogService = catalogService;
    }

    @QueryMapping
    List<BookView> books(@Argument("limit") Integer limit,
                         RequestActor actor) {
        return catalogService.listBooks(actor, limit);
    }

    @QueryMapping
    BookView book(@Argument("id") String id,
                  RequestActor actor) {
        return catalogService.findVisibleBook(actor, id);
    }

    @MutationMapping
    CreateBookPayload createBook(
            @Argument("input") CreateBookInput input,
            RequestActor actor) {
        return catalogService.createBook(actor, input);
    }

    @SubscriptionMapping
    Publisher<BookView> bookAdded(
            @Argument("tenantId") String tenantId,
            RequestActor actor) {
        return catalogService.authorizedBookEvents(actor, tenantId);
    }
}
Nested field — pseudo-code minh họa tĩnh
@Controller
final class BookFieldController {

    private final AuthorService authorService;

    BookFieldController(AuthorService authorService) {
        this.authorService = authorService;
    }

    @SchemaMapping(typeName = "Book", field = "author")
    AuthorView author(BookView source, RequestActor actor) {
        return authorService.findVisibleAuthor(actor, source.authorId());
    }
}
Application và data boundary — pseudo-code minh họa tĩnh
@Service
final class CatalogService {

    @Transactional
    CreateBookPayload createBook(RequestActor actor, CreateBookInput input) {
        // Business validation, authorization, invariant và transaction.
        // Repository chỉ được gọi sau khi policy của use case đã rõ.
        return createBookUseCase.execute(actor, input);
    }
}

interface CatalogRepository {
    List<BookRow> findBooksFor(RequestActor actor, int limit);
}

Các tên BookView, RequestActor, AuthorService, Publisher, CreateBookInput và createBookUseCase là kiểu đại diện trong ví dụ. RequestActor biểu diễn principal/tenant đã được server xác thực, không phải giá trị được tin trực tiếp từ variables. @Transactional ở đây chỉ minh họa vị trí application service; nó không bao phủ mặc định mọi lời gọi async, nested resolver hoặc downstream HTTP.

Đọc Binding arguments và input để đi sâu vào @Argument và input binder, Schema mapping và nested field để đi sâu vào source object, và Context, interceptor và request lifecycle khi cần chọn hook Spring cụ thể.

Request lifecycle từ transport tới serialization

Luồng sau dùng query HTTP làm trục chính. WebSocket có thêm connection/protocol lifecycle nhưng mỗi subscribe vẫn đi qua parse, validation, execution và serialization tương tự.

HTTP/WebSocket boundaryrequest envelope + trusted metadataparsevalidatecoerce variablesinstrumentation / guardrailsroot DataFetchernested DataFetcherservice/use caserepository/gatewaydata sourcecomplete type + nullability + errorsserialize JSON hoặc protocol message
HTTP/WebSocket boundaryrequest envelope + trusted metadataparsevalidatecoerce variablesinstrumentation / guardrailsroot DataFetchernested DataFetcherservice/use caserepository/gatewaydata sourcecomplete type + nullability + errorsserialize JSON hoặc protocol message
Pipeline HTTP và WebSocket

Bước 0 xây runtime lúc startup

Trước khi có request, Spring Boot và Spring for GraphQL thường thực hiện chuỗi khái niệm sau:

  1. Tạo ApplicationContext và các bean từ component scan/auto-configuration.
  2. Tìm schema resources trên classpath theo location và extension.
  3. Parse, merge và kiểm tra SDL thành GraphQL schema.
  4. Chạy runtime wiring configurer, gồm annotated controller discovery, explicit DataFetcher, scalar/type resolver và instrumentation nếu project đăng ký.
  5. Tạo GraphQL runtime thông qua GraphQlSource hoặc cấu hình tương đương.
  6. Đăng ký HTTP handler; đăng ký WebSocket handler chỉ khi transport và version/configuration hỗ trợ.

Có thể hình dung:

classpath SDL + Spring beansGraphQlSource / runtime builderGraphQL schemaDataFetcher registrationinstrumentationtransport adaptersserver ready
classpath SDL + Spring beansGraphQlSource / runtime builderGraphQL schemaDataFetcher registrationinstrumentationtransport adaptersserver ready
Runtime builder tạo các thành phần tích hợp

Lỗi parse SDL, duplicate type/field, thiếu type reference hoặc runtime wiring collision thường cần được xử lý ở startup/build schema. Lỗi data source, authorization resource hoặc timeout chỉ xuất hiện khi execution chạm nhánh tương ứng. Không dùng “startup thành công” làm bằng chứng mọi field đã được kiểm thử.

Bước 1 transport nhận request

HTTP

  1. Client gửi request đến URL đã cấu hình, thường bằng POST và Content-Type: application/json.
  2. HTTP layer kiểm tra route, method, body size, JSON syntax, TLS, CORS/rate limit và các filter/security policy.
  3. Spring for GraphQL lấy document, variables, operation name và metadata để tạo input cho execution.

WebSocket

  1. Client thực hiện HTTP Upgrade; proxy phải chuyển tiếp Upgrade headers.
  2. Server thương lượng subprotocol, nếu stack yêu cầu.
  3. Protocol handshake như connection_init/connection_ack xác nhận metadata/authentication.
  4. Mỗi message subscribe tạo một GraphQL execution; một connection có thể có nhiều operation.

Nếu request bị từ chối ở route, body JSON, TLS, CORS hoặc authentication biên, resolver chưa chạy. Đừng tìm lỗi trong @QueryMapping trước khi xác nhận request đã tới đúng handler. Mô hình request/response giải thích request envelope và HTTP status; Kiểm thử request bằng curl/Postman có checklist request HTTP.

Bước 2 tạo context và metadata

Server tạo metadata đáng tin cậy cho lần execution, thường gồm:

  • principal hoặc Authentication sau khi credential được kiểm tra;
  • tenant đã được suy ra từ principal/policy;
  • correlation ID, trace ID và operation name;
  • locale, deadline, feature flag hoặc request budget;
  • DataLoader registry theo execution;
  • thông tin connection/operation đối với WebSocket.

Metadata đi theo flow như sau:

HTTP headers / WebSocket handshakeSpring Security + transport/interceptorGraphQL execution contextresolver đọc principal/tenant/request IDservice nhận dữ liệu typed cần thiếtinstrumentation nối trace/metric xác thực và làm sạch
HTTP headers / WebSocket handshakeSpring Security + transport/interceptorGraphQL execution contextresolver đọc principal/tenant/request IDservice nhận dữ liệu typed cần thiếtinstrumentation nối trace/metric xác thực và làm sạch
Trusted context thay cho raw header

Context nên chứa metadata request và các capability có kiểm soát, không phải toàn bộ domain state. Nếu service cần actor, truyền một kiểu ứng dụng như RequestActor thay vì truyền HttpServletRequest hoặc DataFetchingEnvironment xuống domain. Chi tiết GraphQLContext, @ContextValue và local context phụ thuộc API version; xem DataFetcher và GraphQL context và Context, interceptor và request lifecycle.

Với WebSocket, context của connection không nên được hiểu là giấy phép vĩnh viễn cho mọi operation. Mỗi subscribe cần kiểm tra argument/selection theo principal hiện tại; stream dài hạn có thể cần xử lý token expiry hoặc quyền bị thu hồi.

Bước 3 parse validate và coerce

GraphQL Java parse document rồi validate với executable schema. Các kiểm tra thường gồm:

  • field có tồn tại trên type hiện tại không;
  • object/list object có selection set không;
  • scalar có bị mở selection set không;
  • argument và variable có tên, kiểu, default và nullability tương thích không;
  • input object/enum có hợp lệ không;
  • operation name có chọn đúng operation không.

Sau đó, GraphQL coerce giá trị variables/literal theo kiểu SDL. "20" không tự trở thành Int chỉ vì Java method nhận Integer; tenantId đúng kiểu cũng không chứng minh caller có quyền với tenant đó.

parse documentvalidate schemacoerce variablesoperation hợp lệ: bắt đầu executionerrors, chưa vào resolvererrors, chưa vào resolvererrors, chưa thực hiện use case cú pháp hợp lệ lỗi cú pháp field/selection/argument hợp lệ field/selection/argument sai kiểu/nullability hợp lệ kiểu/nullability sai
parse documentvalidate schemacoerce variablesoperation hợp lệ: bắt đầu executionerrors, chưa vào resolvererrors, chưa vào resolvererrors, chưa thực hiện use case cú pháp hợp lệ lỗi cú pháp field/selection/argument hợp lệ field/selection/argument sai kiểu/nullability hợp lệ kiểu/nullability sai
Request error dừng trước use case

Structural validation (kiểm tra cấu trúc) thuộc schema/execution boundary. Business validation như “limit không vượt quá 100”, “ID thuộc tenant” hoặc “trạng thái cho phép update” thuộc policy/service. Không làm schema quá nullable chỉ để che lỗi domain, và không biến mọi business rule thành custom GraphQL directive nếu service cũng cần bảo vệ chúng.

Bước 4 instrumentation bao quanh execution

Khi operation đã được chọn, instrumentation có thể ghi nhận hoặc áp dụng guardrail theo cấu hình. Một pipeline quan sát thường theo dõi:

operationName + query hash + request IDparse/validation timingroot field timingnested field timingservice/downstream timing + DataLoader batchresult status, GraphQL errors, response timing
operationName + query hash + request IDparse/validation timingroot field timingnested field timingservice/downstream timing + DataLoader batchresult status, GraphQL errors, response timing
Liên kết timing theo request

Query depth/complexity, document size, persisted-operation allow-list hoặc timeout có thể được đặt ở gateway, transport, validation policy hoặc instrumentation tùy implementation. Hãy đặt guardrail càng sớm càng tốt nhưng vẫn giữ authorization ở service/field boundary.

Instrumentation phải tránh side effect nghiệp vụ. Một callback đo thời gian không được tự ghi database; một metric không được trở thành nguồn duy nhất để quyết định user có được xem resource hay không. Khi có nhiều instrumentation, thứ tự callback và API context cần được kiểm tra theo GraphQL Java version.

Để phân biệt thứ tự logic với thời gian thực hiện, waterfall dưới đây dùng số tổng hợp minh họa, không phải benchmark hoặc trace đã đo. Tất cả span dùng cùng mốc 0 ms. Span Query.books bao gồm việc đọc sách; sau đó hai nested field có thể chồng lấn. Không cộng các duration chồng lấn để suy ra latency toàn request.

Timing minh họa của query catalogTrace minh họa · 60 ms
Chi tiết span

Chi tiết span

POST /graphql, query Catalog graphql-api · Chưa xác định · 60 ms
ID
request
Cha
Không có (root)
Bắt đầu
0 ms
Kết thúc
60 ms
{
  "operationName": "Catalog",
  "demo": true
}
parse, validate, coerce graphql-java · Chưa xác định · 4 ms
ID
parse
Cha
request
Bắt đầu
1 ms
Kết thúc
5 ms

Không khai báo attributes.

Query.books catalog · Chưa xác định · 20 ms
ID
books
Cha
request
Bắt đầu
6 ms
Kết thúc
26 ms

Không khai báo attributes.

Read visible books repository · Chưa xác định · 16 ms
ID
db
Cha
books
Bắt đầu
8 ms
Kết thúc
24 ms

Không khai báo attributes.

Book.author, batch lookup author · Chưa xác định · 18 ms
ID
author
Cha
request
Bắt đầu
27 ms
Kết thúc
45 ms
{
  "batch.size": 2
}
Book.reviews review · Chưa xác định · 25 ms
ID
reviews
Cha
request
Bắt đầu
28 ms
Kết thúc
53 ms

Không khai báo attributes.

Complete và serialize response graphql-api · Chưa xác định · 5 ms
ID
complete
Cha
request
Bắt đầu
54 ms
Kết thúc
59 ms

Không khai báo attributes.

Bảng thời gian đầy đủ
Timing minh họa của query catalog · ms từ mốc 0
Span / IDChaServiceBắt đầuThời lượngKết thúcTrạng thái
POST /graphql, query Catalogrequest—graphql-api0 ms60 ms60 msChưa xác định
parse, validate, coerceparserequestgraphql-java1 ms4 ms5 msChưa xác định
Query.booksbooksrequestcatalog6 ms20 ms26 msChưa xác định
Read visible booksdbbooksrepository8 ms16 ms24 msChưa xác định
Book.author, batch lookupauthorrequestauthor27 ms18 ms45 msChưa xác định
Book.reviewsreviewsrequestreview28 ms25 ms53 msChưa xác định
Complete và serialize responsecompleterequestgraphql-api54 ms5 ms59 msChưa xác định
Về trace

Số tổng hợp để đọc duration và overlap; không đo backend, không tự suy ra critical path.

Thời gian tương đối từ mốc 0; không cộng thời lượng các span chồng nhau. Quan hệ cha/con không khẳng định thứ tự nhân quả.

  • graphql-api
  • graphql-java
  • catalog
  • repository
  • author
  • review

Book.author và Book.reviews cùng nằm trong request nhưng không có quan hệ cha–con với nhau. Ở ứng dụng thật, hãy đối chiếu trace context, DataLoader dispatch và backend call count trước khi kết luận nút thắt nằm ở đâu.

Bước 5 gọi root và nested DataFetcher

Execution engine bắt đầu từ root operation:

Query.books(limit: 20)@QueryMapping books(...)CatalogService.listBooks(...)List<BookView>Book.idBook.titleBook.authorproperty/default fetcher@SchemaMapping hoặc DataLoader
Query.books(limit: 20)@QueryMapping books(...)CatalogService.listBooks(...)List<BookView>Book.idBook.titleBook.authorproperty/default fetcher@SchemaMapping hoặc DataLoader
Query trả DTO, nested mapping lấy author

Root field Query.books không có source object Book; nó nhận arguments/context. Khi kết quả là Book, nested field Book.author mới có parent/source object. Nhầm hai vị trí này là nguyên nhân phổ biến của mapping sai và N+1.

Execution chỉ hoàn tất field trong selection set. Java object có thêm property không làm field tự xuất hiện trong response, còn selection set có field không được schema khai báo sẽ bị chặn ở validation. Query, selection set và alias giải thích response shape; Viết query đầu tiên cho flow nhập môn từ SDL đến resolver.

Bước 6 đi qua service và data source

Resolver nên chuyển yêu cầu đã bind và metadata cần thiết vào use case. Một flow đọc có thể là:

DataFetcher Query.bookCatalogService.findVisibleBook(actor, id)authorization resource/tenantcache policy hoặc read modelCatalogRepository.findById(...)database / API downstream
DataFetcher Query.bookCatalogService.findVisibleBook(actor, id)authorization resource/tenantcache policy hoặc read modelCatalogRepository.findById(...)database / API downstream
Query use case áp policy trước repository

Một flow ghi có thể là:

DataFetcher Mutation.createBookCatalogService.createBook(actor, input)validation và invarianttransaction databaseoutbox/event sau commit nếu cầnpayload hoặc domain exception sau commit, nếu cần
DataFetcher Mutation.createBookCatalogService.createBook(actor, input)validation và invarianttransaction databaseoutbox/event sau commit nếu cầnpayload hoặc domain exception sau commit, nếu cần
Mutation use case sở hữu invariant và transaction

Một request GraphQL có thể gọi nhiều service và data source. Không đánh giá hiệu năng chỉ bằng số HTTP request từ client. Hãy đo resolver count, downstream call count, batch size, DB query count, latency và payload size.

Bước 7 hoàn tất giá trị và lỗi

GraphQL Java hoàn tất giá trị theo kiểu SDL:

  • object được đi vào selection set con;
  • list được hoàn tất từng phần tử;
  • scalar/custom scalar được serialize theo type resolver;
  • interface/union cần xác định concrete type;
  • ! được kiểm tra và có thể làm lỗi bubble lên parent.

Bốn nhóm lỗi cần phân biệt:

Lớp lỗiResolver field đã chạy?Tín hiệu thường thấy
TransportThường chưa404, 401, timeout kết nối, không có GraphQL body
Parse/validation/coercionChưa vào field bị lỗiTop-level errors, thường không có data
Business/domainCóPayload typed như userErrors, hoặc domain exception theo policy
Execution/serializationCó thể cóerrors.path, partial data, null bubbling

Đừng bắt mọi exception rồi trả null. Nếu field là nullable, việc đó có thể che outage; nếu field là non-null, GraphQL vẫn có thể tạo error và bubble. Error mapping nên đưa code/message an toàn ra client, còn stack trace, SQL và chi tiết downstream ở log/trace nội bộ. Xử lý lỗi tập trung dành cho implementation error boundary.

Bước 8 serialize và trả response

Sau khi execution hoàn tất, Spring for GraphQL adapter chuyển execution result thành response của transport:

Java value / CompletionStage / Publisher itemGraphQL completion + scalar serializationExecutionResult: data/errors/extensionsHTTP adapterWebSocket adapterJSON body + HTTP status/header policynext/error/complete message
Java value / CompletionStage / Publisher itemGraphQL completion + scalar serializationExecutionResult: data/errors/extensionsHTTP adapterWebSocket adapterJSON body + HTTP status/header policynext/error/complete message
Completion trước transport serialization

Serialization xảy ra sau khi resolver/service có thể đã kết thúc transaction. Vì thế, không nên trả entity JPA còn lazy relation chưa được materialize rồi kỳ vọng session luôn còn mở; ưu tiên DTO/projection hoặc boundary transaction/read model rõ ràng.

JSON response chỉ có field được chọn. HTTP status vẫn do adapter/security/gateway policy quyết định. Một client đúng phải đọc status, body data, top-level errors và metadata đã được tài liệu hóa; không dùng status 200 làm tín hiệu thành công duy nhất.

Lifecycle subscription qua WebSocket

Subscription có hai lifecycle lồng nhau:

HTTP UpgradeWebSocket connectionsubscribe operation@SubscriptionMapping tạo Publisherevent 1event 2execute selection, event 1execute selection, event 2next, event 1next, event 2Publisher errorerror theo policycompletecomplete operationclient/socket cancelcleanup event source connection_init; authentication; connection_ack parse/validate/coerce + authorization
HTTP UpgradeWebSocket connectionsubscribe operation@SubscriptionMapping tạo Publisherevent 1event 2execute selection, event 1execute selection, event 2next, event 1next, event 2Publisher errorerror theo policycompletecomplete operationclient/socket cancelcleanup event source connection_init; authentication; connection_ack parse/validate/coerce + authorization
Subscription từ handshake tới cleanup

@SubscriptionMapping chỉ tạo điểm nối tới Publisher; nó không tạo broker, không mở WebSocket, không đảm bảo fan-out, replay hoặc backpressure. Event source phải có policy cho tenant, ordering, duplicate, subscriber chậm và shutdown.

Khi một event làm nested resolver lỗi, server cần quyết định giữ stream, gửi execution error trong result tiếp theo hay kết thúc operation. Khi token bị thu hồi, cần hủy stream và giải phóng consumer theo security policy. Đọc Subscription và WebSocket và Subscription WebSocket khi deploy để đi sâu vào protocol, proxy và cleanup.

Context metadata và nơi đặt policy

Context không phải input của client

Variables là dữ liệu client gửi; context là metadata server tạo hoặc kiểm soát. Hai thứ có thể cùng xuất hiện trong một resolver nhưng không có cùng mức độ tin cậy:

variables.tenantIdsecurity principal / claimsinput cần kiểm traapplication authorization
variables.tenantIdsecurity principal / claimsinput cần kiểm traapplication authorization
Tenant input phải đối chiếu principal

Không cấp quyền chỉ vì client gửi tenantId: "tenant-7", userId của chính mình hoặc một key trong extensions. Server phải lấy principal từ credential đã xác minh, áp policy membership/resource và truyền kết quả xuống service. Context cũng không nên là một Map toàn cục dùng chung giữa request; DataLoader, request ID và deadline phải có scope rõ.

Context có thể gồm:

  • request-scoped metadata: request ID, trace ID, locale, deadline;
  • security metadata: principal, authorities, tenant scope;
  • execution helpers: DataLoader registry, request cache, budget;
  • connection metadata: WebSocket connection ID, protocol state, generation.

Với async/reactive, đừng dựa vào ThreadLocal để truyền mọi metadata. Hãy dùng context propagation của stack hoặc truyền một value typed ở boundary đã chọn. Nếu dùng WebSocket, phân biệt context cấp connection với context cấp từng execution.

Authentication và authorization

Authentication trả lời “request thuộc về ai”; authorization trả lời “principal đó được làm gì và đọc gì”. Chúng nên được đặt ở các boundary khác nhau:

Điểm đặtTrách nhiệm phù hợpKhông nên ủy thác toàn bộ
Gateway/HTTP/WebSocket handshakeTLS, route, credential extraction, rate limit, auth sớmBusiness authorization của từng resource
Security/interceptorXác thực token/session, tạo principalTin mọi variables là scope hợp lệ
GraphQL validation/field policyOperation allow-list, field policy, query costThay thế invariant trong service
Resolver adapterChuyển context/actor vào use case, field check nhẹ nếu cầnSao chép business rule ở nhiều controller
Service/domainAuthorization theo resource/tenant, invariant, state transitionPhụ thuộc trực tiếp vào HTTP/GraphQL API
Repository/gatewayDefense in depth, tenant filter, downstream credentialQuyết định public schema

Một query nhỏ vẫn có thể đọc dữ liệu trái quyền. Tắt introspection hoặc ẩn field khỏi GraphiQL không phải authorization. Đọc Authentication với JWT/OAuth2 và Authorization theo field và resolver khi triển khai security cụ thể.

Validation và guardrail

Tách validation theo thời điểm giúp đặt đúng owner:

  1. Transport validation: route, method, body size, JSON, protocol, origin và credential format.
  2. GraphQL structural validation: parse document, schema field, selection set, variable, argument, input, enum và nullability.
  3. Query guardrail: depth, complexity, tổng node, page size, persisted operation, timeout và rate limit.
  4. Business validation: title không trắng, state transition hợp lệ, resource thuộc tenant, quota và invariant.
  5. Data validation: constraint database, response downstream, scalar serialization và nullability output.

Complexity là ước lượng chi phí CPU/I/O/memory hoặc số node; nó không tự thay thế pagination hay authorization. limit: 20 đúng kiểu vẫn có thể gây tải lớn nếu nested field tạo nhiều downstream call. Đặt giới hạn ở schema/policy/service và đo thực tế bằng instrumentation. Đọc Giới hạn độ sâu và complexity khi cần thiết kế guardrail.

Observability

GraphQL thường dùng một endpoint chung, nên chỉ log URL /graphql là không đủ. Một telemetry tối thiểu nên nối được:

request ID / trace IDoperationName + query hashGraphQL executionresolver parent.field timingDataLoader batch size / cache hitservice / downstream timingerrors.path + error coderesponse / WebSocket operation lifecycle
request ID / trace IDoperationName + query hashGraphQL executionresolver parent.field timingDataLoader batch size / cache hitservice / downstream timingerrors.path + error coderesponse / WebSocket operation lifecycle
Telemetry gắn cùng request và operation

Nên theo dõi:

  • request count, duration, validation reject và timeout theo operation/hash;
  • resolver timing theo parentType.field, không đưa raw query text vào metric label;
  • database query count, downstream latency, retry/circuit-breaker và batch size;
  • error code/path, partial data, null bubbling và serialization failure;
  • active WebSocket connections/operations, event lag, queue depth, cancel và cleanup.

Không log access token, toàn bộ variables chứa PII (thông tin nhận dạng cá nhân), payload connection_init hoặc event nhạy cảm. Query hash/operation name có thể giúp nhóm metrics nhưng không thay thế redaction. Logging và tracing là bài nối tiếp cho observability; phần này chỉ đặt vị trí của nó trong lifecycle.

Sync async reactive và thread model

Ba kiểu hoàn tất giá trị

Resolver có thể hoàn tất theo nhiều hình thức. Tên return type và adapter được hỗ trợ cần được đối chiếu version; bảng này mô tả semantics:

Hình thứcSemanticsCạm bẫy
Giá trị đồng bộMethod trả BookView, List<BookView> hoặc scalar; execution nhận kết quả ngay theo callLời gọi blocking giữ thread đang thực thi
Một kết quả bất đồng bộCompletionStage/CompletableFuture hoàn tất một giá trị sauAsync chỉ có ích nếu công việc thật sự chạy trên executor phù hợp
Reactive một kết quảMono<BookView> hoặc adapter tương đươngReactive wrapper không làm code blocking bên trong thành non-blocking
Reactive streamPublisher<BookView>/Flux<BookView> phát nhiều itemCần cancellation, backpressure, cleanup và transport phù hợp

Pseudo-code minh họa:

Sync/async/reactive — pseudo-code minh họa tĩnh
@QueryMapping
BookView book(...) {
    return service.find(...);                 // sync
}

@QueryMapping
CompletionStage<BookView> bookAsync(...) {
    return service.findAsync(...);            // one async result
}

@QueryMapping
Mono<BookView> bookReactive(...) {
    return service.findReactive(...);         // one reactive result
}

@SubscriptionMapping
Publisher<BookView> bookAdded(...) {
    return eventSource.forTenant(...);        // many results
}

Không nên dùng Publisher để che một method trả list blocking hoặc một event source không có lifecycle. Với subscription, Publisher phải phản ứng với cancel, error và complete; với query, reactive return vẫn cần timeout và giới hạn tài nguyên.

Async không tự biến blocking thành non-blocking

Ví dụ sai về mặt ý tưởng:

// Không phải non-blocking: lời gọi database đã block trước khi future được tạo.
return CompletableFuture.completedFuture(jdbcRepository.findById(id));

Bọc một kết quả đã lấy bằng completedFuture, Mono.just hoặc một wrapper tương tự không di chuyển lời gọi blocking sang thread khác. Nếu dùng Spring WebFlux, JDBC/JPA/blocking HTTP client trên event-loop có thể làm nghẽn nhiều request khác. Các lựa chọn thường là:

  • dùng stack servlet/MVC nếu use case chủ yếu blocking;
  • dùng reactive driver/client xuyên suốt chain nếu cần non-blocking;
  • đẩy phần blocking có chủ đích sang bounded executor phù hợp và giới hạn queue;
  • đo thread utilization, queue wait, downstream latency và timeout;
  • không tạo một thread mới vô hạn cho mỗi field hoặc subscription.

Ngược lại, dùng supplyAsync với executor không giới hạn cũng có thể làm cạn thread/memory. Async là quyết định về scheduler, backpressure và resource budget, không phải chỉ đổi return type.

Bài Async và reactive được dành cho trade-off implementation. Bài này chỉ cần giữ nguyên tắc: tính chất blocking/non-blocking của toàn chuỗi quan trọng hơn kiểu wrapper ở controller.

Thread và context propagation

Không nên giả định:

  • mọi field chạy trên cùng một thread;
  • thread-local security context tự xuất hiện trong mọi callback;
  • transaction thread-bound tự kéo dài qua CompletionStage/Publisher;
  • một Flux tự truyền backpressure xuyên proxy và browser.

Hãy truyền hoặc khôi phục context bằng cơ chế mà web stack/reactive stack cung cấp, đặt deadline ở boundary và kiểm tra cancellation. Với subscriber chậm, cần queue bounded, drop/coalesce/disconnect hoặc durable replay theo contract; buffer vô hạn không phải failure policy.

Nếu một execution cần giữ snapshot nhất quán, hãy thiết kế read model hoặc transaction scope rõ ràng. Đừng dựa vào thứ tự resolver hoặc một ThreadLocal tình cờ còn tồn tại.

Root mapping và nested mapping

Root mapping

Root mapping nối field dưới Query, Mutation hoặc Subscription với handler:

SDL fieldAnnotation thường dùngÝ nghĩa
Query.books@QueryMappingĐọc root field; không có parent Book
Mutation.createBook@MutationMappingGọi use case thay đổi trạng thái
Subscription.bookAdded@SubscriptionMappingTạo/nhận Publisher cho event stream

Các annotation root-specific là shortcut có ngữ nghĩa rõ cho schema mapping. Về ý tưởng, @QueryMapping nhắm root type Query, @MutationMapping nhắm Mutation, còn @SubscriptionMapping nhắm Subscription. Nếu root type trong SDL đã đổi tên, mapping tường minh với @SchemaMapping có thể cần thiết; shortcut không nên được giả định là tự suy ra tên custom.

Operation name và alias của client không được dùng để tìm method. Resolver map theo field schema, ví dụ Query.books, không theo query BooksPage hay alias items: books. Đọc Query, mutation và subscription mapping để xem collision, argument và return type chi tiết.

Nested mapping

Nested mapping nối field của output type, ví dụ Book.author, sau khi root resolver đã tạo Book:

Query.bookBookViewBook.id / Book.titleBook.authorproperty/default fetcher@SchemaMapping hoặc DataFetcher riêngAuthorView trả BookView nhận source BookView
Query.bookBookViewBook.id / Book.titleBook.authorproperty/default fetcher@SchemaMapping hoặc DataFetcher riêngAuthorView trả BookView nhận source BookView
Source BookView của nested author

@SchemaMapping(typeName = "Book", field = "author") có ý nghĩa khác @QueryMapping: nó không nhắm root Query. Chỉ thêm nested mapping khi cần gọi service, authorization, tính toán, batch loading hoặc chuyển đổi có chủ đích; field scalar đơn giản có thể dùng property fetcher mặc định nếu DTO phù hợp.

Root mapping và nested mapping cùng nằm trong executable schema, nhưng có source và ownership khác nhau. Root resolver không nên nhận Book parent chỉ vì kết quả cuối cùng là Book; nested resolver mới nhận source object. Bài Schema mapping và nested field dành cho chi tiết này.

Subscription mapping không bật transport

@SubscriptionMapping chỉ đăng ký handler cho root subscription field. Để client thật sự nhận event còn cần:

Subscription hoạt độngSubscription SDL@SubscriptionMappingPublisherWebSocket/SSE (Server-Sent Events, luồng server đẩy một chiều)/HTTP streaming transportprotocol + auth + proxy + event source cần cần trả về cần cần
Subscription hoạt độngSubscription SDL@SubscriptionMappingPublisherWebSocket/SSE (Server-Sent Events, luồng server đẩy một chiều)/HTTP streaming transportprotocol + auth + proxy + event source cần cần trả về cần cần
Các phần bắt buộc của subscription

Vì vậy, test mapping thành công không chứng minh WebSocket endpoint đã mở. Cần test handshake, protocol subscribe, event next, error, complete, reconnect và cancellation ở transport integration boundary.

Business boundary và dependency direction

Ownership của từng lớp

Ownership (quyền sở hữu trách nhiệm) không đồng nghĩa với việc ai tạo file. Owner là người chịu trách nhiệm cuối cùng khi semantics, dữ liệu, quyền, latency hoặc compatibility của một field gặp vấn đề.

LớpSở hữu chínhKhông nên âm thầm sở hữu
Schema/SDL ownerType, field, input/output, nullability, mô tả, evolutionSQL, thread pool hoặc credential runtime
Transport/platformRoute, protocol, TLS, body limit, connection, status policyQuy tắc “sách nào được hiển thị”
GraphQL adapterMapping, binding, context bridge, response/error adapterInvariant phải đúng ngoài GraphQL
Application serviceUse case, authorization resource, invariant, transaction, idempotencyHTTP envelope và field syntax
Repository/gatewayPersistence/downstream access, timeout và mapping nguồnPublic schema và selection set
Data source ownerAvailability, consistency, capacity, event semanticsTự mở public GraphQL field
Observability/platformTrace, metric, redaction, alerting và SLOQuyết định nghiệp vụ thay domain owner

Một team có thể gộp nhiều lớp trong project nhỏ. Điều cần giữ là ownership vẫn được gọi tên. Nếu Book.reviews gọi Review service, owner phải biết chi phí nested field, policy quyền và failure mode của Review service.

Hướng phụ thuộc

Hướng phụ thuộc nên đi từ adapter vào application port, rồi tới infrastructure; domain không phụ thuộc GraphQL:

HTTP / WebSocket adapterGraphQL adapter: schema mapping, context, error mappingApplication use case / service interfacedomain model / policyrepository or gateway portJPA / JDBC / HTTP client / broker adapter
HTTP / WebSocket adapterGraphQL adapter: schema mapping, context, error mappingApplication use case / service interfacedomain model / policyrepository or gateway portJPA / JDBC / HTTP client / broker adapter
Adapter không sở hữu domain policy

GraphQL adapter có thể phụ thuộc vào service. Service không nên nhận DataFetchingEnvironment, HttpServletRequest hoặc protocol WebSocket làm dependency bắt buộc. Nếu service cần actor, tenant hoặc deadline, định nghĩa value/port ở application boundary.

Hướng phụ thuộc này giúp cùng một use case được gọi từ GraphQL, REST, job hoặc message consumer. Nó cũng làm test service không cần dựng ApplicationContext hoặc một GraphQL document. Schema có thể tham chiếu type domain theo graph, nhưng điều đó không buộc Java module phải tạo dependency vòng vào implementation nội bộ của nhau.

Transaction boundary

type Mutation và @MutationMapping không tự mở transaction. Transaction là boundary của use case, thường nằm ở application service khi use case cần atomicity trong phạm vi transaction manager đã chọn.

Các câu hỏi cần trả lời:

  • Transaction bao phủ database/resource nào?
  • DTO đã được materialize trước khi transaction kết thúc chưa?
  • Event có phát trước hay sau commit?
  • Downstream HTTP/broker có được rollback không? Thông thường không.
  • Timeout sau commit được client xử lý thế nào?
  • Retry có idempotency hoặc cách kiểm tra trạng thái không?

Một mutation ghi database rồi gửi message thường cần outbox hoặc workflow/compensation nếu event không được phép mất. Không nói mutation “atomic” chỉ vì nó nằm dưới root Mutation.

Với query JPA, nested resolver chạy sau service root có thể chạm lazy relation khi transaction đã đóng. Ưu tiên projection/DTO, fetch plan hoặc DataLoader có transaction/batch boundary rõ thay vì dựa mơ hồ vào Open Session in View. Với reactive transaction, transaction context phải đi xuyên reactive chain và dùng reactive transaction manager phù hợp; @Transactional imperative không tự bao phủ mọi Publisher.

Nếu một request chứa nhiều root query field, không mặc định coi chúng nằm trong một transaction chung. Nếu cần consistency toàn màn hình, tạo một read use case/snapshot có chủ đích. Với top-level mutation field thực thi tuần tự, vẫn phải đặt mỗi use case và transaction boundary rõ; tuần tự không đồng nghĩa rollback chung.

DataLoader N+1 và failure isolation

Vì sao một request có thể thành N+1

GraphQL cho phép client chọn nested field, nhưng selection set không tự tối ưu data source. Với query:

query BookDetails {
  books(limit: 20) {
    id
    title
    author {
      id
      displayName
    }
  }
}

Implementation ngây thơ có thể tạo:

1 query lấy 20 books
+ 1 query lấy author của book-1
+ 1 query lấy author của book-2
+ ...
+ 1 query lấy author của book-20
= 21 query (N + 1)

Đó là một HTTP request nhưng nhiều backend calls. Alias, fragment hoặc việc dùng GraphQL không tự giảm số query.

DataLoader theo execution

DataLoader là cơ chế gom các key được yêu cầu trong cùng execution thành batch và thường cache trong phạm vi execution đó. Flow khái niệm:

Book.author của nhiều parentload(tenant-7, author-1)load(tenant-7, author-2)load(tenant-7, author-3)repository.findAuthorsByKeys([key1, key2, key3])map kết quả theo đúng keytừng nested field dispatch batch dispatch batch dispatch batch
Book.author của nhiều parentload(tenant-7, author-1)load(tenant-7, author-2)load(tenant-7, author-3)repository.findAuthorsByKeys([key1, key2, key3])map kết quả theo đúng keytừng nested field dispatch batch dispatch batch dispatch batch
Gom load theo execution rồi trả đúng key

Một batch loader production cần:

  • registry mới theo mỗi GraphQL execution/request, không dùng global cache giữa user/tenant;
  • key chứa đủ scope bảo mật, chẳng hạn (tenantId, authorId) khi ID không toàn cục;
  • giữ thứ tự/mapping key → result đúng contract của loader;
  • xử lý key không tìm thấy, duplicate key và lỗi từng phần;
  • giới hạn batch size, timeout và memory;
  • ghi metric batch size, cache hit, miss và backend call;
  • không bỏ qua authorization chỉ vì nhiều key được gom chung.

DataLoader giảm N+1 nhưng không thay thế query complexity, pagination, transaction hoặc policy quyền. Với subscription, scope DataLoader và context phải được xác định cho từng execution/event; không giữ registry của một client trong cache toàn process.

Bài N+1 query và Batch loading và DataLoader sẽ đi sâu hơn khi các trang đó được áp dụng vào data layer thật.

Cô lập lỗi và partial data

Failure isolation (cô lập lỗi) nghĩa là lỗi ở một downstream/field không tự làm mất mọi dữ liệu nếu contract cho phép giữ các nhánh độc lập. Có thể áp dụng các cơ chế sau:

  • timeout riêng cho từng downstream hoặc field có chi phí cao;
  • bulkhead (ngăn tài nguyên riêng) cho pool/consumer quan trọng;
  • circuit breaker/rate limit ở gateway phù hợp;
  • fallback/cache chỉ khi semantics cho phép và được đánh dấu rõ;
  • payload typed cho business outcome dự kiến;
  • exception resolver và redaction cho execution failure;
  • cancellation/cleanup cho async và subscription.

Nullability quyết định phạm vi dữ liệu bị mất. Nếu Book.author: Author! lỗi, lỗi có thể bubble lên Book hoặc list tùy wrapper; nếu field là nullable, các field sibling có thể còn lại. Không biến mọi lỗi thành null im lặng để “giữ partial data”; client cần biết dữ liệu đang thiếu vì không có record hay vì upstream unavailable.

nested field lỗifield nullablecó thể giữ parent/sibling + errors.pathfield non-nullnull bubbling lên parent gần nhất nullablepartial data nhỏ hơn hoặc data = null
nested field lỗifield nullablecó thể giữ parent/sibling + errors.pathfield non-nullnull bubbling lên parent gần nhất nullablepartial data nhỏ hơn hoặc data = null
Nullable và non-null có failure boundary khác nhau

Với subscription, failure isolation còn phải quyết định lỗi làm mất một event, kết thúc operation hay đóng connection. Cần gắn event ID/sequence nếu client phải catch-up sau reconnect. Đọc Xử lý lỗi tập trung và Subscription và WebSocket để nối error contract với lifecycle cụ thể.

Version boundary và các điểm cần kiểm tra

Spring Boot quản lý version tương thích của Spring for GraphQL và GraphQL Java qua parent/BOM trong project thông thường. Không tự override một version graphql-java hoặc spring-graphql chỉ để làm snippet biên dịch nếu chưa kiểm tra compatibility.

Trước khi áp dụng một ví dụ, xác nhận:

  • dòng Spring Boot và JDK;
  • web stack MVC hay WebFlux;
  • property schema location và file extension;
  • property endpoint HTTP đúng dòng version;
  • annotation/package mapping và argument resolver;
  • return adapter cho CompletionStage, Mono, Flux, Publisher;
  • cách đăng ký GraphQlSource, runtime wiring và instrumentation;
  • DataFetcherExceptionResolver/subscription exception hook nếu cần;
  • WebSocket handler, path, subprotocol và reverse proxy;
  • behavior của context propagation, DataLoader và transaction manager.

Bảng endpoint cần giữ nhất quán trong nhóm tài liệu:

Version Spring BootKey HTTP endpoint
3.5+spring.graphql.http.path
3.4 trở xuốngspring.graphql.path

Giá trị /graphql chỉ là ví dụ khi project cấu hình path đó. spring.graphql.graphiql.enabled nếu được dùng chỉ bật UI phát triển; GraphiQL không phải GraphQL endpoint khác. WebSocket path không được suy ra từ hai key HTTP trên. Khi property không bind hoặc handler không tạo, xem configuration metadata/reference đúng version thay vì đoán.

Debug và test checklist

Debug từ ngoài vào trong

Khi request không chạy, giữ một request có thể tái lập với variables/headers đã redaction rồi đi theo thứ tự:

  1. Transport: URL, port, context path, HTTP method, Content-Type, TLS, CORS, status và response body. Với WebSocket, kiểm tra 101, negotiated subprotocol, connection_init và connection_ack.
  2. Schema resource: startup log, classpath location, extension, SDL parse/merge, root type và field. Kiểm tra introspection hoặc schema snapshot trong backend thật.
  3. Operation validation: operationName, query document, selection set, variables, argument name, enum/input và nullability. Lỗi Cannot query field thường chưa vào resolver.
  4. Registration: @Controller, component scan, annotation đúng root/nested, mapping name và duplicate registration. Log entry ở handler nếu cần; không log secret.
  5. Context/security: principal, tenant scope, request ID, deadline và DataLoader registry có được tạo đúng scope không. Không dùng variables làm bằng chứng quyền.
  6. Service/data source: invariant, transaction, query count, downstream URL/policy, timeout, retry và circuit state. Đọc trace thay vì đoán từ response shape.
  7. Completion/error: errors.path, extensions.code, nullability, null bubbling, serialization và HTTP status. Phân biệt data một phần với payload userErrors.
  8. Async/subscription: thread pool, reactive signal, backpressure, cancel, event source, broker lag và cleanup. Nếu reconnect, kiểm tra duplicate event và consumer cũ.

Bảng định vị nhanh:

Dấu hiệuNghi ngờ đầu tiênBằng chứng
404, 405, connection refusedRoute/transport/propertyURL, method, port, proxy log
Cannot query fieldSchema/query validationSDL, introspection, operation
Handler không được gọiComponent scan/mappingstartup log, mapping registration
data có null và errors.pathExecution/data source/nullabilitytrace, service log, schema wrapper
Query chậmN+1/complexity/downstream/blockingresolver timing, DB calls, batch, thread
Chỉ WebSocket lỗiUpgrade/protocol/auth/cleanupNetwork handshake, protocol message, proxy
Memory tăng sau disconnectQueue/Publisher/consumer leakactive operation, queue, cancel, cleanup metric

Test theo từng lớp

Đừng dùng một request thành công để kết luận toàn kiến trúc đúng. Bộ test production nên có các lớp sau:

Lớp testMục tiêuVí dụ kiểm tra
SDL/schema contractContract và merge đúngRoot, field, input/output, nullability, duplicate, extend type
Operation validationQuery của client còn hợp lệVariables, selection set, operation name, persisted operation
Service/domain unitBusiness rule độc lập GraphQLAuthorization, invariant, idempotency, error outcome
Repository/gateway integrationNguồn dữ liệu và policy nguồnQuery plan, timeout, tenant filter, mapping, constraint
GraphQL mapping/sliceController được đăng ký đúngRoot/nested mapping, argument, data/errors, nullability
Full HTTP integrationBoot + schema + handler thậtEndpoint, auth filter, serialization, status/body
WebSocket integrationConnection và streamUpgrade, protocol, ACK, subscribe, next, error, complete, cancel
Failure/performanceProduction behaviorDownstream timeout, partial data, N+1, batch, backpressure, shutdown

Trong backend thật, @GraphQlTest/GraphQlTester có thể là lựa chọn phù hợp cho mapping slice tùy version. Hãy kiểm tra test slice, bean filter và API của version đang dùng; một slice test không chứng minh transaction, broker, reverse proxy hoặc WebSocket deployment đúng. Test full context hoặc integration test mới kiểm tra các boundary đó.

Subscription cần test riêng resolver, Publisher và transport:

  • event source phát event đúng tenant/principal;
  • Publisher nhận complete, error và cancel;
  • socket đóng giải phóng consumer/queue/registry;
  • subscriber chậm tuân policy bounded/drop/coalesce/disconnect;
  • reconnect không tạo stream cũ song song hoặc duplicate không xử lý;
  • token expiry/revocation kết thúc đúng operation/connection;
  • multi-instance nhận event theo fan-out/replay contract.

Checklist review production

  • SDL được review trước controller; schema resources hợp nhất thành một graph hợp lệ.
  • Mỗi type/field có owner, source dữ liệu, chi phí và policy quyền rõ.
  • GraphQlSource/runtime wiring không bị cấu hình trùng với auto-configuration ngoài chủ ý.
  • @QueryMapping, @MutationMapping, @SubscriptionMapping chỉ map root; nested field dùng mapping phù hợp.
  • Không nhầm operation name/alias với field schema hoặc method resolver.
  • HTTP endpoint dùng đúng property: Boot 3.5+ là spring.graphql.http.path, Boot 3.4 trở xuống là spring.graphql.path.
  • HTTP và WebSocket có route, auth, timeout, protocol và test riêng; annotation subscription không bị coi là enablement.
  • Context được server tạo từ credential đã xác minh; variables không được dùng trực tiếp làm authorization.
  • Structural validation, query complexity, business validation và data validation có owner riêng.
  • Resolver mỏng ở boundary; invariant, transaction, idempotency và resource authorization nằm ở service/domain.
  • Blocking call không chạy vô tình trên event-loop; executor/bounded queue/timeout đã được đo.
  • Transaction trả DTO/projection đã materialize; lazy loading không phụ thuộc vào session sau resolver.
  • DataLoader registry là request/execution-scoped, key có tenant scope và batch có giới hạn.
  • N+1 được đo bằng backend call/query count, không chỉ đo GraphQL duration.
  • Downstream có timeout, retry/circuit/bulkhead và error mapping phù hợp; không nuốt exception thành null.
  • Client đọc data, errors, path, metadata và HTTP status; code không parse message tự do.
  • Instrumentation có operation/hash, resolver/downstream timing, batch, error và subscription metrics với redaction.
  • Test có positive, negative, partial data, null bubbling, timeout, cancellation, reconnect và shutdown.
  • Mọi API/property trong code đã được kiểm tra theo Spring Boot/Spring for GraphQL version thật.

Bước tiếp theo

On this page

Mục lụcBức tranh tổng thểMental model một requestBa câu hỏi để đọc kiến trúcCác thành phần và boundaryHTTP và WebSocket transportSpring Boot auto-configurationGraphQlSource và executable schemaSchema resources trên classpathGraphQL Java executionInstrumentationDataFetcher và controller có annotationService repository và data sourceResponse và errorSchema-first và resolver registrationSDL là contractRegistration là wiringMột field một owner một registrationVí dụ xuyên suốt với catalog sáchSDL contractCấu hình runtimeController service và repositoryRequest lifecycle từ transport tới serializationBước 0 xây runtime lúc startupBước 1 transport nhận requestBước 2 tạo context và metadataBước 3 parse validate và coerceBước 4 instrumentation bao quanh executionBước 5 gọi root và nested DataFetcherBước 6 đi qua service và data sourceBước 7 hoàn tất giá trị và lỗiBước 8 serialize và trả responseLifecycle subscription qua WebSocketContext metadata và nơi đặt policyContext không phải input của clientAuthentication và authorizationValidation và guardrailObservabilitySync async reactive và thread modelBa kiểu hoàn tất giá trịAsync không tự biến blocking thành non-blockingThread và context propagationRoot mapping và nested mappingRoot mappingNested mappingSubscription mapping không bật transportBusiness boundary và dependency directionOwnership của từng lớpHướng phụ thuộcTransaction boundaryDataLoader N+1 và failure isolationVì sao một request có thể thành N+1DataLoader theo executionCô lập lỗi và partial dataVersion boundary và các điểm cần kiểm traDebug và test checklistDebug từ ngoài vào trongTest theo từng lớpChecklist review productionBước tiếp theo