Opportunity Detection & Recommendations

This section documents Spring Boot 4.1.x and Spring Framework 7.0.x on the Java 17/21+ baseline, as described by the official Spring Boot reference documentation and each sub-project’s own site (Spring Data, Spring for Apache Kafka, Micrometer, Project Reactor, springdoc-openapi, Spring gRPC, and the others listed in this section’s Bibliography) — which are the references these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Spring Boot and its ecosystem continue to evolve; the examples here target the current 4.1.x / 7.0.x releases.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page builds a recommendation and opportunity-detection feature on top of a purchase graph: "customers who bought what you bought also bought this", "which product categories does this company not buy yet", and "who can introduce us to that contact". The expensive part (similarity between customers) runs offline and is persisted; the request path only reads a few relationships. The GDS plumbing — the projection lifecycle, the GdsGraphs helper, memory estimation and scheduling with ShedLock — is defined once in Calling GDS from Spring Boot and is used here without being repeated.

Problem

A recommendation is structurally "find the customers most similar to this one and suggest what they bought that this one has not". The algorithmic side (Node Similarity, Filtered Node Similarity, KNN) is explained in the Recommendation engines section of the Neo4j Reference and in Similarity, Embeddings and ML Pipelines; this page is about the application around it. Four questions have to be answered:

  • Which model lets one graph serve recommendations, whitespace analysis and introductions?

  • Where does similarity live: computed per request (simple, slow) or precomputed and persisted (fast, stale by up to one schedule period)?

  • How is the result served with predictable latency, pagination and caching?

  • How is it tested against a real server with the GDS plugin?

The figure shows the answer used throughout the page: an offline job turns purchases into SIMILAR relationships, and the online path only reads them.

Pipeline from purchases through similarity and persisted SIMILAR relationships to a cached query and the REST API

Graph model

flowchart LR Customer((Customer)) Contact((Contact)) Company((Company)) Product((Product)) Category((Category)) Customer -->|PURCHASED| Product Customer -->|WORKS_AT| Company Contact -->|WORKS_AT| Company Contact -->|KNOWS| Contact Product -->|IN_CATEGORY| Category Customer -.->|SIMILAR score| Customer

A Customer is a buyer account that works at a Company; a Contact is a person we know (a sales rep or a prospect’s employee) who KNOWS other contacts. PURCHASED carries the quantity and date and is a relationship properties class, IN_CATEGORY classifies products, and SIMILAR is derived data written by the offline job, never by the application code that records purchases. The mapping annotations are described in Spring Data Neo4j; the entities are:

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;
import org.springframework.data.neo4j.core.schema.Relationship;
import org.springframework.data.neo4j.core.schema.RelationshipId;
import org.springframework.data.neo4j.core.schema.RelationshipProperties;
import org.springframework.data.neo4j.core.schema.TargetNode;

@Node("Customer")
public class Customer {

    @Id
    private String id;

    private String name;

    @Relationship(type = "WORKS_AT", direction = Relationship.Direction.OUTGOING)
    private Company company;

    @Relationship(type = "PURCHASED", direction = Relationship.Direction.OUTGOING)
    private List<Purchase> purchases = new ArrayList<>();

    protected Customer() {
    }

    public Customer(String id, String name, Company company) {
        this.id = id;
        this.name = name;
        this.company = company;
    }

    public void purchase(Product product, int quantity, LocalDate date) {
        purchases.add(new Purchase(product, quantity, date));
    }

    public String getId() { return id; }
    public String getName() { return name; }
}

@Node("Company")
public class Company {

    @Id
    private String id;
    private String name;
    private String segment;

    protected Company() {
    }

    public Company(String id, String name, String segment) {
        this.id = id;
        this.name = name;
        this.segment = segment;
    }
}

@Node("Product")
public class Product {

    @Id
    private String id;
    private String title;

    @Relationship(type = "IN_CATEGORY", direction = Relationship.Direction.OUTGOING)
    private Category category;

    protected Product() {
    }

    public Product(String id, String title, Category category) {
        this.id = id;
        this.title = title;
        this.category = category;
    }
}

@Node("Category")
public class Category {

    @Id
    private String id;
    private String name;

    protected Category() {
    }

    public Category(String id, String name) {
        this.id = id;
        this.name = name;
    }
}

@Node("Contact")
public class Contact {

    @Id
    private String id;
    private String name;

    @Relationship(type = "WORKS_AT", direction = Relationship.Direction.OUTGOING)
    private Company company;

    @Relationship(type = "KNOWS", direction = Relationship.Direction.OUTGOING)
    private List<Contact> knows = new ArrayList<>();

    protected Contact() {
    }

    public Contact(String id, String name, Company company) {
        this.id = id;
        this.name = name;
        this.company = company;
    }
}

@RelationshipProperties
public class Purchase {

    @RelationshipId
    private Long id;

    @TargetNode
    private Product product;

    private int quantity;
    private LocalDate purchasedAt;

    protected Purchase() {
    }

    public Purchase(Product product, int quantity, LocalDate purchasedAt) {
        this.product = product;
        this.quantity = quantity;
        this.purchasedAt = purchasedAt;
    }
}

See Spring Data Neo4j — Metadata-based mapping for @Node, @Relationship and @RelationshipProperties. Business keys (id) are used as @Id because the recommendation job and the REST API address customers by them; the database-internal identity is elementId(), never id(). SIMILAR is deliberately not mapped on Customer: loading an aggregate must not drag the derived edges along, and the queries that read them are explicit Cypher.

Mapping PURCHASED as a collection of Purchase makes save(customer) rewrite the customer’s purchases. For a high-volume purchase feed use a dedicated write (next sections) instead of loading and saving the aggregate.

Schema

Constraints and indexes are created at startup with idempotent IF NOT EXISTS statements, so that every deployment converges on the same schema. Uniqueness constraints give the MERGE of the write path an index to seek through; the range index on purchasedAt serves time-window queries:

import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.neo4j.core.Neo4jClient;

@Configuration
public class RecommendationSchema {

    private static final List<String> STATEMENTS = List.of(
        "CREATE CONSTRAINT customer_id IF NOT EXISTS FOR (c:Customer) REQUIRE c.id IS UNIQUE",
        "CREATE CONSTRAINT product_id IF NOT EXISTS FOR (p:Product) REQUIRE p.id IS UNIQUE",
        "CREATE CONSTRAINT company_id IF NOT EXISTS FOR (c:Company) REQUIRE c.id IS UNIQUE",
        "CREATE CONSTRAINT category_id IF NOT EXISTS FOR (c:Category) REQUIRE c.id IS UNIQUE",
        "CREATE CONSTRAINT contact_id IF NOT EXISTS FOR (c:Contact) REQUIRE c.id IS UNIQUE",
        "CREATE INDEX purchased_at IF NOT EXISTS FOR ()-[p:PURCHASED]-() ON (p.purchasedAt)");

    @Bean
    ApplicationRunner createRecommendationSchema(Neo4jClient client) {
        return args -> STATEMENTS.forEach(statement -> client.query(statement).run());
    }
}

Each statement is the form documented in Cypher manual — Constraints and Cypher manual — Indexes; the background (and the option of letting SDN create constraints itself) is in Indexes and Constraints. Running schema statements from application code is fine for a single service; with several services sharing the database, own the schema in one of them or in a migration tool.

Writes

Recording a purchase is a small, idempotent write. A MERGE keyed on the business ids makes a replayed event harmless (a retried Kafka message, see Kafka messaging), and it avoids loading the customer aggregate:

@Service
public class PurchaseWriter {

    private final Neo4jClient client;

    public PurchaseWriter(Neo4jClient client) {
        this.client = client;
    }

    @Transactional
    public void record(String customerId, String productId, int quantity, LocalDate date) {
        client.query("""
                MATCH (c:Customer {id: $customerId}), (p:Product {id: $productId})
                MERGE (c)-[purchase:PURCHASED]->(p)
                  ON CREATE SET purchase.quantity = $quantity, purchase.purchasedAt = $date
                  ON MATCH SET purchase.quantity = purchase.quantity + $quantity
                """)
            .bind(customerId).to("customerId")
            .bind(productId).to("productId")
            .bind(quantity).to("quantity")
            .bind(date).to("date")
            .run();
    }
}

The statement is an ordinary parameterised MERGE as described in Cypher manual — MERGE, executed through the Neo4jClient of Spring Data Neo4j — reference guide. Note that nothing here touches SIMILAR: similarity is recomputed by the job in the next section, so the OLTP write stays one cheap statement.

Queries and analytics

Plain Cypher collaborative filtering

Before reaching for GDS, the classic "people who bought the same also bought" query is plain Cypher, and for a graph of modest size it is the right answer. It counts how many neighbours bought each candidate product and is exposed as a repository method returning a DTO projection:

import org.springframework.data.neo4j.repository.Neo4jRepository;
import org.springframework.data.neo4j.repository.query.Query;
import org.springframework.data.repository.query.Param;

public record ProductScore(String productId, String title, double score) {
}

public interface CustomerRepository extends Neo4jRepository<Customer, String> {

    @Query("""
        MATCH (me:Customer {id: $customerId})-[:PURCHASED]->(:Product)<-[:PURCHASED]-(peer:Customer)
        WHERE peer <> me
        MATCH (peer)-[:PURCHASED]->(candidate:Product)
        WHERE NOT (me)-[:PURCHASED]->(candidate)
        RETURN candidate.id AS productId, candidate.title AS title, count(DISTINCT peer) AS score
        ORDER BY score DESC, title
        SKIP $skip LIMIT $limit
        """)
    List<ProductScore> alsoBought(@Param("customerId") String customerId,
                                  @Param("skip") long skip,
                                  @Param("limit") int limit);
}

The method uses @Query and a DTO projection (the record’s constructor parameters are matched to the returned column names), both described in Spring Data Neo4j — Query creation and the Projections section of the Spring Data Neo4j page. It has two limits that motivate the next step: it is computed per request, and it ranks by a raw count of neighbours, so a peer who bought one overlapping product weighs as much as one who bought twenty. Similarity scores fix the weighting; precomputing them fixes the latency.

Filtered Node Similarity written back as SIMILAR

The offline job projects the purchase graph, runs Filtered Node Similarity restricted to customers on both sides, and writes the result as SIMILAR relationships with a score property. The projection is a native one (PURCHASED between Customer and Product, undirected so that the similarity can use either direction), created with the helper of the foundation page:

public record SimilarityRun(long nodesCompared, long relationshipsWritten) {
}

@Service
public class SimilarityService {

    private final Neo4jClient client;
    private final GdsGraphs graphs;
    private final GdsMemoryGuard memoryGuard;
    private final CacheManager cacheManager;

    public SimilarityService(Neo4jClient client, GdsGraphs graphs, GdsMemoryGuard memoryGuard,
                             CacheManager cacheManager) {
        this.client = client;
        this.graphs = graphs;
        this.memoryGuard = memoryGuard;
        this.cacheManager = cacheManager;
    }

    public SimilarityRun recomputeSimilarity() {
        var labels = List.of("Customer", "Product");
        var relationships = Map.<String, Object>of("PURCHASED", Map.of("orientation", "UNDIRECTED"));
        memoryGuard.requireFits(labels, relationships);

        try (var graph = graphs.projectNative("purchases", labels, relationships)) {
            deleteExistingSimilarity();
            var run = client
                .query("""
                    CALL gds.nodeSimilarity.filtered.write($graph, {
                        sourceNodeFilter: 'Customer',
                        targetNodeFilter: 'Customer',
                        topK: $topK,
                        similarityCutoff: $cutoff,
                        writeRelationshipType: 'SIMILAR',
                        writeProperty: 'score'
                    })
                    YIELD nodesCompared, relationshipsWritten
                    RETURN nodesCompared, relationshipsWritten
                    """)
                .bind(graph.name()).to("graph")
                .bind(10).to("topK")
                .bind(0.1).to("cutoff")
                .fetchAs(SimilarityRun.class)
                .mappedBy((typeSystem, record) -> new SimilarityRun(
                    record.get("nodesCompared").asLong(), record.get("relationshipsWritten").asLong()))
                .one()
                .orElseThrow();
            evictRecommendations();
            return run;
        }
    }

    private void deleteExistingSimilarity() {
        long deleted;
        do {
            deleted = client
                .query("""
                    MATCH ()-[similar:SIMILAR]->()
                    WITH similar LIMIT 10000
                    DELETE similar
                    RETURN count(*) AS deleted
                    """)
                .fetchAs(Long.class)
                .mappedBy((typeSystem, record) -> record.get("deleted").asLong())
                .one()
                .orElse(0L);
        } while (deleted > 0);
    }

    private void evictRecommendations() {
        var cache = cacheManager.getCache("recommendations");
        if (cache != null) {
            cache.clear();
        }
    }
}

The procedure is gds.nodeSimilarity.filtered.write (no tier label in the manual) with the sourceNodeFilter and targetNodeFilter options, the topK and similarityCutoff parameters of Node Similarity and the standard writeRelationshipType / writeProperty write options; the filters accept a node label, a node id or a list of ids. See GDS — Filtered Node Similarity. The lifecycle (projectNative, the try-with-resources drop and the estimate guard) is explained in Calling GDS from Spring Boot.

Three decisions in the listing are worth stating:

  • The old SIMILAR edges are deleted first, in batches, because write mode adds relationships and never removes the ones that no longer qualify. Deleting inside the job means a customer whose peers changed does not keep stale neighbours. Each loop iteration is one statement that deletes at most 10,000 relationships, so no single transaction grows with the size of the graph. The Cypher-only alternative, CALL { ... } IN TRANSACTIONS, runs only in an implicit (auto-commit) transaction, which is why the loop is used here instead; see Cypher manual — CALL subqueries in transactions and Cypher manual — DELETE.

  • topK and similarityCutoff bound the output: at most ten SIMILAR edges per customer, none weaker than the cutoff. This bounds both the write volume and the cost of the online read.

  • The cache is cleared when the job has written, so the next request sees the new edges (see Service and API).

Filtered Node Similarity is the form to use when the comparison should be limited to a set of source nodes, for example only customers with recent purchases (sourceNodeFilter takes ids) or one customer on demand. K-Nearest Neighbors approximates the same neighbourhood without comparing every pair and works on node properties rather than shared neighbours, so it needs a numeric vector on Customer, for example a purchase profile produced by an upstream job. The only change in the job is the procedure and its required nodeProperties:

CALL gds.knn.write($graph, {
  nodeLabels: ['Customer'],
  nodeProperties: ['profile'],
  topK: 10,
  writeRelationshipType: 'SIMILAR',
  writeProperty: 'score'
})
YIELD nodesCompared, relationshipsWritten

randomSeed makes a KNN run repeatable only together with concurrency: 1. See GDS — K-Nearest Neighbors.

Scheduling the job

The job follows the pattern of the foundation page: a @Scheduled trigger guarded by a ShedLock lock whose lockAtMostFor is longer than the longest realistic run (scheduling and lock configuration are in Scheduling and ShedLock):

@Component
public class SimilarityJob {

    private static final Logger log = LoggerFactory.getLogger(SimilarityJob.class);

    private final SimilarityService similarity;

    public SimilarityJob(SimilarityService similarity) {
        this.similarity = similarity;
    }

    @Scheduled(cron = "${recommendations.similarity.cron:0 30 3 * * *}")
    @SchedulerLock(name = "recommendation-similarity", lockAtMostFor = "PT1H", lockAtLeastFor = "PT5M")
    public void run() {
        try {
            log.info("Similarity recomputed: {}", similarity.recomputeSimilarity());
        } catch (AnalyticsRejectedException e) {
            log.warn("Similarity recomputation skipped: {}", e.getMessage());
        }
    }
}

See Spring Framework — Task Execution and Scheduling and ShedLock. The recommendations served between two runs are as old as the last run; the schedule is the freshness contract and belongs in the API documentation.

The cheap OLTP query over SIMILAR

With the edges persisted, a recommendation is a short traversal: the customer’s few SIMILAR neighbours and the products those neighbours bought. Neighbours are weighted by their score, which is what the Neo4j Reference query does with sum(similarity):

public interface RecommendationRepository extends Neo4jRepository<Customer, String> {

    @Query("""
        MATCH (me:Customer {id: $customerId})-[similar:SIMILAR]->(peer:Customer)
        MATCH (peer)-[:PURCHASED]->(candidate:Product)
        WHERE NOT (me)-[:PURCHASED]->(candidate)
        RETURN candidate.id AS productId, candidate.title AS title, sum(similar.score) AS score
        ORDER BY score DESC, title
        SKIP $skip LIMIT $limit
        """)
    List<ProductScore> recommendations(@Param("customerId") String customerId,
                                       @Param("skip") long skip,
                                       @Param("limit") int limit);
}

The query is a plain @Query as in the previous listing (Spring Data Neo4j — reference guide); the tie-break on title makes paging deterministic, since SKIP/LIMIT over an ordering with ties can repeat or lose rows between pages. The Cypher semantics are in Cypher manual — SKIP and Cypher manual — LIMIT.

Whitespace analysis

Whitespace is the part of a company’s potential spend it does not buy yet. In graph terms: product categories that the company’s similar customers buy, and that the company itself does not. The query below counts, per company and category, how many distinct similar peers bought in the category while nobody at the company did:

public record Whitespace(String companyId, String category, long peers) {
}

@Repository
public class WhitespaceQueries {

    private final Neo4jClient client;

    public WhitespaceQueries(Neo4jClient client) {
        this.client = client;
    }

    @Transactional(readOnly = true)
    public List<Whitespace> forCompany(String companyId, int limit) {
        return client
            .query("""
                MATCH (company:Company {id: $companyId})<-[:WORKS_AT]-(:Customer)-[:PURCHASED]->(:Product)
                      -[:IN_CATEGORY]->(owned:Category)
                WITH company, collect(DISTINCT owned) AS ownedCategories
                MATCH (company)<-[:WORKS_AT]-(:Customer)-[:SIMILAR]->(peer:Customer)
                      -[:PURCHASED]->(:Product)-[:IN_CATEGORY]->(gap:Category)
                WHERE NOT gap IN ownedCategories
                RETURN company.id AS companyId, gap.name AS category, count(DISTINCT peer) AS peers
                ORDER BY peers DESC, category
                LIMIT $limit
                """)
            .bind(companyId).to("companyId")
            .bind(limit).to("limit")
            .fetchAs(Whitespace.class)
            .mappedBy((typeSystem, record) -> new Whitespace(
                record.get("companyId").asString(), record.get("category").asString(),
                record.get("peers").asLong()))
            .all()
            .stream()
            .toList();
    }
}

Both MATCH clauses are ordinary pattern matching with collect(DISTINCT ...) and a list-membership test (Cypher manual — MATCH); the result mapping is the Neo4jClient API of the foundation page. The method is read-only and cheap because it rides on the persisted SIMILAR edges. A company with no purchases at all returns nothing here, since there is no owned anchor; for "new logo" prospecting start from the category side instead.

Opportunities are also relationships that do not exist yet. For the contact graph (KNOWS) GDS offers two levels:

  • Topological functions, scored for a given pair of nodes with no training: gds.linkprediction.adamicAdar and gds.linkprediction.commonNeighbors. They are functions, so they are used inside a normal query. The function pages carry no tier label, but the algorithm overview lists the topological link prediction family as Alpha; treat the names as subject to change.

  • Link prediction pipelines (tier Beta), which train a model on node properties and topology features and predict new relationships: gds.beta.pipeline.linkPrediction.create, .addNodeProperty, .addFeature, .configureSplit, .addLogisticRegression / .addRandomForest, then gds.beta.pipeline.linkPrediction.train and gds.beta.pipeline.linkPrediction.predict.stream / .predict.mutate.

The topological score for a pair needs no projection, because the function takes a relationshipQuery that names the relationship type and a direction:

public record IntroductionScore(String contactId, String name, double score) {
}

@Transactional(readOnly = true)
public List<IntroductionScore> likelyConnections(String contactId, int limit) {
    return client
        .query("""
            MATCH (me:Contact {id: $contactId})-[:KNOWS]-()-[:KNOWS]-(candidate:Contact)
            WHERE candidate <> me AND NOT (me)-[:KNOWS]-(candidate)
            WITH DISTINCT me, candidate
            RETURN candidate.id AS contactId, candidate.name AS name,
                   gds.linkprediction.adamicAdar(me, candidate, {relationshipQuery: 'KNOWS', direction: 'BOTH'}) AS score
            ORDER BY score DESC
            LIMIT $limit
            """)
        .bind(contactId).to("contactId")
        .bind(limit).to("limit")
        .fetchAs(IntroductionScore.class)
        .mappedBy((typeSystem, record) -> new IntroductionScore(
            record.get("contactId").asString(), record.get("name").asString(), record.get("score").asDouble()))
        .all()
        .stream()
        .toList();
}

The candidate set (friends of friends who are not already friends) is restricted in plain Cypher first, so the function runs on a handful of pairs rather than on every pair in the graph. See GDS — Adamic Adar and GDS — Common Neighbors.

For a learned model, the pipeline is configured once and trained offline like the similarity job. The statements below are the sequence of the Beta pipeline procedures (the pipeline and model names are parameters you choose; the projection comes from the helper of the foundation page):

CALL gds.beta.pipeline.linkPrediction.create('contact-links');
CALL gds.beta.pipeline.linkPrediction.addNodeProperty('contact-links', 'fastRP', {
  mutateProperty: 'embedding', embeddingDimension: 64, randomSeed: 42
});
CALL gds.beta.pipeline.linkPrediction.addFeature('contact-links', 'hadamard', {nodeProperties: ['embedding']});
CALL gds.beta.pipeline.linkPrediction.configureSplit('contact-links', {testFraction: 0.2, trainFraction: 0.6, validationFolds: 3});
CALL gds.beta.pipeline.linkPrediction.addLogisticRegression('contact-links');

CALL gds.beta.pipeline.linkPrediction.train($graph, {
  pipeline: 'contact-links', modelName: 'contact-links-model',
  targetRelationshipType: 'KNOWS', randomSeed: 42
});

CALL gds.beta.pipeline.linkPrediction.predict.stream($graph, {
  modelName: 'contact-links-model', topN: 20, threshold: 0.5
})
YIELD node1, node2, probability;

The pipeline, train and predict steps are described in GDS — Link prediction pipelines and the surrounding workflow in Similarity, Embeddings and ML Pipelines. Training and prediction run on a projected graph, so they belong in the same kind of background job as the similarity run, never in a request. Because the whole family is Alpha or Beta, wrap the calls in one adapter class and pin the Neo4j image tag so that an upgrade is a deliberate change.

For a warm introduction — the shortest chain of acquaintances from one of our contacts to a target contact — no GDS is needed; the built-in shortestPath over KNOWS answers it, and it avoids a projection entirely:

public record IntroductionPath(List<String> contactIds) {
}

@Transactional(readOnly = true)
public Optional<IntroductionPath> warmIntroduction(String fromId, String toId, int maxHops) {
    return client
        .query("""
            MATCH (from:Contact {id: $fromId}), (to:Contact {id: $toId})
            MATCH path = shortestPath((from)-[:KNOWS*..10]-(to))
            WHERE length(path) <= $maxHops
            RETURN [contact IN nodes(path) | contact.id] AS contactIds
            """)
        .bind(fromId).to("fromId")
        .bind(toId).to("toId")
        .bind(maxHops).to("maxHops")
        .fetchAs(IntroductionPath.class)
        .mappedBy((typeSystem, record) -> new IntroductionPath(
            record.get("contactIds").asList(Value::asString)))
        .one();
}

This is shortestPath as documented in Cypher manual — Shortest paths; the variable length bound is a literal because Cypher does not accept a parameter there, so the maxHops parameter filters the result after the search. When introductions should prefer strong relationships over short ones, add a numeric strength to KNOWS and run Dijkstra (gds.shortestPath.dijkstra.stream with relationshipWeightProperty) on a projection, as in Pathfinding and Centrality Algorithms. The Value type is org.neo4j.driver.Value.

Service and API

The service is the only place where caching and paging rules live. It reads from the persisted SIMILAR edges, caches by customer and page, and the job of the previous section clears the cache once it has written new edges. Caching is configured as described in Caching:

public record RecommendationPage(String customerId, int page, int size, List<ProductScore> items) {
}

@Service
public class RecommendationService {

    static final int MAX_PAGE_SIZE = 50;

    private final RecommendationRepository repository;

    public RecommendationService(RecommendationRepository repository) {
        this.repository = repository;
    }

    @Cacheable(cacheNames = "recommendations", key = "#customerId + ':' + #page + ':' + #size")
    @Transactional(readOnly = true)
    public RecommendationPage recommend(String customerId, int page, int size) {
        int boundedSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE);
        int boundedPage = Math.max(page, 0);
        var items = repository.recommendations(customerId, (long) boundedPage * boundedSize, boundedSize);
        return new RecommendationPage(customerId, boundedPage, boundedSize, items);
    }
}

See Spring Framework — Declarative annotation-based caching. The key must include every argument that changes the result, and the page size is bounded in the service so that no client can ask for the whole catalogue. A cache keyed on the bounded values would hit more often; normalising before the cached call is a refinement if the raw-argument keys fragment. The cache store matters on a scaled-out service: a local Caffeine cache is cleared only on the instance that ran the job, so either use a shared cache (Redis) or a short time-to-live that bounds staleness on the other instances; both options are compared in Caching.

The controller exposes it with query parameters for paging, following the conventions of REST APIs with Spring MVC and WebFlux:

@RestController
@RequestMapping("/customers/{customerId}")
public class RecommendationController {

    private final RecommendationService recommendations;

    public RecommendationController(RecommendationService recommendations) {
        this.recommendations = recommendations;
    }

    @GetMapping("/recommendations")
    public RecommendationPage recommendations(@PathVariable String customerId,
                                              @RequestParam(defaultValue = "0") int page,
                                              @RequestParam(defaultValue = "10") int size) {
        return recommendations.recommend(customerId, page, size);
    }
}

See Spring Framework — Annotated controllers. For an unknown customer the endpoint returns an empty page; whether that should be a 404 is a product decision, and a lookup by existsById before the query makes it explicit. Securing the endpoint by customer ownership is covered in Spring Security, and the latency of the cached and uncached paths can be watched with the timers of Metrics and Observability.

Integration test

The test starts a Neo4j server with the GDS and APOC plugins, loads the dataset of the Recommendation engines section (three customers and three products: Ada bought Nine Lives and Red Horizon, Bo bought all three, Cy bought Glass Orchard only) and asserts the results. The Neo4j Reference page models the three people with a WATCHED relationship to Movie nodes; here the same shape is purchases of Product nodes, with the product titles kept. The Jaccard similarities follow directly from the data: Ada and Bo share two of three products (2/3), Bo and Cy share one of three (1/3), and Ada and Cy share none, so Ada gets one recommendation, Glass Orchard, and Cy gets the two products Bo has that Cy lacks. The container setup, the plugin variable and the version pairing are those of the setup notes; test fundamentals are in Unit and Integration Testing.

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.data.neo4j.core.Neo4jClient;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.neo4j.Neo4jContainer;

@Testcontainers
@SpringBootTest
class RecommendationIntegrationTest {

    @Container
    @ServiceConnection
    static final Neo4jContainer NEO4J = new Neo4jContainer("neo4j:2026.10.1")
        .withEnv("NEO4J_PLUGINS", "[\"graph-data-science\", \"apoc\"]");

    @Autowired
    Neo4jClient client;

    @Autowired
    SimilarityService similarity;

    @Autowired
    RecommendationService recommendations;

    @Autowired
    CustomerRepository customers;

    @BeforeEach
    void loadDataset() {
        client.query("MATCH (n) DETACH DELETE n").run();
        client.query("""
            CREATE (ada:Customer {id: 'ada', name: 'Ada'})
            CREATE (bo:Customer {id: 'bo', name: 'Bo'})
            CREATE (cy:Customer {id: 'cy', name: 'Cy'})
            CREATE (m1:Product {id: 'nine-lives', title: 'Nine Lives'})
            CREATE (m2:Product {id: 'red-horizon', title: 'Red Horizon'})
            CREATE (m3:Product {id: 'glass-orchard', title: 'Glass Orchard'})
            CREATE (ada)-[:PURCHASED {quantity: 1}]->(m1)
            CREATE (ada)-[:PURCHASED {quantity: 1}]->(m2)
            CREATE (bo)-[:PURCHASED {quantity: 1}]->(m1)
            CREATE (bo)-[:PURCHASED {quantity: 1}]->(m2)
            CREATE (bo)-[:PURCHASED {quantity: 1}]->(m3)
            CREATE (cy)-[:PURCHASED {quantity: 1}]->(m3)
            """).run();
    }

    @Test
    void plainCypherRecommendsTheProductOnlyBoBought() {
        var items = customers.alsoBought("ada", 0, 10);

        assertThat(items).extracting(ProductScore::title).containsExactly("Glass Orchard");
    }

    @Test
    void similarityIsWrittenAsSimilarRelationships() {
        var run = similarity.recomputeSimilarity();

        assertThat(run.relationshipsWritten()).isEqualTo(4);
        var adaToBo = client
            .query("MATCH (:Customer {id: 'ada'})-[s:SIMILAR]->(:Customer {id: 'bo'}) RETURN s.score AS score")
            .fetchAs(Double.class)
            .mappedBy((typeSystem, record) -> record.get("score").asDouble())
            .one()
            .orElseThrow();
        assertThat(adaToBo).isCloseTo(2.0 / 3.0, within(1e-6));
        var adaToCy = client
            .query("MATCH (:Customer {id: 'ada'})-[s:SIMILAR]->(:Customer {id: 'cy'}) RETURN count(s) AS n")
            .fetchAs(Long.class)
            .mappedBy((typeSystem, record) -> record.get("n").asLong())
            .one()
            .orElseThrow();
        assertThat(adaToCy).isZero();
    }

    @Test
    void servesRecommendationsFromPersistedSimilarity() {
        similarity.recomputeSimilarity();

        var forAda = recommendations.recommend("ada", 0, 10);
        assertThat(forAda.items()).extracting(ProductScore::title).containsExactly("Glass Orchard");
        assertThat(forAda.items().get(0).score()).isCloseTo(2.0 / 3.0, within(1e-6));

        var forCy = recommendations.recommend("cy", 0, 10);
        assertThat(forCy.items()).extracting(ProductScore::title)
            .containsExactlyInAnyOrder("Nine Lives", "Red Horizon");
    }

    @Test
    void secondPageOfCyIsEmptyAndPageSizeIsBounded() {
        similarity.recomputeSimilarity();

        assertThat(recommendations.recommend("cy", 1, 2).items()).isEmpty();
        assertThat(recommendations.recommend("cy", 0, 1_000).size()).isEqualTo(50);
    }
}

The class uses @ServiceConnection with Neo4jContainer from org.testcontainers.neo4j, as described in Spring Boot — Testcontainers and the Testcontainers — Neo4j module. A few details keep it deterministic: the dataset is rebuilt before every test, Node Similarity is exact and deterministic (so the scores can be compared with a tolerance, unlike the community ids of Louvain), and the assertions on the Cy recommendations use containsExactlyInAnyOrder because both products have the same score (1/3). Because the recommendations cache survives between tests in the same context, the test class either uses a test configuration with a no-op cache manager or relies on the job clearing the cache on every recomputeSimilarity(), which is why each test calls it first.

Pitfalls and variations

  • Stale recommendations are a feature, not a bug. The service answers with the data of the last job. Say so in the API contract, expose the job’s finish time, and make the schedule configurable (recommendations.similarity.cron).

  • Do not recompute on the request thread. A projection plus Node Similarity inside a request holds a connection for its whole duration and leaks the projection when the request dies; keep it in the job (Calling GDS from Spring Boot).

  • Popular products and super-buyers dominate. A customer who bought everything is similar to everyone, and a best-seller is recommended to everyone. Cap the number of purchases per customer in the projection filter, exclude products above a popularity threshold, or weight by inverse popularity.

  • Cold start. A customer with no purchases has no SIMILAR edges, so the personalised query returns nothing. Fall back to the most purchased products of the customer’s company or segment, which also come from the graph.

  • Write mode adds, it does not reconcile. Without the delete step of the job, a relationship that no longer qualifies stays forever. If a recomputation must be atomic for readers, write to a versioned relationship type (SIMILAR_V2) and switch the query once the write is complete.

  • SKIP pagination degrades with depth. Page numbers are fine for the first few pages of a recommendation list; for a long feed, return a keyset (the score and id of the last item) instead of a page number.

  • Treat Alpha and Beta procedures as unstable. The topological link prediction functions and the link prediction pipelines may change between GDS releases; isolate them behind a single class, cover it with the integration test, and pin the Neo4j image tag.

  • Never id(). Return business keys or elementId(...) from queries; id() is deprecated and not stable across stores.

  • Variations. Restrict the source set of Filtered Node Similarity to active customers (ids in sourceNodeFilter), recompute incrementally for changed customers only, or replace the neighbour-based score by a KNN on embeddings when purchase overlap is sparse. The general OLTP side of the model is in Spring Data Neo4j.