# Product catalog ## Product modeling In 2025, commercetools introduced architectural changes designed to streamline how product data is structured and retrieved. The focus shifted from "per-variant" data management to a more centralized "Product-level" approach, significantly reducing data redundancy and API payload sizes. These updates are critical for developers looking to optimize storefront performance, especially for large catalogs with high variant counts. ### Key enhancements - Product-level Attributes: historically, Attributes like `brand`, `manufacturer`, or `material` had to be replicated across every variant using the `SameForAll` constraint. You can now define Product Attributes directly at the Product level. This centralization ensures that common data is stored only once, leading to faster updates and leaner API responses. - Attribute filtering for Product Projections: a new `filter[attributes]` query parameter allows you to explicitly include or exclude specific Attributes in the API response. This is a game-changer for mobile performance, where reducing the JSON payload size is vital for quick page loads. - Updated Query Predicate format: to enhance indexing efficiency, Query Predicates for Attributes now require the Attribute `name`. This change allows the platform to route queries more effectively, preventing performance degradation as your Attribute count grows. **Example:** Let’s use the Attribute filtering feature to fetch a current Product Projection, but it should only include two specific Attributes we need (`search-color` and `color-code`): ```ts import { ctpClientHTTPAPI, projectKey } from "./BuildClient"; import { createApiBuilderFromCtpClient } from "@commercetools/platform-sdk"; const apiRoot = createApiBuilderFromCtpClient(ctpClientHTTPAPI).withProjectKey({ projectKey, }); async function getFilteredAttributes() { await apiRoot .productProjections() .withKey({ key: "comfort-coffee-mug" }) .get({ queryArgs: { // Include only 'search-color' and 'color-code' "filter[attributes]": ["search-color", "color-code"], }, }) .execute() .then((response) => { console.log( "Filtered attributes: \n" + JSON.stringify(response.body.masterVariant.attributes, null, 2) ); }); } await getFilteredAttributes(); ``` ```java import com.commercetools.api.client.ProjectApiRoot; import com.commercetools.api.models.product.ProductProjection; import com.fasterxml.jackson.databind.ObjectMapper; import io.vrap.rmf.base.client.utils.json.JsonUtils; public class Main { public static void main(String[] args) { // Create ApiRoot from your HTTPApiClient ProjectApiRoot apiRoot = HTTPApiClient.createApiClient(); ProductProjection productProjection = apiRoot .productProjections() .withKey("comfort-coffee-mug") .get() // Include only 'search-color' and 'color-code' .withQueryParam("filter[attributes]", "search-color") .addQueryParam("filter[attributes]", "color-code") .executeBlocking() .getBody(); try { ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); String json = mapper.writeValueAsString(productProjection.getMasterVariant().getAttributes()); System.out.println("Filtered attributes: \n" + JsonUtils.prettyPrint(json)); } catch (Exception e) { System.err.println("Failed to serialize productProjection: " + e.getMessage()); e.printStackTrace(); } } } ``` Filtered attributes: ```json title="Result" [ { "name" : "search-color", "value" : { "key" : "white", "label" : { "de-DE" : "Weiß", "en-US" : "White", "en-GB" : "White" } } }, { "name" : "color-code", "value" : "#FFFFFF" } ] ``` As you can see by our result, when trying to display all Attributes of the master variant, only the ones included in our filter are available. This not only improves readability but also can help in reducing the payload size of the response we get from the HTTP API. ## Product Search The Product Search API transitioned from a powerful alternative for specific use cases into the definitive standard for high-performance storefront discovery. This evolution moved beyond simple keyword matching, introducing complex logic and native support for modern commerce requirements like typos, price statistics, and variant-specific filtering. One of the most significant updates to Product Search was the General Availability of Facets. This allows developers to retrieve aggregated counts and statistical data alongside search results, enabling the creation of dynamic sidebar filters without multiple API calls: - [Distinct Facets](/api/projects/product-search.md#distinct-facets): group results by Attributes like `brand`, `color`, or `material`. - [Ranges Facets](/api/projects/product-search.md#ranges-facets): counts Products that have values within a specified range. - [Count Facets](/api/projects/product-search.md#count-facets): counts the number of Products (or Product Variants). - [Stats Facets](/api/projects/product-search.md#stats-facets): dynamically calculate the `min`, `max`, and `average` values for numeric fields, such as prices or weights, across the entire search result set. Last year also introduced several features designed to reduce "no results found" pages and improve conversion rates: - [Fuzzy Search](/api/search-query-language.md#fuzzy): built-in tolerance for typos. If a customer searches for "coffe mug," the engine automatically identifies the intent for "coffee mug." - [Discounted price filtering](/api/projects/product-search.md#boolean-fields): you can now filter specifically for products where `discounted` is not null. This allows for the creation of high-performance "Sale" landing pages that update automatically as promotions are activated. Product Search's speed and efficiency stem from its ID-based nature: it returns only the IDs of matching products, requiring a subsequent call to the GraphQL API for detailed product information. The good news is that Product Search is now directly supported within the GraphQL API, allowing you to achieve everything in a single request! **Example:** Imagine a customer makes a typo, searching for "tarditional" instead of "traditional." A standard `fullText` query would likely return no results (unless, by chance, "tarditional" products exist in the catalog!). We can leverage the new `fuzzy` query feature to tolerate such mistakes. Furthermore, we can enrich the request to also fetch statistical data on the EUR prices for the German market for every matched product. Crucially, we will restrict the results to products without discounted prices. The most significant benefit is that the GraphQL API allows us to achieve all of this in one consolidated request. Before trying this example make sure that [Product Search is activated in your Project](/api/projects/product-search.md#activate-the-product-search-api). ```ts // Ensure all necessary imports are included and the API client is instantiated as demonstrated in the first example. // GraphQL query for fuzzy product search with price statistics facet const searchQuery = ` query FuzzySearchWithGermanPriceStats { productsSearch( query: { and: [ { fuzzy: { field: "name" value: "tarditional" level: 2 language: "en-US" } } { exact: { field: "variants.prices.discounted" value: false } } ] } facets: { stats: { name: "priceStatistics" field: "variants.prices.centAmount" filter: { and: [ { exact: { field: "variants.prices.currencyCode" value: "EUR" } } { exact: { field: "variants.prices.country" value: "DE" } } ] } } } ) { results { product { key } } facets { ... on ProductSearchFacetResultStats { name count sum min max mean } } } } `; // Execute the GraphQL query and print the results async function runFuzzyProductSearch(): Promise { try { const result = await apiRoot .graphql() .post({ body: { query: searchQuery } }) .execute(); // Log the product keys and price statistics from the search response console.log("Fuzzy search result:"); console.log(JSON.stringify(result.body.data, null, 2)); } catch (error) { console.error("Error executing product search:", error); } } // Run the search await runFuzzyProductSearch(); ``` ```java // Ensure all necessary imports are included and the API client is instantiated as demonstrated in the first example. // GraphQL query for fuzzy product search with price statistics facet String searchQuery = """ query FuzzySearchWithGermanPriceStats { productsSearch( query: { and: [ { fuzzy: { field: "name" value: "tarditional" level: 2 language: "en-US" } } { exact: { field: "variants.prices.discounted" value: false } } ] } facets: { stats: { name: "priceStatistics" field: "variants.prices.centAmount" filter: { and: [ { exact: { field: "variants.prices.currencyCode", value: "EUR" } } { exact: { field: "variants.prices.country", value: "DE" } } ] } } } ) { results { product { key } } facets { ... on ProductSearchFacetResultStats { name count sum min max mean } } } } """; try { // Build the GraphQL request object using the builder API GraphQLRequest request = GraphQLRequest.builder() .query(searchQuery) .build(); // Execute the request synchronously and get the GraphQLResponse GraphQLResponse response = apiRoot .graphql() .post(request) .executeBlocking() .getBody(); // response.getData() is an Object (POJO / Map). Convert it to JsonNode for pretty printing if (response == null || response.getData() == null) { System.out.println("Fuzzy search returned no data or an empty response."); } else { ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); JsonNode dataNode = mapper.convertValue(response.getData(), JsonNode.class); String pretty = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(dataNode); System.out.println("Fuzzy search result:\n" + JsonUtils.prettyPrint(pretty)); } } catch (Exception e) { System.err.println("Error executing product search: " + e.getMessage()); e.printStackTrace(); } ``` Fuzzy search result: ```json title="Result" { "productsSearch" : { "results" : [ { "product" : { "key" : "traditional-three-seater-sofa" } }, { "product" : { "key" : "traditional-l-seater-sofa" } } ], "facets" : [ { "name" : "priceStatistics", "count" : 2, "sum" : 599800.0, "min" : 239900.0, "max" : 359900.0, "mean" : 299900.0 } ] } } ``` The single API request performed exactly as intended. The response included data for two “traditional”, non-discounted products, along with statistics on their prices in EUR for the German market. These enhancements are expected to significantly simplify the development of product listing and search pages. ## Product Projection Search Searching for products via the `productProjectionSearch` query in GraphQL often targets specific variant Attributes (for example, "red shirt, size medium"). While the search itself correctly returns the parent product, the `variants` and `allVariants` fields on that product traditionally returned all variants, regardless of the search criteria. Previously, this necessitated implementing client-side filtering to isolate the few variants that actually matched the original query, adding unnecessary complexity and overhead to your application. This is no longer the case. In 2025, we introduced the `onlyMatching: Boolean` argument to the `variants` and `allVariants` fields within the `ProductProjection` type. By setting `onlyMatching: true`, you offload the filtering responsibility to the commercetools API. The response is cleaner and more efficient, as it only includes the Product Variants that matched your original `productProjectionSearch` query. **Example:** To demonstrate this elegant and efficient new feature, consider a product like the "Nala Two-Seater Sofa," which has seven variants (in various colors and finishes). If you search specifically for a blue sofa. Before trying this example make sure that [Product Projection Search is activated in your Project](/api/projects/product-projection-search.md#activate-the-product-projection-search-api). ```ts // Ensure all necessary imports are included and the API client is instantiated as demonstrated in the first example. /** * GraphQL query utilizing the 2025 search enhancements. * 1. 'markMatchingVariants: true' flags variants that meet the filter criteria. * 2. 'allVariants(onlyMatching: true)' filters the results on the server-side, * returning only the specific SKU(s) that match the 'blue' color filter. */ const searchQuery = ` query FindBlueSofa { productProjectionSearch( text: "sofa" locale: "en-US" filters: {model: {value: {path: "variants.attributes.search-color.key", values: "blue"}}} markMatchingVariants: true ) { results { key allVariants(onlyMatching: true) { sku } } } } `; /** * Executes the search and logs the filtered variant data. */ async function onlyMatchingVariantSearch(): Promise { try { const result = await apiRoot .graphql() .post({ body: { query: searchQuery } }) .execute(); // Log the refined data structure (showing only matching SKUs) console.log("Search result (Filtered to Matching Variants):"); console.log(JSON.stringify(result.body.data, null, 2)); } catch (error) { // Catch network or query syntax errors console.error("Error executing product search:", error); } } // Invoke the search function await onlyMatchingVariantSearch(); ``` ```java // Ensure all necessary imports are included and the API client is instantiated as demonstrated in the first example. /** * GraphQL query utilizing the 2025 search enhancements. * 1. 'markMatchingVariants: true' flags variants that meet the filter criteria. * 2. 'allVariants(onlyMatching: true)' filters the results on the server-side, * returning only the specific SKU(s) that match the 'blue' color filter. */ String searchQuery = """ query FindBlueSofa { productProjectionSearch( text: "sofa" locale: "en-US" filters: {model: {value: {path: "variants.attributes.search-color.key", values: "blue"}}} markMatchingVariants: true ) { results { key allVariants(onlyMatching: true) { sku } } } } """; try { // Build the GraphQL request object using the builder API GraphQLRequest request = GraphQLRequest.builder() .query(searchQuery) .build(); // Execute the request synchronously and get the GraphQLResponse GraphQLResponse response = apiRoot .graphql() .post(request) .executeBlocking() .getBody(); // response.getData() is an Object (POJO / Map). Convert it to JsonNode for pretty printing if (response == null || response.getData() == null) { System.out.println("Search returned no data or an empty response."); } else { ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); JsonNode dataNode = mapper.convertValue(response.getData(), JsonNode.class); String pretty = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(dataNode); System.out.println("Search result:\n" + JsonUtils.prettyPrint(pretty)); } } catch (Exception e) { System.err.println("Error executing search: " + e.getMessage()); e.printStackTrace(); } ``` Search result: ```json title="Result" { "productProjectionSearch" : { "results" : [ { "key" : "nala-two-seater-sofa", "allVariants" : [ { "sku" : "NTSS-02" }, { "sku" : "NTTS-04" } ] } ] } } ``` As we can see in the result, only variants matching our search criteria were returned. This new `onlyMatching: true` feature in `ProductProjectionSearch` is a significant developer quality-of-life improvement. By moving the filtering logic from your client application back to the high-performance commercetools API, you can drastically simplify your front-end code, reduce the burden on your application server, and ensure faster, leaner data payloads for a better customer experience. ## Related pages - [Area overview page with navigation](/certifications.md) - [Previous page: Release highlights of 2025](/certifications/composable-commerce-developer-refresher-2026/2025-release-highlights.md) - [Next page: Pricing and Discounts](/certifications/composable-commerce-developer-refresher-2026/pricing-and-discounts.md) - [Search documentation and API specs](/search.md)