diff --git a/core/broadleaf-framework-web/pom.xml b/core/broadleaf-framework-web/pom.xml
index c732740dce..4a8752142e 100644
--- a/core/broadleaf-framework-web/pom.xml
+++ b/core/broadleaf-framework-web/pom.xml
@@ -94,5 +94,9 @@
org.springframework.security
spring-security-oauth2-client
+
+ org.springframework.boot
+ spring-boot-starter-graphql
+
diff --git a/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLContextInterceptor.java b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLContextInterceptor.java
new file mode 100644
index 0000000000..2f01c29c89
--- /dev/null
+++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLContextInterceptor.java
@@ -0,0 +1,236 @@
+/*-
+ * #%L
+ * BroadleafCommerce Framework Web
+ * %%
+ * Copyright (C) 2009 - 2026 Broadleaf Commerce
+ * %%
+ * Licensed under the Broadleaf Fair Use License Agreement, Version 1.0
+ * (the "Fair Use License" located at http://license.broadleafcommerce.org/fair_use_license-1.0.txt)
+ * unless the restrictions on use therein are violated and require payment to Broadleaf in which case
+ * the Broadleaf End User License Agreement (EULA), Version 1.1
+ * (the "Commercial License" located at http://license.broadleafcommerce.org/commercial_license-1.1.txt)
+ * shall apply.
+ *
+ * Alternatively, the Commercial License may be replaced with a mutually agreed upon license (the "Custom License")
+ * between you and Broadleaf Commerce. You may not use this file except in compliance with the applicable license.
+ * #L%
+ */
+package org.broadleafcommerce.core.web.graphql;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.broadleafcommerce.common.util.StringUtil;
+import org.broadleafcommerce.common.web.BroadleafRequestContext;
+import org.broadleafcommerce.profile.core.domain.Customer;
+import org.broadleafcommerce.profile.core.service.CustomerService;
+import org.broadleafcommerce.profile.web.core.CustomerState;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.graphql.server.WebGraphQlInterceptor;
+import org.springframework.graphql.server.WebGraphQlRequest;
+import org.springframework.graphql.server.WebGraphQlResponse;
+import org.springframework.stereotype.Component;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+import org.springframework.web.context.request.ServletWebRequest;
+import org.springframework.web.context.request.WebRequest;
+import reactor.core.publisher.Mono;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A {@link WebGraphQlInterceptor} that populates Broadleaf customer context for incoming
+ * GraphQL requests. It mirrors the identity resolution pattern used by
+ * {@link org.broadleafcommerce.profile.web.core.security.RestApiCustomerStateFilter}:
+ * look for a {@code customerId} header or query parameter, load the customer when numeric,
+ * and populate {@link CustomerState}. When no customer can be resolved, an anonymous
+ * customer is created so downstream resolvers always see a valid customer context.
+ */
+@Component("blGraphQLContextInterceptor")
+public class GraphQLContextInterceptor implements WebGraphQlInterceptor {
+
+ public static final String CUSTOMER_ID_ATTRIBUTE = "customerId";
+ public static final String BLC_RULE_MAP_PARAM = "blRuleMap";
+
+ protected static final Log LOG = LogFactory.getLog(GraphQLContextInterceptor.class);
+
+ @Autowired
+ @Qualifier("blCustomerService")
+ protected CustomerService customerService;
+
+ @Override
+ public Mono intercept(WebGraphQlRequest request, Chain chain) {
+ try {
+ populateCustomerContext(request);
+ } catch (Exception ex) {
+ LOG.warn("Failed to populate customer context for GraphQL request", ex);
+ }
+ ensureCustomerContext();
+ return chain.next(request);
+ }
+
+ protected void populateCustomerContext(WebGraphQlRequest request) {
+ ensureBroadleafRequestContext();
+
+ if (CustomerState.getCustomer() != null) {
+ return;
+ }
+
+ String customerId = resolveCustomerId(request);
+ if (customerId == null || customerId.trim().isEmpty()) {
+ return;
+ }
+ if (!isValidLong(customerId)) {
+ LOG.warn(String.format("The customer id passed in '%s' was not a number",
+ StringUtil.sanitize(customerId)));
+ return;
+ }
+ Customer customer = customerService.readCustomerById(Long.valueOf(customerId));
+ if (customer != null) {
+ applyCustomer(customer);
+ }
+ }
+
+ protected void ensureCustomerContext() {
+ if (hasResolvedCustomer()) {
+ return;
+ }
+ try {
+ ensureBroadleafRequestContext();
+ Customer anonymousCustomer = customerService.createCustomer();
+ applyCustomer(anonymousCustomer);
+ } catch (Exception ex) {
+ LOG.warn("Failed to create anonymous customer for GraphQL request", ex);
+ }
+ }
+
+ /**
+ * Returns {@code true} when a customer has already been attached to the current request,
+ * either via {@link CustomerState} (servlet transport) or via {@link BroadleafRequestContext}
+ * {@code additionalProperties} (non-servlet transports such as WebSocket GraphQL).
+ */
+ protected boolean hasResolvedCustomer() {
+ if (CustomerState.getCustomer() != null) {
+ return true;
+ }
+ BroadleafRequestContext brc = BroadleafRequestContext.getBroadleafRequestContext();
+ if (brc == null) {
+ return false;
+ }
+ Map additional = brc.getAdditionalProperties();
+ return additional != null && additional.get("customer") != null;
+ }
+
+ /**
+ * Binds the customer to the current request context. {@link CustomerState#setCustomer(Customer)}
+ * requires a {@link WebRequest} on the {@link BroadleafRequestContext}; for transports where no
+ * servlet request is bound (e.g., WebSocket GraphQL), we fall back to storing the customer directly
+ * in {@code additionalProperties} so downstream resolvers can still retrieve it via
+ * {@link BroadleafRequestContext}.
+ */
+ protected void applyCustomer(Customer customer) {
+ BroadleafRequestContext brc = BroadleafRequestContext.getBroadleafRequestContext();
+ if (brc != null && brc.getWebRequest() != null) {
+ CustomerState.setCustomer(customer);
+ } else {
+ if (brc == null) {
+ brc = new BroadleafRequestContext();
+ BroadleafRequestContext.setBroadleafRequestContext(brc);
+ }
+ Map additional = brc.getAdditionalProperties();
+ if (additional == null) {
+ additional = new HashMap<>();
+ brc.setAdditionalProperties(additional);
+ }
+ additional.put("customer", customer);
+ LOG.debug("No WebRequest bound to BroadleafRequestContext; "
+ + "customer stored in additionalProperties for GraphQL request");
+ }
+ setupCustomerForRuleProcessing(customer);
+ }
+
+ protected boolean isValidLong(String value) {
+ if (value == null) {
+ return false;
+ }
+ try {
+ Long.parseLong(value);
+ return true;
+ } catch (NumberFormatException ex) {
+ return false;
+ }
+ }
+
+ protected String resolveCustomerId(WebGraphQlRequest request) {
+ String customerId = request.getHeaders().getFirst(CUSTOMER_ID_ATTRIBUTE);
+ if (customerId != null) {
+ return customerId;
+ }
+ String query = request.getUri() != null ? request.getUri().getQuery() : null;
+ if (query == null) {
+ return null;
+ }
+ for (String pair : query.split("&")) {
+ int idx = pair.indexOf('=');
+ if (idx > 0 && CUSTOMER_ID_ATTRIBUTE.equals(pair.substring(0, idx))) {
+ return pair.substring(idx + 1);
+ }
+ }
+ return null;
+ }
+
+ protected void ensureBroadleafRequestContext() {
+ BroadleafRequestContext brc = BroadleafRequestContext.getBroadleafRequestContext();
+ if (brc == null) {
+ brc = new BroadleafRequestContext();
+ BroadleafRequestContext.setBroadleafRequestContext(brc);
+ }
+ if (brc.getWebRequest() == null) {
+ WebRequest webRequest = resolveWebRequest();
+ if (webRequest != null) {
+ brc.setWebRequest(webRequest);
+ }
+ }
+ }
+
+ protected WebRequest resolveWebRequest() {
+ try {
+ if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
+ return new ServletWebRequest(attrs.getRequest(), attrs.getResponse());
+ }
+ } catch (IllegalStateException ignored) {
+ // No servlet request bound to this thread; fall through
+ }
+ return null;
+ }
+
+ @SuppressWarnings("unchecked")
+ protected void setupCustomerForRuleProcessing(Customer customer) {
+ BroadleafRequestContext brc = BroadleafRequestContext.getBroadleafRequestContext();
+ if (brc == null) {
+ return;
+ }
+ if (brc.getRequest() != null) {
+ Map ruleMap = (Map) brc.getRequest()
+ .getAttribute(BLC_RULE_MAP_PARAM);
+ if (ruleMap == null) {
+ ruleMap = new HashMap<>();
+ }
+ ruleMap.put("customer", customer);
+ brc.getRequest().setAttribute(BLC_RULE_MAP_PARAM, ruleMap);
+ return;
+ }
+ Map additional = brc.getAdditionalProperties();
+ if (additional == null) {
+ additional = new HashMap<>();
+ brc.setAdditionalProperties(additional);
+ }
+ Map ruleMap = (Map) additional.get(BLC_RULE_MAP_PARAM);
+ if (ruleMap == null) {
+ ruleMap = new HashMap<>();
+ additional.put(BLC_RULE_MAP_PARAM, ruleMap);
+ }
+ ruleMap.put("customer", customer);
+ }
+}
diff --git a/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLExceptionResolver.java b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLExceptionResolver.java
new file mode 100644
index 0000000000..dbab5f0977
--- /dev/null
+++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/GraphQLExceptionResolver.java
@@ -0,0 +1,85 @@
+/*-
+ * #%L
+ * BroadleafCommerce Framework Web
+ * %%
+ * Copyright (C) 2009 - 2026 Broadleaf Commerce
+ * %%
+ * Licensed under the Broadleaf Fair Use License Agreement, Version 1.0
+ * (the "Fair Use License" located at http://license.broadleafcommerce.org/fair_use_license-1.0.txt)
+ * unless the restrictions on use therein are violated and require payment to Broadleaf in which case
+ * the Broadleaf End User License Agreement (EULA), Version 1.1
+ * (the "Commercial License" located at http://license.broadleafcommerce.org/commercial_license-1.1.txt)
+ * shall apply.
+ *
+ * Alternatively, the Commercial License may be replaced with a mutually agreed upon license (the "Custom License")
+ * between you and Broadleaf Commerce. You may not use this file except in compliance with the applicable license.
+ * #L%
+ */
+package org.broadleafcommerce.core.web.graphql;
+
+import graphql.GraphQLError;
+import graphql.GraphqlErrorBuilder;
+import graphql.schema.DataFetchingEnvironment;
+import org.broadleafcommerce.core.offer.service.exception.OfferMaxUseExceededException;
+import org.broadleafcommerce.core.order.service.exception.AddToCartException;
+import org.broadleafcommerce.core.order.service.exception.IllegalCartOperationException;
+import org.broadleafcommerce.core.order.service.exception.RemoveFromCartException;
+import org.broadleafcommerce.core.order.service.exception.UpdateCartException;
+import org.broadleafcommerce.core.pricing.service.exception.PricingException;
+import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter;
+import org.springframework.graphql.execution.ErrorType;
+import org.springframework.stereotype.Component;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Maps Broadleaf domain exceptions to GraphQL errors with stable error codes in the
+ * {@code extensions} map. Unrecognized exceptions are mapped to {@code INTERNAL_ERROR}.
+ */
+@Component("blGraphQLExceptionResolver")
+public class GraphQLExceptionResolver extends DataFetcherExceptionResolverAdapter {
+
+ public static final String ERROR_CODE_KEY = "errorCode";
+ public static final String CLASSIFICATION_KEY = "classification";
+
+ @Override
+ protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
+ String errorCode;
+ ErrorType errorType = ErrorType.INTERNAL_ERROR;
+
+ if (ex instanceof AddToCartException) {
+ errorCode = "ADD_TO_CART_ERROR";
+ errorType = ErrorType.BAD_REQUEST;
+ } else if (ex instanceof PricingException) {
+ errorCode = "PRICING_ERROR";
+ } else if (ex instanceof OfferMaxUseExceededException) {
+ errorCode = "OFFER_MAX_USE_EXCEEDED";
+ errorType = ErrorType.BAD_REQUEST;
+ } else if (ex instanceof IllegalCartOperationException) {
+ errorCode = "ILLEGAL_CART_OPERATION";
+ errorType = ErrorType.BAD_REQUEST;
+ } else if (ex instanceof RemoveFromCartException) {
+ errorCode = "REMOVE_FROM_CART_ERROR";
+ errorType = ErrorType.BAD_REQUEST;
+ } else if (ex instanceof UpdateCartException) {
+ errorCode = "UPDATE_CART_ERROR";
+ errorType = ErrorType.BAD_REQUEST;
+ } else {
+ errorCode = "INTERNAL_ERROR";
+ }
+
+ Map extensions = new HashMap<>();
+ extensions.put(ERROR_CODE_KEY, errorCode);
+ extensions.put(CLASSIFICATION_KEY, errorType.name());
+
+ boolean safeToExposeMessage = errorType != ErrorType.INTERNAL_ERROR && ex.getMessage() != null;
+ String message = safeToExposeMessage ? ex.getMessage() : errorCode;
+
+ return GraphqlErrorBuilder.newError(env)
+ .errorType(errorType)
+ .message(message)
+ .extensions(extensions)
+ .build();
+ }
+}
diff --git a/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/CatalogQueryResolver.java b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/CatalogQueryResolver.java
new file mode 100644
index 0000000000..44fb1dfe0f
--- /dev/null
+++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/CatalogQueryResolver.java
@@ -0,0 +1,98 @@
+/*-
+ * #%L
+ * BroadleafCommerce Framework Web
+ * %%
+ * Copyright (C) 2009 - 2026 Broadleaf Commerce
+ * %%
+ * Licensed under the Broadleaf Fair Use License Agreement, Version 1.0
+ * (the "Fair Use License" located at http://license.broadleafcommerce.org/fair_use_license-1.0.txt)
+ * unless the restrictions on use therein are violated and require payment to Broadleaf in which case
+ * the Broadleaf End User License Agreement (EULA), Version 1.1
+ * (the "Commercial License" located at http://license.broadleafcommerce.org/commercial_license-1.1.txt)
+ * shall apply.
+ *
+ * Alternatively, the Commercial License may be replaced with a mutually agreed upon license (the "Custom License")
+ * between you and Broadleaf Commerce. You may not use this file except in compliance with the applicable license.
+ * #L%
+ */
+package org.broadleafcommerce.core.web.graphql.resolvers;
+
+import org.broadleafcommerce.core.catalog.domain.Category;
+import org.broadleafcommerce.core.catalog.domain.Product;
+import org.broadleafcommerce.core.catalog.domain.Sku;
+import org.broadleafcommerce.core.catalog.service.CatalogService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+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;
+
+/**
+ * GraphQL query resolvers for catalog lookups. Delegates directly to {@link CatalogService}
+ * without modifying existing REST controller behavior.
+ */
+@Controller
+public class CatalogQueryResolver {
+
+ protected static final int DEFAULT_LIMIT = 50;
+ protected static final int DEFAULT_OFFSET = 0;
+
+ @Autowired
+ @Qualifier("blCatalogService")
+ protected CatalogService catalogService;
+
+ @QueryMapping
+ public Product product(@Argument Long id) {
+ return catalogService.findProductById(id);
+ }
+
+ @QueryMapping
+ public Product productByExternalId(@Argument String externalId) {
+ return catalogService.findProductByExternalId(externalId);
+ }
+
+ @QueryMapping
+ public Product productByUri(@Argument String uri) {
+ return catalogService.findProductByURI(uri);
+ }
+
+ @QueryMapping
+ public List products(@Argument String name,
+ @Argument Integer limit,
+ @Argument Integer offset) {
+ int effectiveLimit = limit != null ? limit : DEFAULT_LIMIT;
+ int effectiveOffset = offset != null ? offset : DEFAULT_OFFSET;
+ return catalogService.findProductsByName(name, effectiveLimit, effectiveOffset);
+ }
+
+ @QueryMapping
+ public Category category(@Argument Long id) {
+ return catalogService.findCategoryById(id);
+ }
+
+ @QueryMapping
+ public Category categoryByUri(@Argument String uri) {
+ return catalogService.findCategoryByURI(uri);
+ }
+
+ @QueryMapping
+ public List categories(@Argument String name,
+ @Argument Integer limit,
+ @Argument Integer offset) {
+ int effectiveLimit = limit != null ? limit : DEFAULT_LIMIT;
+ int effectiveOffset = offset != null ? offset : DEFAULT_OFFSET;
+ return catalogService.findCategoriesByName(name, effectiveLimit, effectiveOffset);
+ }
+
+ @QueryMapping
+ public Sku sku(@Argument Long id) {
+ return catalogService.findSkuById(id);
+ }
+
+ @QueryMapping
+ public Sku skuByUpc(@Argument String upc) {
+ return catalogService.findSkuByUpc(upc);
+ }
+}
diff --git a/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/CategoryFieldResolver.java b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/CategoryFieldResolver.java
new file mode 100644
index 0000000000..e6b409c6c4
--- /dev/null
+++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/CategoryFieldResolver.java
@@ -0,0 +1,63 @@
+/*-
+ * #%L
+ * BroadleafCommerce Framework Web
+ * %%
+ * Copyright (C) 2009 - 2026 Broadleaf Commerce
+ * %%
+ * Licensed under the Broadleaf Fair Use License Agreement, Version 1.0
+ * (the "Fair Use License" located at http://license.broadleafcommerce.org/fair_use_license-1.0.txt)
+ * unless the restrictions on use therein are violated and require payment to Broadleaf in which case
+ * the Broadleaf End User License Agreement (EULA), Version 1.1
+ * (the "Commercial License" located at http://license.broadleafcommerce.org/commercial_license-1.1.txt)
+ * shall apply.
+ *
+ * Alternatively, the Commercial License may be replaced with a mutually agreed upon license (the "Custom License")
+ * between you and Broadleaf Commerce. You may not use this file except in compliance with the applicable license.
+ * #L%
+ */
+package org.broadleafcommerce.core.web.graphql.resolvers;
+
+import org.broadleafcommerce.core.catalog.domain.Category;
+import org.broadleafcommerce.core.catalog.domain.Product;
+import org.broadleafcommerce.core.catalog.service.CatalogService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.graphql.data.method.annotation.Argument;
+import org.springframework.graphql.data.method.annotation.SchemaMapping;
+import org.springframework.stereotype.Controller;
+
+import java.util.List;
+
+/**
+ * Field resolvers for the GraphQL {@code Category} type. Each method receives the parent
+ * {@link Category} as the first argument and lazily loads child collections via
+ * {@link CatalogService} so they are only fetched when requested.
+ */
+@Controller
+public class CategoryFieldResolver {
+
+ protected static final int DEFAULT_LIMIT = 50;
+ protected static final int DEFAULT_OFFSET = 0;
+
+ @Autowired
+ @Qualifier("blCatalogService")
+ protected CatalogService catalogService;
+
+ @SchemaMapping(typeName = "Category", field = "activeSubCategories")
+ public List activeSubCategories(Category category,
+ @Argument Integer limit,
+ @Argument Integer offset) {
+ int effectiveLimit = limit != null ? limit : DEFAULT_LIMIT;
+ int effectiveOffset = offset != null ? offset : DEFAULT_OFFSET;
+ return catalogService.findActiveSubCategoriesByCategory(category, effectiveLimit, effectiveOffset);
+ }
+
+ @SchemaMapping(typeName = "Category", field = "products")
+ public List products(Category category,
+ @Argument Integer limit,
+ @Argument Integer offset) {
+ int effectiveLimit = limit != null ? limit : DEFAULT_LIMIT;
+ int effectiveOffset = offset != null ? offset : DEFAULT_OFFSET;
+ return catalogService.findActiveProductsByCategory(category, effectiveLimit, effectiveOffset);
+ }
+}
diff --git a/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/ProductFieldResolver.java b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/ProductFieldResolver.java
new file mode 100644
index 0000000000..eba12aa3b2
--- /dev/null
+++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/ProductFieldResolver.java
@@ -0,0 +1,55 @@
+/*-
+ * #%L
+ * BroadleafCommerce Framework Web
+ * %%
+ * Copyright (C) 2009 - 2026 Broadleaf Commerce
+ * %%
+ * Licensed under the Broadleaf Fair Use License Agreement, Version 1.0
+ * (the "Fair Use License" located at http://license.broadleafcommerce.org/fair_use_license-1.0.txt)
+ * unless the restrictions on use therein are violated and require payment to Broadleaf in which case
+ * the Broadleaf End User License Agreement (EULA), Version 1.1
+ * (the "Commercial License" located at http://license.broadleafcommerce.org/commercial_license-1.1.txt)
+ * shall apply.
+ *
+ * Alternatively, the Commercial License may be replaced with a mutually agreed upon license (the "Custom License")
+ * between you and Broadleaf Commerce. You may not use this file except in compliance with the applicable license.
+ * #L%
+ */
+package org.broadleafcommerce.core.web.graphql.resolvers;
+
+import org.broadleafcommerce.core.catalog.domain.Category;
+import org.broadleafcommerce.core.catalog.domain.Product;
+import org.broadleafcommerce.core.catalog.domain.ProductOption;
+import org.broadleafcommerce.core.catalog.domain.Sku;
+import org.springframework.graphql.data.method.annotation.SchemaMapping;
+import org.springframework.stereotype.Controller;
+
+import java.util.List;
+
+/**
+ * Field resolvers for the GraphQL {@code Product} type. Lazy-loads nested associations
+ * so the underlying JPA proxies are only initialized when the caller selects the field.
+ */
+@Controller
+public class ProductFieldResolver {
+
+ @SchemaMapping(typeName = "Product", field = "defaultCategory")
+ public Category defaultCategory(Product product) {
+ return product.getDefaultCategory();
+ }
+
+ @SchemaMapping(typeName = "Product", field = "defaultSku")
+ public Sku defaultSku(Product product) {
+ return product.getDefaultSku();
+ }
+
+ @SchemaMapping(typeName = "Product", field = "additionalSkus")
+ public List additionalSkus(Product product) {
+ return product.getAdditionalSkus();
+ }
+
+ @SchemaMapping(typeName = "Product", field = "productOptions")
+ public List productOptions(Product product) {
+ return product.getProductOptions();
+ }
+}
diff --git a/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/SearchQueryResolver.java b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/SearchQueryResolver.java
new file mode 100644
index 0000000000..aad61de7b7
--- /dev/null
+++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/SearchQueryResolver.java
@@ -0,0 +1,79 @@
+/*-
+ * #%L
+ * BroadleafCommerce Framework Web
+ * %%
+ * Copyright (C) 2009 - 2026 Broadleaf Commerce
+ * %%
+ * Licensed under the Broadleaf Fair Use License Agreement, Version 1.0
+ * (the "Fair Use License" located at http://license.broadleafcommerce.org/fair_use_license-1.0.txt)
+ * unless the restrictions on use therein are violated and require payment to Broadleaf in which case
+ * the Broadleaf End User License Agreement (EULA), Version 1.1
+ * (the "Commercial License" located at http://license.broadleafcommerce.org/commercial_license-1.1.txt)
+ * shall apply.
+ *
+ * Alternatively, the Commercial License may be replaced with a mutually agreed upon license (the "Custom License")
+ * between you and Broadleaf Commerce. You may not use this file except in compliance with the applicable license.
+ * #L%
+ */
+package org.broadleafcommerce.core.web.graphql.resolvers;
+
+import org.broadleafcommerce.core.catalog.domain.Product;
+import org.broadleafcommerce.core.search.domain.SearchCriteria;
+import org.broadleafcommerce.core.search.domain.SearchResult;
+import org.broadleafcommerce.core.search.service.SearchService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.graphql.data.method.annotation.Argument;
+import org.springframework.graphql.data.method.annotation.QueryMapping;
+import org.springframework.stereotype.Controller;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * GraphQL resolver for product search. Constructs a {@link SearchCriteria} from the
+ * query/limit/offset arguments and delegates to {@link SearchService#findSearchResults}.
+ */
+@Controller
+public class SearchQueryResolver {
+
+ protected static final int DEFAULT_LIMIT = 50;
+ protected static final int DEFAULT_OFFSET = 0;
+
+ @Autowired
+ @Qualifier("blSearchService")
+ protected SearchService searchService;
+
+ @QueryMapping
+ public List search(@Argument String query,
+ @Argument Integer limit,
+ @Argument Integer offset) throws Exception {
+ int effectiveLimit = limit != null ? limit : DEFAULT_LIMIT;
+ int effectiveOffset = offset != null ? offset : DEFAULT_OFFSET;
+
+ if (effectiveLimit <= 0) {
+ throw new IllegalArgumentException("limit must be greater than zero");
+ }
+ if (effectiveOffset < 0) {
+ throw new IllegalArgumentException("offset must be greater than or equal to zero");
+ }
+ // The Broadleaf search backend paginates via page/pageSize and ignores startIndex for
+ // non-page-aligned offsets, so reject values that would silently round down.
+ if (effectiveOffset % effectiveLimit != 0) {
+ throw new IllegalArgumentException(
+ "offset must be a multiple of limit (" + effectiveLimit + ")");
+ }
+
+ SearchCriteria criteria = new SearchCriteria();
+ criteria.setQuery(query);
+ criteria.setPageSize(effectiveLimit);
+ int page = (effectiveOffset / effectiveLimit) + 1;
+ criteria.setPage(page);
+
+ SearchResult result = searchService.findSearchResults(criteria);
+ if (result == null || result.getProducts() == null) {
+ return Collections.emptyList();
+ }
+ return result.getProducts();
+ }
+}
diff --git a/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/SkuFieldResolver.java b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/SkuFieldResolver.java
new file mode 100644
index 0000000000..7327341df8
--- /dev/null
+++ b/core/broadleaf-framework-web/src/main/java/org/broadleafcommerce/core/web/graphql/resolvers/SkuFieldResolver.java
@@ -0,0 +1,52 @@
+/*-
+ * #%L
+ * BroadleafCommerce Framework Web
+ * %%
+ * Copyright (C) 2009 - 2026 Broadleaf Commerce
+ * %%
+ * Licensed under the Broadleaf Fair Use License Agreement, Version 1.0
+ * (the "Fair Use License" located at http://license.broadleafcommerce.org/fair_use_license-1.0.txt)
+ * unless the restrictions on use therein are violated and require payment to Broadleaf in which case
+ * the Broadleaf End User License Agreement (EULA), Version 1.1
+ * (the "Commercial License" located at http://license.broadleafcommerce.org/commercial_license-1.1.txt)
+ * shall apply.
+ *
+ * Alternatively, the Commercial License may be replaced with a mutually agreed upon license (the "Custom License")
+ * between you and Broadleaf Commerce. You may not use this file except in compliance with the applicable license.
+ * #L%
+ */
+package org.broadleafcommerce.core.web.graphql.resolvers;
+
+import org.broadleafcommerce.common.money.Money;
+import org.broadleafcommerce.core.catalog.domain.Sku;
+import org.springframework.graphql.data.method.annotation.SchemaMapping;
+import org.springframework.stereotype.Controller;
+
+/**
+ * Field resolvers for the GraphQL {@code Sku} type. Maps Broadleaf {@link Money} values to
+ * the shared {@code Money} GraphQL type so callers can select {@code amount} and
+ * {@code currency} independently.
+ */
+@Controller
+public class SkuFieldResolver {
+
+ @SchemaMapping(typeName = "Sku", field = "salePrice")
+ public Money salePrice(Sku sku) {
+ return sku.getSalePrice();
+ }
+
+ @SchemaMapping(typeName = "Sku", field = "retailPrice")
+ public Money retailPrice(Sku sku) {
+ return sku.getRetailPrice();
+ }
+
+ @SchemaMapping(typeName = "Sku", field = "cost")
+ public Money cost(Sku sku) {
+ return sku.getCost();
+ }
+
+ @SchemaMapping(typeName = "Money", field = "currency")
+ public String currency(Money money) {
+ return money.getCurrency() != null ? money.getCurrency().getCurrencyCode() : null;
+ }
+}
diff --git a/core/broadleaf-framework-web/src/main/resources/graphql-application.properties b/core/broadleaf-framework-web/src/main/resources/graphql-application.properties
new file mode 100644
index 0000000000..bdbfa221b0
--- /dev/null
+++ b/core/broadleaf-framework-web/src/main/resources/graphql-application.properties
@@ -0,0 +1,20 @@
+###
+# #%L
+# BroadleafCommerce Framework Web
+# %%
+# Copyright (C) 2009 - 2026 Broadleaf Commerce
+# %%
+# Licensed under the Broadleaf Fair Use License Agreement, Version 1.0
+# (the "Fair Use License" located at http://license.broadleafcommerce.org/fair_use_license-1.0.txt)
+# unless the restrictions on use therein are violated and require payment to Broadleaf in which case
+# the Broadleaf End User License Agreement (EULA), Version 1.1
+# (the "Commercial License" located at http://license.broadleafcommerce.org/commercial_license-1.1.txt)
+# shall apply.
+#
+# Alternatively, the Commercial License may be replaced with a mutually agreed upon license (the "Custom License")
+# between you and Broadleaf Commerce. You may not use this file except in compliance with the applicable license.
+# #L%
+###
+spring.graphql.graphiql.enabled=true
+spring.graphql.path=/graphql
+spring.graphql.schema.locations=classpath:graphql/
diff --git a/core/broadleaf-framework-web/src/main/resources/graphql/schema.graphqls b/core/broadleaf-framework-web/src/main/resources/graphql/schema.graphqls
new file mode 100644
index 0000000000..4d8a3ebb41
--- /dev/null
+++ b/core/broadleaf-framework-web/src/main/resources/graphql/schema.graphqls
@@ -0,0 +1,201 @@
+type Query {
+ # Catalog queries
+ product(id: ID!): Product
+ productByExternalId(externalId: String!): Product
+ productByUri(uri: String!): Product
+ products(name: String!, limit: Int = 50, offset: Int = 0): [Product!]!
+ category(id: ID!): Category
+ categoryByUri(uri: String!): Category
+ categories(name: String!, limit: Int = 50, offset: Int = 0): [Category!]!
+ sku(id: ID!): Sku
+ skuByUpc(upc: String!): Sku
+ search(query: String!, limit: Int = 50, offset: Int = 0): [Product!]!
+}
+
+type Mutation {
+ _placeholder: Boolean
+}
+
+type Money {
+ amount: BigDecimal!
+ currency: String
+}
+
+scalar BigDecimal
+
+type Product {
+ id: ID!
+ name: String
+ description: String
+ longDescription: String
+ url: String
+ manufacturer: String
+ defaultCategory: Category
+ defaultSku: Sku
+ additionalSkus: [Sku!]
+ productOptions: [ProductOption!]
+}
+
+type Category {
+ id: ID!
+ name: String
+ url: String
+ description: String
+ longDescription: String
+ activeStartDate: String
+ activeEndDate: String
+ activeSubCategories(limit: Int = 50, offset: Int = 0): [Category!]
+ products(limit: Int = 50, offset: Int = 0): [Product!]
+}
+
+type Sku {
+ id: ID!
+ name: String
+ description: String
+ longDescription: String
+ salePrice: Money
+ retailPrice: Money
+ cost: Money
+ upc: String
+ quantityAvailable: Int
+ available: Boolean
+ activeStartDate: String
+ activeEndDate: String
+}
+
+type ProductOption {
+ id: ID!
+ label: String
+ type: String
+ required: Boolean
+ allowedValues: [ProductOptionValue!]
+}
+
+type ProductOptionValue {
+ id: ID!
+ attributeValue: String
+ displayOrder: Int
+ priceAdjustment: Money
+}
+
+type Order {
+ id: ID!
+ orderNumber: String
+ status: OrderStatus
+ subTotal: Money
+ total: Money
+ totalTax: Money
+ totalShipping: Money
+ emailAddress: String
+ orderItems: [OrderItem!]
+ fulfillmentGroups: [FulfillmentGroup!]
+ payments: [OrderPayment!]
+}
+
+enum OrderStatus {
+ IN_PROCESS
+ NAMED
+ SUBMITTED
+ CANCELLED
+ QUOTE
+ CSR
+}
+
+type OrderItem {
+ id: ID!
+ name: String
+ quantity: Int
+ price: Money
+ salePrice: Money
+ retailPrice: Money
+ totalPrice: Money
+ product: Product
+ sku: Sku
+ orderItemAttributes: [OrderItemAttribute!]
+}
+
+type OrderItemAttribute {
+ id: ID!
+ name: String
+ value: String
+}
+
+type FulfillmentGroup {
+ id: ID!
+ address: Address
+ shippingPrice: Money
+ merchandiseTotal: Money
+ total: Money
+ type: String
+ fulfillmentGroupItems: [FulfillmentGroupItem!]
+}
+
+type FulfillmentGroupItem {
+ id: ID!
+ quantity: Int
+ totalItemAmount: Money
+ orderItem: OrderItem
+}
+
+type Address {
+ id: ID!
+ firstName: String
+ lastName: String
+ addressLine1: String
+ addressLine2: String
+ city: String
+ stateProvinceRegion: String
+ postalCode: String
+ country: String
+ phonePrimary: String
+}
+
+type OrderPayment {
+ id: ID!
+ amount: Money
+ type: String
+ gatewayType: String
+ referenceNumber: String
+}
+
+type Customer {
+ id: ID!
+ firstName: String
+ lastName: String
+ emailAddress: String
+ username: String
+}
+
+input AddToCartInput {
+ productId: ID!
+ skuId: ID
+ quantity: Int!
+ itemAttributes: [ItemAttributeInput!]
+}
+
+input ItemAttributeInput {
+ name: String!
+ value: String!
+}
+
+input ShippingInfoInput {
+ address: AddressInput!
+ fulfillmentOption: String
+}
+
+input AddressInput {
+ firstName: String
+ lastName: String
+ addressLine1: String!
+ addressLine2: String
+ city: String!
+ stateProvinceRegion: String!
+ postalCode: String!
+ country: String!
+ phonePrimary: String
+}
+
+input BillingInfoInput {
+ address: AddressInput!
+ paymentType: String!
+}
diff --git a/pom.xml b/pom.xml
index 6836d3b269..c36ca6fa97 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1202,6 +1202,22 @@
spring-boot-autoconfigure
${spring.boot.version}
+
+ org.springframework.boot
+ spring-boot-starter-graphql
+ ${spring.boot.version}
+
+
+ org.reactivestreams
+ reactive-streams
+
+
+
+
+ org.reactivestreams
+ reactive-streams
+ 1.0.4
+
org.springframework.security
spring-security-oauth2-client