Logistics & Supply Chain

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 the logistics slice of a Spring Boot service on Neo4j: a road network that is queried for the best route and its alternatives, a supply chain of suppliers, parts, products and warehouses that is queried for the nearest warehouse with stock and for the products a supplier outage would hit, and a scheduled job that finds the roads and cities the network depends on most. The Graph Data Science plumbing (the project, run and drop lifecycle, the GdsGraphs helper, memory estimates and scheduling with ShedLock) is the one described in Calling GDS from Spring Boot and is used here without being repeated.

Problem

Vehicle routing and delivery planning reduce, at their core, to weighted shortest-path search over a location graph: the source is a depot, the target a delivery address, and the weight is distance, drive time or a blend of both. The same company also needs to know where a delivery should start (which warehouse is closest and actually has the goods), what a disruption upstream breaks (a supplier that stops delivering a part), and which places in the network are bottlenecks. The scenario, the road dataset and the algorithms are introduced in Routing and logistics optimization in the Neo4j Reference, and the algorithms themselves in Pathfinding and Centrality Algorithms. This page shows how to expose them from an application:

Question Technique Where it runs

Best route between two places

Dijkstra with the road distance as weight

GDS stream, synchronous, small projection

Faster search on a coordinate-aware network

A* with latitude and longitude

GDS stream

Backup routes

Yen’s k-shortest paths

GDS stream

Nearest warehouse with stock

point.distance plus an inventory filter

Plain Cypher on an indexed Point

What a supplier outage breaks

Bounded variable-length traversal

Plain Cypher

Which place is a bottleneck

Betweenness Centrality, written back

GDS write, nightly job under ShedLock

Graph model

The road network is a graph of Location nodes joined by ROAD relationships that carry the distance and the travel time; the supply chain hangs off the same graph through warehouses placed at locations.

The road network of five Italian cities with the 450 km Dijkstra route Turin, Milan, Bologna, Florence highlighted and the 570 km alternative through Genoa dashed

The figure shows the roads dataset of the Neo4j Reference: five locations and five roads, with the best route from Turin to Florence highlighted. The complete domain is:

flowchart LR Supplier -- SUPPLIES --> Part Part -- PART_OF --> Part Part -- PART_OF --> Product Warehouse -- STOCKS --> Product Warehouse -- LOCATED_AT --> Location Warehouse -- SHIPS_TO --> Customer Location -- ROAD --> Location

Locations are the only nodes that carry geography. A location keeps its coordinates twice, on purpose: as a Point for spatial queries, and as plain numeric latitude and longitude properties because a GDS projection can only carry numeric properties and A* reads exactly those two. The ROAD relationship needs a relationship property class, annotated with @RelationshipProperties, because it carries data of its own:

import java.util.ArrayList;
import java.util.List;

import org.springframework.data.annotation.Id;
import org.springframework.data.geo.Point;
import org.springframework.data.neo4j.core.schema.Node;
import org.springframework.data.neo4j.core.schema.Property;
import org.springframework.data.neo4j.core.schema.Relationship;
import org.springframework.data.neo4j.core.schema.RelationshipProperties;
import org.springframework.data.neo4j.core.schema.TargetNode;

@Node("Location")
public class Location {

    @Id
    private final String name;

    private final Point location;          // x = longitude, y = latitude, WGS-84
    private final double latitude;         // numeric copies, projected into GDS for A*
    private final double longitude;

    @Relationship(type = "ROAD", direction = Relationship.Direction.OUTGOING)
    private final List<Road> roads = new ArrayList<>();

    public Location(String name, double latitude, double longitude) {
        this.name = name;
        this.latitude = latitude;
        this.longitude = longitude;
        this.location = new Point(longitude, latitude);
    }

    public Location connectTo(Location other, double distance, double travelTime) {
        roads.add(new Road(other, distance, travelTime));
        return this;
    }

    public String getName() {
        return name;
    }

    public Point getLocation() {
        return location;
    }

    public List<Road> getRoads() {
        return roads;
    }
}

@RelationshipProperties
public class Road {

    @TargetNode
    private final Location target;

    @Property("distance")
    private final double distance;         // kilometres

    @Property("travelTime")
    private final double travelTime;       // minutes

    public Road(Location target, double distance, double travelTime) {
        this.target = target;
        this.distance = distance;
        this.travelTime = travelTime;
    }

    public Location getTarget() {
        return target;
    }

    public double getDistance() {
        return distance;
    }

    public double getTravelTime() {
        return travelTime;
    }
}

The annotations and the relationship property pattern are those of Modeling relationships with @Relationship; the Point mapping is the one of Geospatial queries. The location’s name is the business key and the @Id, so there is no generated identifier to leak into the API. See Spring Data Neo4j — Metadata-based mapping and Spring Data Neo4j — Query creation.

The remaining entities are ordinary nodes. A warehouse is placed at a location and stocks products with a quantity, so the STOCKS relationship is a second relationship property class; the bill of materials is a PART_OF chain from a part through sub-assemblies up to a product:

@Node("Warehouse")
public class Warehouse {

    @Id
    private final String code;

    private final String name;

    @Relationship(type = "LOCATED_AT", direction = Relationship.Direction.OUTGOING)
    private final Location site;

    @Relationship(type = "STOCKS", direction = Relationship.Direction.OUTGOING)
    private final List<Stock> stock = new ArrayList<>();

    @Relationship(type = "SHIPS_TO", direction = Relationship.Direction.OUTGOING)
    private final List<Customer> customers = new ArrayList<>();

    public Warehouse(String code, String name, Location site) {
        this.code = code;
        this.name = name;
        this.site = site;
    }

    public Warehouse stocks(Product product, int quantity) {
        stock.add(new Stock(product, quantity));
        return this;
    }
}

@RelationshipProperties
public class Stock {

    @TargetNode
    private final Product product;

    private final int quantity;

    public Stock(Product product, int quantity) {
        this.product = product;
        this.quantity = quantity;
    }
}

@Node("Supplier")
public class Supplier {

    @Id
    private final String code;
    private final String name;

    @Relationship(type = "SUPPLIES", direction = Relationship.Direction.OUTGOING)
    private final List<Part> parts = new ArrayList<>();

    public Supplier(String code, String name) {
        this.code = code;
        this.name = name;
    }
}

@Node("Part")
public class Part {

    @Id
    private final String number;
    private final String name;

    @Relationship(type = "PART_OF", direction = Relationship.Direction.OUTGOING)
    private final List<Part> assemblies = new ArrayList<>();      // the sub-assemblies this part goes into

    @Relationship(type = "PART_OF", direction = Relationship.Direction.OUTGOING)
    private final List<Product> products = new ArrayList<>();     // the products this part goes into directly

    public Part(String number, String name) {
        this.number = number;
        this.name = name;
    }
}

@Node("Product")
public class Product {

    @Id
    private final String sku;
    private final String name;

    public Product(String sku, String name) {
        this.sku = sku;
        this.name = name;
    }
}

@Node("Customer")
public class Customer {

    @Id
    private final String code;
    private final String name;

    public Customer(String code, String name) {
        this.code = code;
        this.name = name;
    }
}

Two relationship fields of the same type but different target classes (assemblies and products on Part) are the way to model a relationship whose target is one of two labels without inheritance. See Spring Data Neo4j — Custom queries and the entity chapter linked above. Everything that follows reads the model with Cypher, so the entities mostly serve the write side and simple lookups.

Schema

Spring Data Neo4j does not create constraints or indexes by itself. The application creates them at startup, with IF NOT EXISTS so that every start is idempotent. Uniqueness constraints give the MERGE statements of the write side a fast key lookup, and a point index serves the nearest-warehouse query. The concepts are in Indexes and Constraints:

@Component
public class LogisticsSchema implements ApplicationRunner {

    private static final List<String> STATEMENTS = List.of(
        "CREATE CONSTRAINT location_name IF NOT EXISTS FOR (l:Location) REQUIRE l.name IS UNIQUE",
        "CREATE CONSTRAINT warehouse_code IF NOT EXISTS FOR (w:Warehouse) REQUIRE w.code IS UNIQUE",
        "CREATE CONSTRAINT supplier_code IF NOT EXISTS FOR (s:Supplier) REQUIRE s.code IS UNIQUE",
        "CREATE CONSTRAINT part_number IF NOT EXISTS FOR (p:Part) REQUIRE p.number IS UNIQUE",
        "CREATE CONSTRAINT product_sku IF NOT EXISTS FOR (p:Product) REQUIRE p.sku IS UNIQUE",
        "CREATE CONSTRAINT customer_code IF NOT EXISTS FOR (c:Customer) REQUIRE c.code IS UNIQUE",
        "CREATE POINT INDEX location_point IF NOT EXISTS FOR (l:Location) ON (l.location)");

    private final Neo4jClient client;

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

    @Override
    public void run(ApplicationArguments args) {
        STATEMENTS.forEach(statement -> client.query(statement).run());
    }
}

Constraint and index creation is described in Neo4j Cypher manual — Constraints and Neo4j Cypher manual — Point indexes. A point index is used by a point.distance(...) < radius predicate or by a bounding-box predicate on the indexed property; the nearest-warehouse query below sorts by distance, which the index does not accelerate, so the index matters mostly for radius searches. For a schema that evolves with releases, move these statements into a migration tool as described in Spring Data Neo4j and the Neo4j Reference.

Writes

The road network is reference data that changes rarely, so it is loaded in bulk with an idempotent UNWIND and MERGE statement through Neo4jClient. The PlaceSeed and RoadSeed records are the same shape as the dataset of the Neo4j page, with a coordinate added to every location and a travel time added to every road:

public record PlaceSeed(String name, double latitude, double longitude) {

    Map<String, Object> asMap() {
        return Map.of("name", name, "latitude", latitude, "longitude", longitude);
    }
}

public record RoadSeed(String from, String to, double distance, double travelTime) {

    Map<String, Object> asMap() {
        return Map.of("from", from, "to", to, "distance", distance, "travelTime", travelTime);
    }
}

@Service
public class RoadNetworkLoader {

    private final Neo4jClient client;

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

    @Transactional
    public void load(List<PlaceSeed> places, List<RoadSeed> roads) {
        client.query("""
                UNWIND $places AS place
                MERGE (l:Location {name: place.name})
                SET l.latitude = place.latitude,
                    l.longitude = place.longitude,
                    l.location = point({latitude: place.latitude, longitude: place.longitude})
                """)
            .bind(places.stream().map(PlaceSeed::asMap).toList()).to("places")
            .run();
        client.query("""
                UNWIND $roads AS road
                MATCH (a:Location {name: road.from}), (b:Location {name: road.to})
                MERGE (a)-[r:ROAD]->(b)
                SET r.distance = road.distance, r.travelTime = road.travelTime
                """)
            .bind(roads.stream().map(RoadSeed::asMap).toList()).to("roads")
            .run();
    }
}

Both statements are safe to run again: MERGE on the unique key finds the existing node or relationship and SET refreshes its properties. Roads are stored in one direction and treated as two-way by the projection, so there is one ROAD per pair of cities and no duplicate to keep in sync. See Neo4j Cypher manual — MERGE and Neo4j Cypher manual — UNWIND.

Day-to-day changes are small and go through the repositories and entities: a warehouse receiving stock, a new customer for a warehouse, a closed road removed. A repository over the root entity needs nothing beyond the standard interface:

public interface WarehouseRepository extends Neo4jRepository<Warehouse, String> {
}

Saving a Warehouse with its stocks(...) calls creates the LOCATED_AT, STOCKS and SHIPS_TO relationships in one transaction. See Spring Data Neo4j — Repositories core concepts. For the transaction semantics and optimistic locking of such updates, see Transactions in the Spring Data Neo4j page.

Queries and analytics

The road projection

Every route query runs on an in-memory copy of the road network. Because a road is a two-way street and the model stores it once, the projection declares the relationship type undirected, and it carries the coordinates as node properties for A*. The projection is a Cypher projection built with the gds.graph.project aggregation function, the form described in Native versus Cypher projections:

static final String ROADS_PROJECTION = """
    MATCH (source:Location)-[road:ROAD]->(target:Location)
    WITH gds.graph.project(
        $graphName,
        source,
        target,
        {
            sourceNodeProperties: source {.latitude, .longitude},
            targetNodeProperties: target {.latitude, .longitude},
            relationshipProperties: road {.distance, .travelTime}
        },
        {undirectedRelationshipTypes: ['*']}
    ) AS graph
    RETURN graph.nodeCount AS nodeCount, graph.relationshipCount AS relationshipCount
    """;

See GDS — Cypher projection. For a network of a few hundred thousand roads this projection takes well under a second to build, which is why the route endpoints project per request inside a try-with-resources block; a continent-sized network would keep a named graph in the catalog instead and drop it when the roads change. The projection for a route is kept small by a WHERE filter on the MATCH, for example a region or a road class driven by a request parameter.

Dijkstra, A* and Yen

All three algorithms share a request shape (a source, a target and a weight property) and differ in the procedure name and a few configuration keys. The service below keeps them behind one small API and maps the YIELD rows to records, as in the foundation page:

public record Route(List<String> stops, double totalCost) {
}

@Service
public class RoutingService {

    private final Neo4jClient client;
    private final GdsGraphs graphs;

    public RoutingService(Neo4jClient client, GdsGraphs graphs) {
        this.client = client;
        this.graphs = graphs;
    }

    @Transactional(readOnly = true)
    public Optional<Route> shortest(String from, String to, WeightProperty weight) {
        try (var graph = graphs.projectCypher("roads", ROADS_PROJECTION, Map.of())) {
            return client
                .query("""
                    MATCH (source:Location {name: $from}), (target:Location {name: $to})
                    CALL gds.shortestPath.dijkstra.stream($graph, {
                        sourceNode: source,
                        targetNodes: [target],
                        relationshipWeightProperty: $weight
                    })
                    YIELD totalCost, nodeIds
                    RETURN totalCost, [nodeId IN nodeIds | gds.util.asNode(nodeId).name] AS stops
                    """)
                .bind(graph.name()).to("graph")
                .bind(from).to("from")
                .bind(to).to("to")
                .bind(weight.property()).to("weight")
                .fetchAs(Route.class)
                .mappedBy((typeSystem, record) -> toRoute(record))
                .one();
        }
    }

    @Transactional(readOnly = true)
    public Optional<Route> shortestAStar(String from, String to) {
        try (var graph = graphs.projectCypher("roads-astar", ROADS_PROJECTION, Map.of())) {
            return client
                .query("""
                    MATCH (source:Location {name: $from}), (target:Location {name: $to})
                    CALL gds.shortestPath.astar.stream($graph, {
                        sourceNode: source,
                        targetNode: target,
                        relationshipWeightProperty: 'distance',
                        latitudeProperty: 'latitude',
                        longitudeProperty: 'longitude'
                    })
                    YIELD totalCost, nodeIds
                    RETURN totalCost, [nodeId IN nodeIds | gds.util.asNode(nodeId).name] AS stops
                    """)
                .bind(graph.name()).to("graph")
                .bind(from).to("from")
                .bind(to).to("to")
                .fetchAs(Route.class)
                .mappedBy((typeSystem, record) -> toRoute(record))
                .one();
        }
    }

    @Transactional(readOnly = true)
    public List<Route> alternatives(String from, String to, int k) {
        try (var graph = graphs.projectCypher("roads-yens", ROADS_PROJECTION, Map.of())) {
            return client
                .query("""
                    MATCH (source:Location {name: $from}), (target:Location {name: $to})
                    CALL gds.shortestPath.yens.stream($graph, {
                        sourceNode: source,
                        targetNode: target,
                        k: $k,
                        relationshipWeightProperty: 'distance'
                    })
                    YIELD index, totalCost, nodeIds
                    RETURN totalCost, [nodeId IN nodeIds | gds.util.asNode(nodeId).name] AS stops
                    ORDER BY index
                    """)
                .bind(graph.name()).to("graph")
                .bind(from).to("from")
                .bind(to).to("to")
                .bind(k).to("k")
                .fetchAs(Route.class)
                .mappedBy((typeSystem, record) -> toRoute(record))
                .all()
                .stream()
                .toList();
        }
    }

    private static Route toRoute(Record record) {
        return new Route(
            record.get("stops").asList(Value::asString),
            record.get("totalCost").asDouble());
    }
}

public enum WeightProperty {
    DISTANCE("distance"),
    TRAVEL_TIME("travelTime");

    private final String property;

    WeightProperty(String property) {
        this.property = property;
    }

    public String property() {
        return property;
    }
}

The procedures and their configuration are documented in GDS — Dijkstra Source-Target, GDS — A* and GDS — Yen’s algorithm. Dijkstra takes its target as targetNodes (a list, here of one node): the manual marks the single targetNode key as deprecated for Dijkstra, while A* and Yen’s still document targetNode. The weight property is the only thing the caller chooses at runtime, and it is chosen from a closed enum and bound as a parameter, so a request can never inject a property name; a blended cost would be a third property written onto the roads or computed in the projection. The procedure names stay in the statement text because a procedure name cannot be a parameter.

The three algorithms answer different questions:

  • Dijkstra is the default: the lowest-cost route for any non-negative weight, here either distance or travel time.

  • A* returns the same optimal route when its heuristic is admissible, visiting fewer nodes on a large geographically spread network. The heuristic is the great-circle distance between the latitude and longitude properties, so the weight and the heuristic must use compatible units; a heuristic that over-estimates the real cost can return a route that is not the cheapest. Check the unit expected by the procedure in the A-star page of the GDS manual before switching a network to A*, and keep Dijkstra as the reference result in tests.

  • Yen’s returns up to k loopless routes in increasing order of cost: the primary route plus the fallbacks to offer when a road is closed, without recomputing from scratch. If the network has fewer than k distinct routes, fewer rows come back.

Nearest warehouse with stock

The question "where should this order ship from" is a spatial filter plus an inventory filter, and it needs no projection, so it is plain Cypher and cheap enough for a request. The warehouse’s position is the position of its location, and the stock check is a predicate on the STOCKS relationship property:

public record WarehouseOffer(String code, String name, String city, int quantity, double distanceMeters) {
}

@Service
public class FulfilmentService {

    private final Neo4jClient client;

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

    @Transactional(readOnly = true)
    public Optional<WarehouseOffer> nearestWithStock(String sku, int quantity, double latitude, double longitude) {
        return client
            .query("""
                MATCH (w:Warehouse)-[:LOCATED_AT]->(site:Location),
                      (w)-[stock:STOCKS]->(:Product {sku: $sku})
                WHERE stock.quantity >= $quantity
                WITH w, site, stock,
                     point.distance(site.location, point({latitude: $latitude, longitude: $longitude})) AS distance
                RETURN w.code AS code, w.name AS name, site.name AS city,
                       stock.quantity AS quantity, distance
                ORDER BY distance
                LIMIT 1
                """)
            .bind(sku).to("sku")
            .bind(quantity).to("quantity")
            .bind(latitude).to("latitude")
            .bind(longitude).to("longitude")
            .fetchAs(WarehouseOffer.class)
            .mappedBy((typeSystem, record) -> new WarehouseOffer(
                record.get("code").asString(),
                record.get("name").asString(),
                record.get("city").asString(),
                record.get("quantity").asInt(),
                record.get("distance").asDouble()))
            .one();
    }
}

The Cypher function point.distance returns the geodesic distance in metres between two WGS-84 points; the inventory predicate removes a nearer warehouse that cannot fill the order, which is the point of the query. See Neo4j Cypher manual — Spatial functions and the custom point() and distance() section of the Spring Data Neo4j page. The straight-line distance is only the shortlisting criterion: once the warehouse is chosen, shortest(...) gives the real road route from its location to the customer’s.

Disruption impact

A supplier that stops delivering one part disrupts every product that part ends up in, however many sub-assemblies lie in between. That is a variable-length traversal along the PART_OF direction, and it must be bounded: an unbounded pattern on a bill of materials with a data error (a part that is, through a cycle, part of itself) would never end.

flowchart LR S1["Supplier S-LIGURIA"] -- SUPPLIES --> P1["Part P-TUBE"] P1 -- PART_OF --> P2["Part P-FRAME"] P2 -- PART_OF --> X1["Product CARGO-BIKE"] P2 -- PART_OF --> X2["Product E-TRIKE"] S2["Supplier S-OTHER"] -- SUPPLIES --> P3["Part P-BELL"] P3 -- PART_OF --> X1 X1 -. "STOCKS" .-> W1["Warehouses holding the product"] X2 -. "STOCKS" .-> W1

The report returns the affected products, the parts on the way and the units in stock that could still be shipped while the replacement is found:

public record ImpactedProduct(String sku, String name, List<String> viaParts, long unitsInStock) {
}

@Service
public class DisruptionService {

    private static final String IMPACT = """
        MATCH path = (supplier:Supplier {code: $supplier})-[:SUPPLIES]->(:Part)-[:PART_OF*1..5]->(product:Product)
        WITH product,
             collect(DISTINCT [n IN nodes(path) WHERE n:Part | n.number]) AS chains
        OPTIONAL MATCH (:Warehouse)-[stock:STOCKS]->(product)
        RETURN product.sku AS sku,
               product.name AS name,
               apoc.coll.toSet(apoc.coll.flatten(chains)) AS viaParts,
               coalesce(sum(stock.quantity), 0) AS unitsInStock
        ORDER BY sku
        """;

    private final Neo4jClient client;

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

    @Transactional(readOnly = true)
    public List<ImpactedProduct> impactOf(String supplierCode) {
        return client.query(IMPACT)
            .bind(supplierCode).to("supplier")
            .fetchAs(ImpactedProduct.class)
            .mappedBy((typeSystem, record) -> new ImpactedProduct(
                record.get("sku").asString(),
                record.get("name").asString(),
                record.get("viaParts").asList(Value::asString),
                record.get("unitsInStock").asLong()))
            .all()
            .stream()
            .toList();
    }
}

The upper bound of the pattern is a literal in the statement, because the bounds of a variable-length pattern cannot be parameters; if it must be configurable, choose it from a fixed set and build the statement from constants. The DISTINCT and the collect keep a product that is reachable through two sub-assemblies from appearing twice, and the two APOC collection functions flatten and de-duplicate the part numbers; they are part of APOC Core, which the integration test below installs. Without APOC the same result is obtained with reduce over the collected lists. See Neo4j Cypher manual — Variable-length patterns and APOC — Collection functions.

Bottlenecks with Betweenness Centrality

Which city sits on the most shortest routes is a question for Betweenness Centrality, which scores every node by the share of shortest paths between other pairs that pass through it. It looks at the whole network and is expensive, so it is not run per request: a scheduled job projects the network, writes the score back onto each Location, and the endpoint reads the stored property. The projection, the write mode and the estimate guard are described in the foundation page; the job only adds the specific call:

public record WriteSummary(long nodePropertiesWritten) {
}

@Service
public class BottleneckService {

    private final Neo4jClient client;
    private final GdsGraphs graphs;

    public BottleneckService(Neo4jClient client, GdsGraphs graphs) {
        this.client = client;
        this.graphs = graphs;
    }

    public WriteSummary recomputeBottlenecks() {
        try (var graph = graphs.projectCypher("bottlenecks", ROADS_PROJECTION, Map.of())) {
            return client
                .query("""
                    CALL gds.betweenness.write($graph, {writeProperty: 'bottleneckScore'})
                    YIELD nodePropertiesWritten
                    RETURN nodePropertiesWritten
                    """)
                .bind(graph.name()).to("graph")
                .fetchAs(WriteSummary.class)
                .mappedBy((typeSystem, record) -> new WriteSummary(record.get("nodePropertiesWritten").asLong()))
                .one()
                .orElseThrow();
        }
    }

    @Transactional(readOnly = true)
    public List<Bottleneck> top(int limit) {
        return client
            .query("""
                MATCH (l:Location)
                WHERE l.bottleneckScore IS NOT NULL
                RETURN l.name AS city, l.bottleneckScore AS score
                ORDER BY score DESC
                LIMIT $limit
                """)
            .bind(limit).to("limit")
            .fetchAs(Bottleneck.class)
            .mappedBy((typeSystem, record) -> new Bottleneck(
                record.get("city").asString(), record.get("score").asDouble()))
            .all()
            .stream()
            .toList();
    }
}

public record Bottleneck(String city, double score) {
}

@Component
public class BottleneckJob {

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

    private final BottleneckService bottlenecks;

    public BottleneckJob(BottleneckService bottlenecks) {
        this.bottlenecks = bottlenecks;
    }

    @Scheduled(cron = "${logistics.bottlenecks.cron:0 30 3 * * *}")
    @SchedulerLock(name = "logistics-bottlenecks", lockAtMostFor = "PT1H", lockAtLeastFor = "PT5M")
    public void run() {
        log.info("Bottleneck scores written: {}", bottlenecks.recomputeBottlenecks());
    }
}

The @SchedulerLock annotation, the lock provider and the choice of lockAtMostFor are covered in Scheduling and ShedLock; for this job lockAtMostFor only has to exceed the longest realistic run. The procedure is documented in GDS — Betweenness Centrality. Betweenness counts shortest paths by the number of hops and does not take the road length into account, so a result is a statement about the shape of the network, not about kilometres; on a very large network, set samplingSize and samplingSeed in the configuration to trade accuracy for a bounded running time. The endpoint reads the stored score and can cache it as described in Caching, evicting the cache when the job ends.

Service and API

The controller is a thin layer over the services: validation of the inputs, a stable JSON shape, and no Cypher. The conventions of resource design and error responses are those of REST APIs:

@RestController
@RequestMapping("/routes")
@Validated
public class RoutesController {

    private final RoutingService routing;
    private final FulfilmentService fulfilment;

    public RoutesController(RoutingService routing, FulfilmentService fulfilment) {
        this.routing = routing;
        this.fulfilment = fulfilment;
    }

    @GetMapping
    public RouteResponse route(@RequestParam @NotBlank String from,
                               @RequestParam @NotBlank String to,
                               @RequestParam(defaultValue = "DISTANCE") WeightProperty weight,
                               @RequestParam(defaultValue = "DIJKSTRA") Algorithm algorithm,
                               @RequestParam(defaultValue = "1") @Min(1) @Max(10) int alternatives) {
        var best = switch (algorithm) {
            case DIJKSTRA -> routing.shortest(from, to, weight);
            case ASTAR -> routing.shortestAStar(from, to);
        };
        var route = best.orElseThrow(() -> new RouteNotFoundException(from, to));
        var others = alternatives > 1
            ? routing.alternatives(from, to, alternatives).stream().skip(1).toList()
            : List.<Route>of();
        return new RouteResponse(route, others);
    }

    @GetMapping("/nearest-warehouse")
    public WarehouseOffer nearestWarehouse(@RequestParam String sku,
                                           @RequestParam(defaultValue = "1") @Min(1) int quantity,
                                           @RequestParam double lat,
                                           @RequestParam double lon) {
        return fulfilment.nearestWithStock(sku, quantity, lat, lon)
            .orElseThrow(() -> new NoStockException(sku, quantity));
    }

    public enum Algorithm { DIJKSTRA, ASTAR }

    public record RouteResponse(Route best, List<Route> alternatives) {
    }
}

@RestController
public class SupplyChainController {

    private final DisruptionService disruptions;
    private final BottleneckService bottlenecks;

    public SupplyChainController(DisruptionService disruptions, BottleneckService bottlenecks) {
        this.disruptions = disruptions;
        this.bottlenecks = bottlenecks;
    }

    @GetMapping("/suppliers/{code}/impact")
    public List<ImpactedProduct> impact(@PathVariable String code) {
        return disruptions.impactOf(code);
    }

    @GetMapping("/bottlenecks")
    public List<Bottleneck> bottlenecks(@RequestParam(defaultValue = "5") @Min(1) @Max(50) int limit) {
        return bottlenecks.top(limit);
    }
}

GET /routes?from=Turin&to=Florence&alternatives=3 returns the best route and the other routes the network offers. RouteNotFoundException and NoStockException map to 404 through a @RestControllerAdvice, as in the REST APIs page. See Spring Framework — Annotated controllers.

The route endpoint is safe to call synchronously only while the projection stays small: it builds, runs and drops a graph per request. Two guards belong in front of it in production: authorization, so that only the dispatching system and its operators can read the routes and the supplier impact (Spring Security), and metrics on the duration of the GDS calls and on rejected requests (Metrics and Observability). A cache of fixed-origin routes with a short time to live helps when the same depot-to-city pairs are asked repeatedly.

Integration test

The test starts a real Neo4j with the GDS and APOC plugins, loads the road dataset of the Neo4j Reference and a small supply chain, and asserts the observable results. Container setup, @ServiceConnection and the plugin variable are the ones of the foundation page; the general test setup of Spring Boot applications is in Unit and Integration Testing:

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

import java.util.List;
import java.util.Map;

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 LogisticsGraphIT {

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

    @Autowired Neo4jClient client;
    @Autowired RoadNetworkLoader loader;
    @Autowired RoutingService routing;
    @Autowired FulfilmentService fulfilment;
    @Autowired DisruptionService disruptions;
    @Autowired BottleneckService bottlenecks;

    @BeforeEach
    void loadFixture() {
        client.query("MATCH (n) DETACH DELETE n").run();
        loader.load(
            List.of(
                new PlaceSeed("Turin", 45.0703, 7.6869),
                new PlaceSeed("Milan", 45.4642, 9.1900),
                new PlaceSeed("Genoa", 44.4056, 8.9463),
                new PlaceSeed("Bologna", 44.4949, 11.3426),
                new PlaceSeed("Florence", 43.7696, 11.2558)),
            List.of(
                new RoadSeed("Turin", "Milan", 140, 90),
                new RoadSeed("Turin", "Genoa", 170, 120),
                new RoadSeed("Milan", "Bologna", 210, 130),
                new RoadSeed("Genoa", "Bologna", 300, 190),
                new RoadSeed("Bologna", "Florence", 100, 65)));
        client.query("""
                CREATE (cargo:Product {sku: 'CARGO-BIKE', name: 'Cargo bike'})
                CREATE (trike:Product {sku: 'E-TRIKE', name: 'Electric trike'})
                CREATE (frame:Part {number: 'P-FRAME', name: 'Frame'})
                CREATE (tube:Part {number: 'P-TUBE', name: 'Steel tube'})
                CREATE (bell:Part {number: 'P-BELL', name: 'Bell'})
                CREATE (tube)-[:PART_OF]->(frame)
                CREATE (frame)-[:PART_OF]->(cargo)
                CREATE (frame)-[:PART_OF]->(trike)
                CREATE (bell)-[:PART_OF]->(cargo)
                CREATE (:Supplier {code: 'S-LIGURIA', name: 'Ligure Steel'})-[:SUPPLIES]->(tube)
                CREATE (:Supplier {code: 'S-OTHER', name: 'Other Parts'})-[:SUPPLIES]->(bell)
                WITH cargo, trike
                MATCH (turin:Location {name: 'Turin'}), (genoa:Location {name: 'Genoa'}),
                      (bologna:Location {name: 'Bologna'})
                CREATE (:Warehouse {code: 'WH-TURIN', name: 'Turin hub'})-[:LOCATED_AT]->(turin)
                CREATE (:Warehouse {code: 'WH-GENOA', name: 'Genoa port'})-[:LOCATED_AT]->(genoa)
                CREATE (wb:Warehouse {code: 'WH-BOLOGNA', name: 'Bologna depot'})-[:LOCATED_AT]->(bologna)
                WITH cargo, trike
                MATCH (t:Warehouse {code: 'WH-TURIN'}), (g:Warehouse {code: 'WH-GENOA'}),
                      (b:Warehouse {code: 'WH-BOLOGNA'})
                CREATE (t)-[:STOCKS {quantity: 12}]->(cargo)
                CREATE (g)-[:STOCKS {quantity: 4}]->(cargo)
                CREATE (b)-[:STOCKS {quantity: 0}]->(cargo)
                CREATE (b)-[:STOCKS {quantity: 5}]->(trike)
                """).run();
    }

    @Test
    void dijkstraFindsTheShortestRouteFromTurinToFlorence() {
        var route = routing.shortest("Turin", "Florence", WeightProperty.DISTANCE).orElseThrow();

        assertThat(route.stops()).containsExactly("Turin", "Milan", "Bologna", "Florence");
        assertThat(route.totalCost()).isEqualTo(450.0);
    }

    @Test
    void yensReturnsTheBestRouteAndTheAlternativeThroughGenoa() {
        var routes = routing.alternatives("Turin", "Florence", 3);

        assertThat(routes).hasSize(2);   // the network has only two loopless routes
        assertThat(routes.get(0).stops()).containsExactly("Turin", "Milan", "Bologna", "Florence");
        assertThat(routes.get(0).totalCost()).isEqualTo(450.0);
        assertThat(routes.get(1).stops()).containsExactly("Turin", "Genoa", "Bologna", "Florence");
        assertThat(routes.get(1).totalCost()).isEqualTo(570.0);
    }

    @Test
    void astarReachesFlorenceFromTurin() {
        var route = routing.shortestAStar("Turin", "Florence").orElseThrow();

        assertThat(route.stops()).startsWith("Turin").endsWith("Florence");
    }

    @Test
    void nearestWarehouseSkipsTheOneOutOfStock() {
        // Bologna is the closest to Florence but holds no cargo bike; Genoa is the nearest that does
        var offer = fulfilment.nearestWithStock("CARGO-BIKE", 1, 43.7696, 11.2558).orElseThrow();

        assertThat(offer.code()).isEqualTo("WH-GENOA");
    }

    @Test
    void ligurianSupplierOutageHitsBothProducts() {
        var impact = disruptions.impactOf("S-LIGURIA");

        assertThat(impact).extracting(ImpactedProduct::sku).containsExactly("CARGO-BIKE", "E-TRIKE");
        assertThat(impact.get(0).viaParts()).containsExactlyInAnyOrder("P-TUBE", "P-FRAME");
        assertThat(impact.get(0).unitsInStock()).isEqualTo(16);   // 12 in Turin + 4 in Genoa + 0 in Bologna
    }

    @Test
    void bolognaIsTheBottleneck() {
        bottlenecks.recomputeBottlenecks();

        assertThat(bottlenecks.top(1)).extracting(Bottleneck::city).containsExactly("Bologna");
    }
}

The expected values come straight from the dataset: the cheapest road from Turin to Florence is Turin, Milan, Bologna and Florence for 140 + 210 + 100 = 450 km, the only other loopless route goes through Genoa for 170 + 300 + 100 = 570 km, and Bologna is the one city through which every route to Florence has to pass. The tests assert routes, memberships and rankings rather than floating-point scores, and A* is asserted only on its end points. Run them with the Failsafe plugin or a profile separate from the unit tests, because starting the container with the plugins takes a few seconds. See Spring Boot — Testcontainers and Testcontainers — Neo4j module.

Pitfalls and variations

  • Never id(). An internal node identifier is neither stable across stores nor safe to put in an API. The entities of this page use business keys as @Id, and any statement that has to return a node identity uses elementId().

  • Projecting per request does not scale to a large network. It is fine for a few thousand roads. For a national network keep a named graph in the catalog, refresh it when the roads change, and drop it explicitly; the lifecycle rules and the memory guard are in Calling GDS from Spring Boot.

  • Directed versus undirected. The roads are stored once and projected undirected. A one-way street needs a oneWay flag and a second projection, or a separate relationship type for each direction, projected as NATURAL.

  • Weights must be non-negative and numeric. A negative weight breaks Dijkstra, and only numeric properties reach a projection, so a categorical road class must be turned into a cost (or filtered in the MATCH) before projecting.

  • A* needs compatible units. The heuristic reads latitude and longitude and compares against the cost, so a weight in kilometres next to a heuristic in metres produces suboptimal routes. Test the result of A* against Dijkstra on real data before using it in production.

  • The number of routes is an upper bound. A request for k alternatives returns at most k; a small network returns fewer, and the API should say so rather than pad the list.

  • Straight-line distance is not driving distance. point.distance shortlists the warehouse; the road route decides. When the nearest by road is not the nearest by air, compute shortest(...) from each candidate with stock and take the cheapest.

  • Bound every variable-length pattern. The 1..5 in the disruption query is a safety limit, not a business rule; pick it from the deepest real bill of materials, and fix the data when a cycle appears.

  • Betweenness ignores weights. It scores by hop count. For a bottleneck in terms of kilometres or minutes, derive the measure from route counts: run Yen’s or Dijkstra between the busiest origin and destination pairs and count how often each road appears.

  • Variations. The same graph supports a what-if closure (project without the closed road and compare the new best route with the old one), a multi-stop delivery (Dijkstra between consecutive stops, with the order chosen outside the database), and a customer impact report that follows SHIPS_TO from the warehouses holding an affected product.

For the algorithm background see Pathfinding and Centrality Algorithms, for the data modeling rules behind the entities Data Modeling, and for the Spring Data side Spring Data Neo4j.