実践データベース設計:生産管理システム 研究 4 - GraphQL サービスの実装¶
はじめに¶
本研究では、API サーバー構成(第32章)とは異なるアプローチとして、GraphQL による生産管理システムを実装します。クライアントが必要なデータを正確に指定できる柔軟なクエリと、リアルタイム更新を実現する Subscription を活用します。
第32章で構築したヘキサゴナルアーキテクチャ(ドメイン層・アプリケーション層)はそのまま共有し、Input Adapter として GraphQL リゾルバ層のみを追加します。
第46章:GraphQL サーバーの基礎¶
46.1 GraphQL とは¶
GraphQL は Facebook が開発したクエリ言語および実行エンジンです。クライアントが必要なデータの形状を指定でき、Over-fetching や Under-fetching を防ぎます。
REST API / gRPC との比較:
| 特徴 | REST API | gRPC | GraphQL |
|---|---|---|---|
| プロトコル | HTTP/1.1 | HTTP/2 | HTTP/1.1 or HTTP/2 |
| データ形式 | JSON | Protocol Buffers | JSON |
| スキーマ | OpenAPI (任意) | .proto (必須) | .graphqls (必須) |
| データ取得 | 固定レスポンス | 固定レスポンス | クライアント指定 |
| エンドポイント | 複数 | 複数 | 単一 |
| リアルタイム | WebSocket 別実装 | ストリーミング | Subscription |
| 主な用途 | 汎用 API | マイクロサービス | フロントエンド向け |
46.2 3つの操作タイプ¶
GraphQL は 3 つの操作タイプをサポートします:
用途:
- Query: データ取得(品目一覧、BOM 展開)
- Mutation: データ更新(品目登録、発注確定)
- Subscription: リアルタイム通知(MRP 進捗、在庫変動)
46.3 GraphQL におけるヘキサゴナルアーキテクチャ¶
GraphQL を導入しても、第32章で構築したヘキサゴナルアーキテクチャ(ドメイン層・アプリケーション層)はそのまま共有し、Input Adapter として GraphQL リゾルバ層のみを追加します。
GraphQL でもヘキサゴナルアーキテクチャを維持する理由:
- 再利用性: 既存の UseCase/Repository をそのまま活用
- 並行運用: REST API、gRPC、GraphQL を同時提供可能
- テスト容易性: ドメインロジックは通信プロトコルに依存しない
- 移行容易性: 段階的に API 形式を追加・変更可能
46.4 ディレクトリ構成¶
既存の構成に infrastructure/graphql/ を追加するだけです。
src/main/java/com/example/production/
├── domain/ # ドメイン層(API版と共通)
│ ├── model/
│ │ ├── item/
│ │ ├── bom/
│ │ ├── order/
│ │ └── inventory/
│ └── exception/
│
├── application/ # アプリケーション層(API版と共通)
│ ├── port/
│ │ ├── in/ # Input Port(ユースケース)
│ │ └── out/ # Output Port(リポジトリ)
│ └── service/
│
├── infrastructure/
│ ├── persistence/ # Output Adapter(DB実装)- 既存
│ │ ├── mapper/
│ │ └── repository/
│ ├── rest/ # Input Adapter(REST実装)- 既存
│ ├── grpc/ # Input Adapter(gRPC実装)- 既存
│ └── graphql/ # Input Adapter(GraphQL実装)- 新規追加
│ ├── resolver/ # Query/Mutation リゾルバ
│ ├── dataloader/ # N+1 問題対策
│ ├── scalar/ # カスタムスカラー型
│ └── subscription/ # Subscription ハンドラ
│
├── config/
│
└── src/main/resources/
└── graphql/ # GraphQL スキーマ定義
├── schema.graphqls
├── item.graphqls
├── bom.graphqls
├── purchase_order.graphqls
└── mrp.graphqls
46.5 技術スタックの追加¶
既存の build.gradle.kts に GraphQL 関連の依存関係を追加します。
build.gradle.kts(差分)
dependencies {
// 既存の依存関係(Spring Boot, MyBatis, PostgreSQL等)はそのまま
// GraphQL 関連を追加
implementation("org.springframework.boot:spring-boot-starter-graphql")
implementation("org.springframework.boot:spring-boot-starter-websocket") // Subscription 用
// GraphQL 拡張
implementation("com.graphql-java:graphql-java-extended-scalars:21.0")
// Test
testImplementation("org.springframework.graphql:spring-graphql-test")
}
追加パッケージの説明:
| パッケージ | 用途 |
|---|---|
| spring-boot-starter-graphql | Spring Boot GraphQL 統合 |
| spring-boot-starter-websocket | Subscription (WebSocket) |
| graphql-java-extended-scalars | DateTime, BigDecimal 等のスカラー型 |
| spring-graphql-test | GraphQL テストサポート |
42.6 GraphQL スキーマ定義¶
src/main/resources/graphql/schema.graphqls
# ルートスキーマ
type Query {
# 品目
item(itemCode: ID!): Item
items(category: ItemCategory, page: Int, size: Int): ItemConnection!
# BOM
bomTree(itemCode: ID!): BomNode
whereUsed(itemCode: ID!): [WhereUsedResult!]!
# 発注
purchaseOrder(orderNumber: ID!): PurchaseOrder
purchaseOrders(status: OrderStatus, page: Int, size: Int): PurchaseOrderConnection!
}
type Mutation {
# 品目
createItem(input: CreateItemInput!): Item!
updateItem(input: UpdateItemInput!): Item!
deleteItem(itemCode: ID!): Boolean!
# 発注
createPurchaseOrder(input: CreatePurchaseOrderInput!): PurchaseOrder!
confirmPurchaseOrder(orderNumber: ID!): PurchaseOrder!
cancelPurchaseOrder(orderNumber: ID!): Boolean!
recordReceiving(input: RecordReceivingInput!): ReceivingResult!
# MRP
executeMrp(input: ExecuteMrpInput!): MrpResult!
}
type Subscription {
# MRP 進捗
mrpProgress(executionId: ID!): MrpProgress!
# 発注ステータス変更
orderStatusChanged(orderNumber: ID): PurchaseOrder!
# 在庫変動
stockChanged(itemCode: ID): StockChange!
}
# ページネーション
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
totalElements: Int!
totalPages: Int!
}
src/main/resources/graphql/item.graphqls
# 品目区分
enum ItemCategory {
PRODUCT # 製品
SEMI_PRODUCT # 半製品
PART # 部品
MATERIAL # 材料
}
# 品目
type Item {
itemCode: ID!
effectiveDate: Date!
itemName: String!
category: ItemCategory!
itemGroupCode: String
unitCode: String
locationCode: String
leadTime: Int
safetyStock: Int
# 関連データ(必要な場合のみ取得)
bom: [BomEntry!]!
stock: Stock
purchaseOrders: [PurchaseOrder!]!
}
# 品目一覧(ページネーション付き)
type ItemConnection {
edges: [ItemEdge!]!
pageInfo: PageInfo!
}
type ItemEdge {
node: Item!
cursor: String!
}
# 入力型
input CreateItemInput {
itemCode: ID!
itemName: String!
category: ItemCategory!
itemGroupCode: String
unitCode: String
locationCode: String
leadTime: Int
safetyStock: Int
}
input UpdateItemInput {
itemCode: ID!
itemName: String
category: ItemCategory
leadTime: Int
safetyStock: Int
}
# カスタムスカラー
scalar Date
scalar DateTime
scalar BigDecimal
src/main/resources/graphql/bom.graphqls
# BOM エントリ
type BomEntry {
parentItemCode: ID!
childItemCode: ID!
requiredQuantity: BigDecimal!
effectiveFrom: Date!
effectiveTo: Date
# 子品目の詳細
childItem: Item!
}
# BOM ツリーノード
type BomNode {
itemCode: ID!
itemName: String!
requiredQuantity: BigDecimal!
level: Int!
children: [BomNode!]!
}
# 逆展開(使用先照会)
type WhereUsedResult {
parentItemCode: ID!
itemName: String!
requiredQuantity: BigDecimal!
level: Int!
}
src/main/resources/graphql/purchase_order.graphqls
# 発注ステータス
enum OrderStatus {
DRAFT
CONFIRMED
CANCELLED
COMPLETED
}
# 発注
type PurchaseOrder {
orderNumber: ID!
supplierCode: String!
orderDate: Date!
status: OrderStatus!
totalAmount: BigDecimal!
details: [PurchaseOrderDetail!]!
# 関連データ
supplier: Supplier
}
type PurchaseOrderDetail {
lineNumber: Int!
itemCode: ID!
orderQuantity: BigDecimal!
unitPrice: BigDecimal!
deliveryDate: Date!
receivedQuantity: BigDecimal!
# 関連データ
item: Item!
}
type PurchaseOrderConnection {
edges: [PurchaseOrderEdge!]!
pageInfo: PageInfo!
}
type PurchaseOrderEdge {
node: PurchaseOrder!
cursor: String!
}
# 入力型
input CreatePurchaseOrderInput {
supplierCode: String!
details: [CreateOrderDetailInput!]!
}
input CreateOrderDetailInput {
itemCode: ID!
orderQuantity: BigDecimal!
unitPrice: BigDecimal!
deliveryDate: Date!
}
input RecordReceivingInput {
orderNumber: ID!
lineNumber: Int!
receivedQuantity: BigDecimal!
receivingDate: Date!
}
type ReceivingResult {
orderNumber: ID!
lineNumber: Int!
totalReceived: BigDecimal!
isCompleted: Boolean!
}
src/main/resources/graphql/mrp.graphqls
# MRP 入力
input ExecuteMrpInput {
startDate: Date!
endDate: Date!
}
# MRP 結果
type MrpResult {
executionId: ID!
periodStart: Date!
periodEnd: Date!
plannedOrders: [PlannedOrder!]!
shortageItems: [ShortageItem!]!
statistics: MrpStatistics!
}
type PlannedOrder {
itemCode: ID!
itemName: String!
quantity: BigDecimal!
dueDate: Date!
orderType: String!
}
type ShortageItem {
itemCode: ID!
itemName: String!
shortageQuantity: BigDecimal!
recommendedOrderDate: Date!
}
type MrpStatistics {
totalItemsProcessed: Int!
purchaseOrderCount: Int!
productionOrderCount: Int!
shortageItemCount: Int!
executionTimeMs: Long!
}
# MRP 進捗(Subscription)
type MrpProgress {
executionId: ID!
phase: String!
current: Int!
total: Int!
message: String!
}
scalar Long
42.7 API サーバー版との Controller の違い¶
API サーバー版(REST Controller)
@RestController // JSON を返す
@RequestMapping("/api/items")
public class ItemApiController {
private final ItemUseCase itemUseCase; // 共有
@GetMapping("/{itemCode}")
public ResponseEntity<ItemResponse> getItem(@PathVariable String itemCode) {
Item item = itemUseCase.getItemByCode(itemCode);
return ResponseEntity.ok(ItemResponse.from(item));
}
}
GraphQL 版(QueryResolver)
@Controller // Spring for GraphQL
public class ItemResolver {
private final ItemUseCase itemUseCase; // 共有(同じインスタンス)
@QueryMapping
public Item item(@Argument String itemCode) {
// 同じ UseCase を呼び出し
return itemUseCase.getItemByCode(itemCode);
}
}
共通点と相違点:
| 観点 | REST Controller | GraphQL Resolver |
|---|---|---|
| UseCase | 共有(同一インスタンス) | 共有(同一インスタンス) |
| アノテーション | @RestController |
@Controller |
| メソッド | @GetMapping 等 |
@QueryMapping / @MutationMapping |
| 引数 | @PathVariable / @RequestBody |
@Argument |
| レスポンス | DTO に変換 | ドメインモデル直接(推奨) |
| データ形式 | 固定 JSON | クライアント指定 |
第47章:マスタ API の実装¶
47.1 カスタムスカラーの実装¶
ScalarConfig.java
package com.example.production.infrastructure.graphql.scalar;
import graphql.scalars.ExtendedScalars;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.graphql.execution.RuntimeWiringConfigurer;
/**
* カスタムスカラー設定
*/
@Configuration
public class ScalarConfig {
@Bean
public RuntimeWiringConfigurer runtimeWiringConfigurer() {
return wiringBuilder -> wiringBuilder
.scalar(ExtendedScalars.Date)
.scalar(ExtendedScalars.DateTime)
.scalar(ExtendedScalars.GraphQLBigDecimal)
.scalar(ExtendedScalars.GraphQLLong);
}
}
47.2 品目 GraphQL リゾルバの TDD 実装¶
Red: 失敗するテストを書く¶
ItemResolverTest.java
package com.example.production.infrastructure.graphql.resolver;
import com.example.production.domain.model.item.Item;
import com.example.production.domain.model.item.ItemCategory;
import com.example.production.infrastructure.persistence.mapper.ItemMapper;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.graphql.tester.AutoConfigureGraphQlTester;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.graphql.test.tester.GraphQlTester;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.*;
@SpringBootTest
@AutoConfigureGraphQlTester
@Testcontainers
@DisplayName("品目 GraphQL リゾルバ")
class ItemResolverTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("production_test")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private GraphQlTester graphQlTester;
@Autowired
private ItemMapper itemMapper;
@BeforeEach
void setUp() {
itemMapper.deleteAll();
}
@Nested
@DisplayName("Query: item")
class ItemQueryTests {
@Test
@DisplayName("品目コードで品目を取得できる")
void shouldGetItemByCode() {
// Arrange
insertTestItem("PROD-001", "テスト製品", ItemCategory.PRODUCT);
// Act & Assert
graphQlTester.document("""
query {
item(itemCode: "PROD-001") {
itemCode
itemName
category
}
}
""")
.execute()
.path("item.itemCode").entity(String.class).isEqualTo("PROD-001")
.path("item.itemName").entity(String.class).isEqualTo("テスト製品")
.path("item.category").entity(String.class).isEqualTo("PRODUCT");
}
@Test
@DisplayName("存在しない品目は null を返す")
void shouldReturnNullForNonExistentItem() {
graphQlTester.document("""
query {
item(itemCode: "NOT-EXIST") {
itemCode
}
}
""")
.execute()
.path("item").valueIsNull();
}
@Test
@DisplayName("関連データを同時に取得できる")
void shouldGetItemWithRelatedData() {
insertTestItem("PROD-001", "テスト製品", ItemCategory.PRODUCT);
graphQlTester.document("""
query {
item(itemCode: "PROD-001") {
itemCode
itemName
bom {
childItemCode
requiredQuantity
}
stock {
quantity
}
}
}
""")
.execute()
.path("item.itemCode").entity(String.class).isEqualTo("PROD-001")
.path("item.bom").entityList(Object.class).hasSize(0);
}
}
@Nested
@DisplayName("Query: items")
class ItemsQueryTests {
@Test
@DisplayName("品目一覧を取得できる")
void shouldGetItems() {
for (int i = 1; i <= 5; i++) {
insertTestItem("ITEM-" + String.format("%03d", i),
"品目" + i, ItemCategory.PRODUCT);
}
graphQlTester.document("""
query {
items(page: 0, size: 10) {
edges {
node {
itemCode
itemName
}
}
pageInfo {
totalElements
}
}
}
""")
.execute()
.path("items.edges").entityList(Object.class).hasSize(5)
.path("items.pageInfo.totalElements").entity(Integer.class).isEqualTo(5);
}
@Test
@DisplayName("カテゴリでフィルタリングできる")
void shouldFilterByCategory() {
insertTestItem("PROD-001", "製品1", ItemCategory.PRODUCT);
insertTestItem("PART-001", "部品1", ItemCategory.PART);
insertTestItem("PROD-002", "製品2", ItemCategory.PRODUCT);
graphQlTester.document("""
query {
items(category: PRODUCT, page: 0, size: 10) {
edges {
node {
itemCode
}
}
pageInfo {
totalElements
}
}
}
""")
.execute()
.path("items.pageInfo.totalElements").entity(Integer.class).isEqualTo(2);
}
}
@Nested
@DisplayName("Mutation: createItem")
class CreateItemMutationTests {
@Test
@DisplayName("品目を登録できる")
void shouldCreateItem() {
graphQlTester.document("""
mutation {
createItem(input: {
itemCode: "NEW-001"
itemName: "新規品目"
category: PRODUCT
leadTime: 5
}) {
itemCode
itemName
category
leadTime
}
}
""")
.execute()
.path("createItem.itemCode").entity(String.class).isEqualTo("NEW-001")
.path("createItem.itemName").entity(String.class).isEqualTo("新規品目")
.path("createItem.category").entity(String.class).isEqualTo("PRODUCT")
.path("createItem.leadTime").entity(Integer.class).isEqualTo(5);
// DB に保存されていることを確認
var saved = itemMapper.findByCode("NEW-001");
assertThat(saved).isNotNull();
assertThat(saved.getItemName()).isEqualTo("新規品目");
}
@Test
@DisplayName("重複する品目コードはエラーになる")
void shouldFailForDuplicateCode() {
insertTestItem("DUP-001", "既存品目", ItemCategory.PRODUCT);
graphQlTester.document("""
mutation {
createItem(input: {
itemCode: "DUP-001"
itemName: "重複品目"
category: PRODUCT
}) {
itemCode
}
}
""")
.execute()
.errors()
.satisfy(errors -> assertThat(errors).hasSize(1));
}
}
private void insertTestItem(String code, String name, ItemCategory category) {
itemMapper.insert(Item.builder()
.itemCode(code)
.effectiveDate(LocalDate.of(2025, 1, 1))
.itemName(name)
.category(category)
.build());
}
}
Green: GraphQL リゾルバの実装¶
ItemResolver.java
package com.example.production.infrastructure.graphql.resolver;
import com.example.production.application.port.in.*;
import com.example.production.domain.exception.DuplicateItemException;
import com.example.production.domain.exception.ItemNotFoundException;
import com.example.production.domain.model.item.Item;
import com.example.production.domain.model.item.ItemCategory;
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import graphql.schema.DataFetchingEnvironment;
import org.springframework.graphql.data.method.annotation.*;
import org.springframework.graphql.execution.ErrorType;
import org.springframework.stereotype.Controller;
import java.util.List;
/**
* 品目 GraphQL リゾルバ実装
* 既存の ItemUseCase を Input Adapter として呼び出す
*/
@Controller
public class ItemResolver {
private final ItemUseCase itemUseCase; // 既存の UseCase を注入
public ItemResolver(ItemUseCase itemUseCase) {
this.itemUseCase = itemUseCase;
}
// ========== Query ==========
/**
* 品目取得
*/
@QueryMapping
public Item item(@Argument String itemCode) {
try {
return itemUseCase.getItemByCode(itemCode);
} catch (ItemNotFoundException e) {
return null; // GraphQL では null を返す
}
}
/**
* 品目一覧(ページネーション付き)
*/
@QueryMapping
public ItemConnection items(
@Argument ItemCategory category,
@Argument Integer page,
@Argument Integer size) {
int pageNum = page != null ? page : 0;
int pageSize = size != null ? size : 20;
List<Item> items;
int totalElements;
if (category != null) {
items = itemUseCase.getItemsByCategory(category);
} else {
items = itemUseCase.getAllItems();
}
totalElements = items.size();
// ページング処理
int start = pageNum * pageSize;
int end = Math.min(start + pageSize, items.size());
List<Item> pagedItems = items.subList(start, end);
return ItemConnection.builder()
.edges(pagedItems.stream()
.map(item -> ItemEdge.builder()
.node(item)
.cursor(item.getItemCode())
.build())
.toList())
.pageInfo(PageInfo.builder()
.hasNextPage(end < totalElements)
.hasPreviousPage(pageNum > 0)
.totalElements(totalElements)
.totalPages((int) Math.ceil((double) totalElements / pageSize))
.build())
.build();
}
// ========== Mutation ==========
/**
* 品目登録
*/
@MutationMapping
public Item createItem(@Argument CreateItemInput input) {
CreateItemCommand command = CreateItemCommand.builder()
.itemCode(input.getItemCode())
.itemName(input.getItemName())
.category(input.getCategory())
.itemGroupCode(input.getItemGroupCode())
.unitCode(input.getUnitCode())
.locationCode(input.getLocationCode())
.leadTime(input.getLeadTime())
.safetyStock(input.getSafetyStock())
.build();
return itemUseCase.createItem(command);
}
/**
* 品目更新
*/
@MutationMapping
public Item updateItem(@Argument UpdateItemInput input) {
UpdateItemCommand command = UpdateItemCommand.builder()
.itemCode(input.getItemCode())
.itemName(input.getItemName())
.category(input.getCategory())
.leadTime(input.getLeadTime())
.safetyStock(input.getSafetyStock())
.build();
return itemUseCase.updateItem(command);
}
/**
* 品目削除
*/
@MutationMapping
public boolean deleteItem(@Argument String itemCode) {
try {
itemUseCase.deleteItem(itemCode);
return true;
} catch (ItemNotFoundException e) {
return false;
}
}
// ========== 例外ハンドリング ==========
@GraphQlExceptionHandler
public GraphQLError handleDuplicateItem(DuplicateItemException ex, DataFetchingEnvironment env) {
return GraphqlErrorBuilder.newError(env)
.errorType(ErrorType.BAD_REQUEST)
.message(ex.getMessage())
.build();
}
@GraphQlExceptionHandler
public GraphQLError handleItemNotFound(ItemNotFoundException ex, DataFetchingEnvironment env) {
return GraphqlErrorBuilder.newError(env)
.errorType(ErrorType.NOT_FOUND)
.message(ex.getMessage())
.build();
}
}
補助クラス(ItemConnection, PageInfo 等)
package com.example.production.infrastructure.graphql.resolver;
import com.example.production.domain.model.item.Item;
import com.example.production.domain.model.item.ItemCategory;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class ItemConnection {
private List<ItemEdge> edges;
private PageInfo pageInfo;
}
@Data
@Builder
public class ItemEdge {
private Item node;
private String cursor;
}
@Data
@Builder
public class PageInfo {
private boolean hasNextPage;
private boolean hasPreviousPage;
private int totalElements;
private int totalPages;
}
@Data
public class CreateItemInput {
private String itemCode;
private String itemName;
private ItemCategory category;
private String itemGroupCode;
private String unitCode;
private String locationCode;
private Integer leadTime;
private Integer safetyStock;
}
@Data
public class UpdateItemInput {
private String itemCode;
private String itemName;
private ItemCategory category;
private Integer leadTime;
private Integer safetyStock;
}
47.3 N+1 問題対策(DataLoader)¶
GraphQL では関連データの取得時に N+1 問題が発生しやすいため、DataLoader を使用します。
DataLoaderConfig.java
package com.example.production.infrastructure.graphql.dataloader;
import com.example.production.application.port.in.ItemUseCase;
import com.example.production.domain.model.item.Item;
import org.dataloader.DataLoader;
import org.dataloader.DataLoaderFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.graphql.execution.BatchLoaderRegistry;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* DataLoader 設定
* N+1 問題を解決するためのバッチローディング
*/
@Configuration
public class DataLoaderConfig {
private final ItemUseCase itemUseCase;
public DataLoaderConfig(ItemUseCase itemUseCase) {
this.itemUseCase = itemUseCase;
}
@Bean
public BatchLoaderRegistry batchLoaderRegistry(BatchLoaderRegistry registry) {
// 品目 DataLoader
registry.forTypePair(String.class, Item.class)
.registerBatchLoader((itemCodes, env) -> {
// バッチで品目を取得(1回のクエリで複数取得)
List<Item> items = itemUseCase.getItemsByCodes(itemCodes);
Map<String, Item> itemMap = items.stream()
.collect(Collectors.toMap(Item::getItemCode, Function.identity()));
return reactor.core.publisher.Flux.fromIterable(itemCodes)
.map(code -> itemMap.get(code));
});
return registry;
}
}
PurchaseOrderDetailResolver.java(DataLoader 使用)
package com.example.production.infrastructure.graphql.resolver;
import com.example.production.domain.model.item.Item;
import com.example.production.domain.model.purchase.PurchaseOrderDetail;
import org.dataloader.DataLoader;
import org.springframework.graphql.data.method.annotation.SchemaMapping;
import org.springframework.stereotype.Controller;
import java.util.concurrent.CompletableFuture;
/**
* 発注明細リゾルバ
* DataLoader を使って品目を効率的に取得
*/
@Controller
public class PurchaseOrderDetailResolver {
/**
* 発注明細から品目を取得(DataLoader 使用)
*/
@SchemaMapping(typeName = "PurchaseOrderDetail", field = "item")
public CompletableFuture<Item> item(
PurchaseOrderDetail detail,
DataLoader<String, Item> itemDataLoader) {
// DataLoader 経由で取得(バッチ処理される)
return itemDataLoader.load(detail.getItemCode());
}
}
47.4 BOM GraphQL リゾルバ¶
BomResolver.java
package com.example.production.infrastructure.graphql.resolver;
import com.example.production.application.service.BomNode;
import com.example.production.application.service.BomService;
import com.example.production.application.service.WhereUsedResult;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;
import java.util.List;
/**
* BOM GraphQL リゾルバ実装
* 既存の BomService を Input Adapter として呼び出す
*/
@Controller
public class BomResolver {
private final BomService bomService; // 既存のサービスを注入
public BomResolver(BomService bomService) {
this.bomService = bomService;
}
/**
* 部品展開(BOM ツリー)
*/
@QueryMapping
public BomNode bomTree(@Argument String itemCode) {
return bomService.explodeBom(itemCode);
}
/**
* 使用先照会
*/
@QueryMapping
public List<WhereUsedResult> whereUsed(@Argument String itemCode) {
return bomService.whereUsed(itemCode);
}
}
第48章:トランザクション API の実装¶
48.1 発注 GraphQL リゾルバ¶
PurchaseOrderResolver.java
package com.example.production.infrastructure.graphql.resolver;
import com.example.production.application.port.in.*;
import com.example.production.domain.model.purchase.PurchaseOrder;
import org.springframework.graphql.data.method.annotation.*;
import org.springframework.stereotype.Controller;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/**
* 発注 GraphQL リゾルバ実装
* 既存の PurchaseOrderUseCase を Input Adapter として呼び出す
*/
@Controller
public class PurchaseOrderResolver {
private final PurchaseOrderUseCase useCase; // 既存の UseCase
public PurchaseOrderResolver(PurchaseOrderUseCase useCase) {
this.useCase = useCase;
}
// ========== Query ==========
@QueryMapping
public PurchaseOrder purchaseOrder(@Argument String orderNumber) {
return useCase.getOrder(orderNumber);
}
@QueryMapping
public PurchaseOrderConnection purchaseOrders(
@Argument String status,
@Argument Integer page,
@Argument Integer size) {
List<PurchaseOrder> orders = useCase.getAllOrders();
// ページネーション処理(省略)
return PurchaseOrderConnection.builder()
.edges(orders.stream()
.map(order -> PurchaseOrderEdge.builder()
.node(order)
.cursor(order.getOrderNumber())
.build())
.toList())
.pageInfo(PageInfo.builder()
.totalElements(orders.size())
.build())
.build();
}
// ========== Mutation ==========
@MutationMapping
public PurchaseOrder createPurchaseOrder(@Argument CreatePurchaseOrderInput input) {
CreatePurchaseOrderCommand command = CreatePurchaseOrderCommand.builder()
.supplierCode(input.getSupplierCode())
.details(input.getDetails().stream()
.map(d -> CreatePurchaseOrderCommand.PurchaseOrderDetailCommand.builder()
.itemCode(d.getItemCode())
.orderQuantity(d.getOrderQuantity())
.unitPrice(d.getUnitPrice())
.deliveryDate(d.getDeliveryDate())
.build())
.toList())
.build();
return useCase.createOrder(command);
}
@MutationMapping
public PurchaseOrder confirmPurchaseOrder(@Argument String orderNumber) {
return useCase.confirmOrder(orderNumber);
}
@MutationMapping
public boolean cancelPurchaseOrder(@Argument String orderNumber) {
useCase.cancelOrder(orderNumber);
return true;
}
@MutationMapping
public ReceivingResult recordReceiving(@Argument RecordReceivingInput input) {
var result = useCase.recordReceiving(
input.getOrderNumber(),
input.getLineNumber(),
input.getReceivedQuantity(),
input.getReceivingDate()
);
return ReceivingResult.builder()
.orderNumber(input.getOrderNumber())
.lineNumber(input.getLineNumber())
.totalReceived(result.getTotalReceived())
.isCompleted(result.isCompleted())
.build();
}
}
// 入力型
@lombok.Data
class CreatePurchaseOrderInput {
private String supplierCode;
private List<CreateOrderDetailInput> details;
}
@lombok.Data
class CreateOrderDetailInput {
private String itemCode;
private BigDecimal orderQuantity;
private BigDecimal unitPrice;
private LocalDate deliveryDate;
}
@lombok.Data
class RecordReceivingInput {
private String orderNumber;
private int lineNumber;
private BigDecimal receivedQuantity;
private LocalDate receivingDate;
}
@lombok.Data
@lombok.Builder
class ReceivingResult {
private String orderNumber;
private int lineNumber;
private BigDecimal totalReceived;
private boolean isCompleted;
}
48.2 MRP GraphQL リゾルバ¶
MrpResolver.java
package com.example.production.infrastructure.graphql.resolver;
import com.example.production.application.service.MrpService;
import org.springframework.graphql.data.method.annotation.*;
import org.springframework.stereotype.Controller;
import java.time.LocalDate;
import java.util.UUID;
/**
* MRP GraphQL リゾルバ実装
* 既存の MrpService を Input Adapter として呼び出す
*/
@Controller
public class MrpResolver {
private final MrpService mrpService; // 既存のサービス
public MrpResolver(MrpService mrpService) {
this.mrpService = mrpService;
}
/**
* MRP 実行
*/
@MutationMapping
public MrpResult executeMrp(@Argument ExecuteMrpInput input) {
String executionId = UUID.randomUUID().toString();
long startTime = System.currentTimeMillis();
var result = mrpService.execute(input.getStartDate(), input.getEndDate());
long executionTime = System.currentTimeMillis() - startTime;
return MrpResult.builder()
.executionId(executionId)
.periodStart(input.getStartDate())
.periodEnd(input.getEndDate())
.plannedOrders(result.getPlannedOrders())
.shortageItems(result.getShortageItems())
.statistics(MrpStatistics.builder()
.totalItemsProcessed(result.getPlannedOrders().size())
.shortageItemCount(result.getShortageItems().size())
.executionTimeMs(executionTime)
.build())
.build();
}
}
@lombok.Data
class ExecuteMrpInput {
private LocalDate startDate;
private LocalDate endDate;
}
@lombok.Data
@lombok.Builder
class MrpResult {
private String executionId;
private LocalDate periodStart;
private LocalDate periodEnd;
private java.util.List<?> plannedOrders;
private java.util.List<?> shortageItems;
private MrpStatistics statistics;
}
@lombok.Data
@lombok.Builder
class MrpStatistics {
private int totalItemsProcessed;
private int purchaseOrderCount;
private int productionOrderCount;
private int shortageItemCount;
private long executionTimeMs;
}
48.3 Subscription(リアルタイム更新)¶
SubscriptionResolver.java
package com.example.production.infrastructure.graphql.subscription;
import com.example.production.domain.model.purchase.PurchaseOrder;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.SubscriptionMapping;
import org.springframework.stereotype.Controller;
import reactor.core.publisher.Flux;
import java.time.Duration;
/**
* GraphQL Subscription リゾルバ
*/
@Controller
public class SubscriptionResolver {
private final OrderEventPublisher orderEventPublisher;
private final MrpProgressPublisher mrpProgressPublisher;
public SubscriptionResolver(
OrderEventPublisher orderEventPublisher,
MrpProgressPublisher mrpProgressPublisher) {
this.orderEventPublisher = orderEventPublisher;
this.mrpProgressPublisher = mrpProgressPublisher;
}
/**
* MRP 進捗の購読
*/
@SubscriptionMapping
public Flux<MrpProgress> mrpProgress(@Argument String executionId) {
return mrpProgressPublisher.subscribe(executionId);
}
/**
* 発注ステータス変更の購読
*/
@SubscriptionMapping
public Flux<PurchaseOrder> orderStatusChanged(@Argument String orderNumber) {
return orderEventPublisher.subscribe(orderNumber);
}
/**
* 在庫変動の購読
*/
@SubscriptionMapping
public Flux<StockChange> stockChanged(@Argument String itemCode) {
// ダミー実装(5秒ごとに在庫変動をシミュレート)
return Flux.interval(Duration.ofSeconds(5))
.map(i -> StockChange.builder()
.itemCode(itemCode != null ? itemCode : "ITEM-001")
.changeType("RECEIVE")
.quantity(java.math.BigDecimal.valueOf(10))
.build());
}
}
@lombok.Data
@lombok.Builder
class StockChange {
private String itemCode;
private String changeType;
private java.math.BigDecimal quantity;
}
MrpProgressPublisher.java(イベントパブリッシャー)
package com.example.production.infrastructure.graphql.subscription;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Sinks;
import java.util.concurrent.ConcurrentHashMap;
/**
* MRP 進捗イベントパブリッシャー
*/
@Component
public class MrpProgressPublisher {
private final ConcurrentHashMap<String, Sinks.Many<MrpProgress>> sinks =
new ConcurrentHashMap<>();
/**
* 進捗を購読
*/
public Flux<MrpProgress> subscribe(String executionId) {
Sinks.Many<MrpProgress> sink = sinks.computeIfAbsent(
executionId,
k -> Sinks.many().multicast().onBackpressureBuffer()
);
return sink.asFlux();
}
/**
* 進捗を発行
*/
public void publish(String executionId, MrpProgress progress) {
Sinks.Many<MrpProgress> sink = sinks.get(executionId);
if (sink != null) {
sink.tryEmitNext(progress);
}
}
/**
* 完了
*/
public void complete(String executionId) {
Sinks.Many<MrpProgress> sink = sinks.remove(executionId);
if (sink != null) {
sink.tryEmitComplete();
}
}
}
@lombok.Data
@lombok.Builder
class MrpProgress {
private String executionId;
private String phase;
private int current;
private int total;
private String message;
}
第49章:エラーハンドリングとベストプラクティス¶
49.1 グローバル例外ハンドラ¶
GraphQLExceptionHandler.java
package com.example.production.infrastructure.graphql;
import com.example.production.domain.exception.*;
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import graphql.schema.DataFetchingEnvironment;
import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter;
import org.springframework.graphql.execution.ErrorType;
import org.springframework.stereotype.Component;
/**
* GraphQL グローバル例外ハンドラ
*/
@Component
public class GraphQLExceptionHandler extends DataFetcherExceptionResolverAdapter {
@Override
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
if (ex instanceof ItemNotFoundException) {
return GraphqlErrorBuilder.newError(env)
.errorType(ErrorType.NOT_FOUND)
.message(ex.getMessage())
.build();
}
if (ex instanceof DuplicateItemException) {
return GraphqlErrorBuilder.newError(env)
.errorType(ErrorType.BAD_REQUEST)
.message(ex.getMessage())
.build();
}
if (ex instanceof OrderAlreadyConfirmedException) {
return GraphqlErrorBuilder.newError(env)
.errorType(ErrorType.BAD_REQUEST)
.message(ex.getMessage())
.build();
}
if (ex instanceof IllegalArgumentException) {
return GraphqlErrorBuilder.newError(env)
.errorType(ErrorType.BAD_REQUEST)
.message(ex.getMessage())
.build();
}
// 未知のエラー
return GraphqlErrorBuilder.newError(env)
.errorType(ErrorType.INTERNAL_ERROR)
.message("内部エラーが発生しました")
.build();
}
}
49.2 入力バリデーション¶
ValidationExceptionHandler.java
package com.example.production.infrastructure.graphql.validation;
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import graphql.schema.DataFetchingEnvironment;
import org.springframework.graphql.data.method.annotation.GraphQlExceptionHandler;
import org.springframework.graphql.execution.ErrorType;
import org.springframework.stereotype.Controller;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import java.util.List;
import java.util.stream.Collectors;
/**
* バリデーション例外ハンドラ
*/
@Controller
public class ValidationExceptionHandler {
@GraphQlExceptionHandler
public List<GraphQLError> handleValidation(
ConstraintViolationException ex,
DataFetchingEnvironment env) {
return ex.getConstraintViolations().stream()
.map(violation -> toGraphQLError(violation, env))
.collect(Collectors.toList());
}
private GraphQLError toGraphQLError(
ConstraintViolation<?> violation,
DataFetchingEnvironment env) {
return GraphqlErrorBuilder.newError(env)
.errorType(ErrorType.BAD_REQUEST)
.message(String.format("%s: %s",
violation.getPropertyPath(),
violation.getMessage()))
.build();
}
}
49.3 設定ファイル¶
application.yml(GraphQL 追加設定)
spring:
graphql:
path: /graphql
graphiql:
enabled: true # 開発時に GraphiQL UI を有効化
path: /graphiql
websocket:
path: /graphql # Subscription 用 WebSocket
schema:
locations: classpath:graphql/
printer:
enabled: true # スキーマ出力
# 既存の設定はそのまま
datasource:
url: jdbc:postgresql://localhost:5432/production
username: postgres
password: postgres
mybatis:
mapper-locations: classpath:mapper/*.xml
49.4 GraphQL Playground からの呼び出し¶
# 品目取得
query {
item(itemCode: "PROD-001") {
itemCode
itemName
category
leadTime
bom {
childItemCode
requiredQuantity
childItem {
itemName
}
}
}
}
# 品目一覧(ページネーション)
query {
items(category: PRODUCT, page: 0, size: 10) {
edges {
node {
itemCode
itemName
}
}
pageInfo {
totalElements
hasNextPage
}
}
}
# 品目登録
mutation {
createItem(input: {
itemCode: "NEW-001"
itemName: "新規製品"
category: PRODUCT
leadTime: 7
}) {
itemCode
itemName
}
}
# MRP 進捗購読
subscription {
mrpProgress(executionId: "exec-123") {
phase
current
total
message
}
}
49.5 GraphQL vs REST vs gRPC の使い分け¶
| 用途 | 推奨 | 理由 |
|---|---|---|
| フロントエンド API | GraphQL | 柔軟なデータ取得、Over-fetching 防止 |
| モバイルアプリ | GraphQL | 帯域制限、必要なデータのみ取得 |
| マイクロサービス間 | gRPC | 高パフォーマンス、型安全 |
| サードパーティ連携 | REST | 広く普及、シンプル |
| リアルタイム更新 | GraphQL Subscription / gRPC Streaming | ネイティブサポート |
| バッチ処理 | gRPC | ストリーミング効率 |
まとめ¶
本研究では、GraphQL による生産管理システム API を実装しました。
API サーバー版との比較¶
| 観点 | REST API(第32章) | gRPC(研究2) | GraphQL(本研究) |
|---|---|---|---|
| プロトコル | HTTP/1.1 + JSON | HTTP/2 + Protobuf | HTTP + JSON |
| スキーマ | OpenAPI(任意) | .proto(必須) | .graphqls(必須) |
| データ取得 | 固定レスポンス | 固定レスポンス | クライアント指定 |
| エンドポイント | 複数 | 複数 | 単一 |
| リアルタイム | WebSocket 別実装 | ストリーミング | Subscription |
| ドメイン層 | 共有 | 共有 | 共有 |
| アプリケーション層 | 共有 | 共有 | 共有 |
| Input Adapter | REST Controller | gRPC Service | GraphQL Resolver |
実装した GraphQL 操作¶
| 操作タイプ | 操作名 | 説明 |
|---|---|---|
| Query | item, items | 品目取得 |
| bomTree, whereUsed | BOM 展開・逆展開 | |
| purchaseOrder, purchaseOrders | 発注取得 | |
| Mutation | createItem, updateItem, deleteItem | 品目 CRUD |
| createPurchaseOrder, confirmPurchaseOrder | 発注操作 | |
| recordReceiving | 入荷登録 | |
| executeMrp | MRP 実行 | |
| Subscription | mrpProgress | MRP 進捗通知 |
| orderStatusChanged | 発注ステータス変更 | |
| stockChanged | 在庫変動 |
技術スタック¶
- Spring for GraphQL: Spring Boot 統合
- GraphQL Java: GraphQL 実行エンジン
- Extended Scalars: Date, BigDecimal 等のカスタム型
- DataLoader: N+1 問題対策
- WebSocket: Subscription サポート
GraphQL を選択すべき場面¶
- フロントエンド主導の開発: クライアントがデータ形状を決定
- モバイルアプリ: 帯域制限下での効率的なデータ取得
- 複雑な関連データ: 1回のクエリで関連データを取得
- リアルタイム更新: Subscription でプッシュ通知
- API 統合: 複数のバックエンドを単一エンドポイントで提供