Docs
  • Solver
  • Models
    • Field Service Routing
    • Employee Shift Scheduling
    • Pick-up and Delivery Routing
    • Task Scheduling
  • Platform
Start Free Trial
  • Timefold Solver SNAPSHOT
  • Example use cases
  • Vehicle routing (guide)
  • Edit this Page

Timefold Solver SNAPSHOT

    • Introduction
    • Getting started
      • Overview
      • Build as a service
      • Embed as a library
        • Hello World guide
        • Quarkus guide
        • Spring Boot guide
    • Domain modeling
      • Guide
      • Building blocks
      • Common patterns
    • Constraints and score
      • Overview
      • Score calculation
      • Understanding the score
      • Load balancing and fairness
      • Performance tips and tricks
    • Running the Solver
      • Overview
      • As a service
        • REST API
        • Model configuration overrides
        • Model enrichment
        • Demo data
        • Exposing metrics
        • Service consumer guide
      • As a library
        • Configuring Timefold Solver
        • Constraint weights
        • Quarkus integration
        • Spring Boot integration
        • JPA/JAXB/JSON integration
    • Diagnosing the Solver
      • Benchmarking
      • Solver diagnostics
    • Deploying to the Timefold Platform
      • Overview
      • Guide
      • Platform model metadata
      • Using metrics
    • Optimization algorithms
      • Overview
      • Construction heuristics
      • Local search
      • Exhaustive search
      • Custom moves
        • Neighborhoods API
        • Move Selector reference
    • Responding to change
      • Continuous planning
      • Real-time planning
      • Non-disruptive replanning
      • Assignment Recommendation API
    • Example use cases
      • Vehicle routing (guide)
      • More examples on GitHub
    • FAQ
    • New and noteworthy[leveloffset=+1]
    • Upgrading Timefold Solver
      • Upgrading Timefold Solver: Overview
      • Upgrade Timefold Solver to the latest version
      • Upgrade from Timefold Solver 1.x to 2.x
      • Upgrading from OptaPlanner[leveloffset=+1]
      • Backwards compatibility
      • Migration guides
        • Variable Listeners to Custom Shadow Variables
        • Chained planning variable to planning list variable
    • Commercial editions
      • Overview
      • Installation
      • Performance improvements
      • Score analysis
      • Recommendation API
      • Nearby selection
      • Multithreaded solving
      • Partitioned search
      • Constraint profiling
      • Multistage moves
      • Throttling best solution events
      • License management

Vehicle Routing Quick Start Guide

This guide walks you through the process of creating a Vehicle Routing optimization service with Timefold's constraint solving Artificial Intelligence (AI). It builds on the service module: you define the planning model and its API, and Timefold Solver takes care of the rest.

Check out our off-the-shelf model for Field Service Routing (REST API). It goes beyond basic vehicle routing and supports additional constraints such as priorities, skills, fairness and more.

What you will build

You will build an optimization service that solves a Vehicle Routing Problem (VRP) with capacities and time windows:

vehicleRouteScreenshot

Each vehicle leaves its own home location at a set time, drives a route of visits, and returns home. Your service will assign Visit instances to Vehicle instances automatically by using AI to adhere to hard, medium and soft constraints:

  • The demand of the visits on a route cannot exceed the capacity of the vehicle.

  • A visit must be serviced before the end of its time window. A vehicle that arrives early waits.

  • As many visits as possible should be assigned to a vehicle. A visit that fits on no route is left unassigned rather than forced onto one.

  • The less total travel time, the better.

Mathematically speaking, VRP is an NP-hard problem. This means it is difficult to scale. Simply brute force iterating through all possible combinations takes millions of years for a non-trivial dataset, even on a supercomputer. Luckily, AI constraint solvers such as Timefold Solver have advanced algorithms that deliver a near-optimal solution in a reasonable amount of time.

Solution source code

Follow the instructions in the next sections to create the application step by step (recommended).

Alternatively, you can also skip right to the completed example:

  1. Clone the Git repository:

    $ git clone https://github.com/TimefoldAI/timefold-quickstarts

    or download an archive.

  2. Find the solution in the use-cases/vehicle-routing directory and run it (see its README file). The complete example also includes a web UI, demo datasets, input validation, metrics and a recommendation endpoint.

Prerequisites

To complete this guide, you need:

  • Tools

    • JDK 21 or higher

    • Maven

    • An IDE of your choice (IntelliJ IDEA, VSCode, …​)

  • Knowledge

    • Java (Basic)

    • Quarkus (Basic)

If this is your first service, consider doing the Getting started: building a service guide first. It introduces the service module with a simpler model.

1. The build file and the dependencies

Create a Maven file that uses the service parent POM and depends on timefold-solver-service-with-maps. That dependency adds the map service, which provides the driving times between locations.

Your pom.xml file has the following content:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>ai.timefold.solver</groupId>
    <artifactId>timefold-solver-service-parent</artifactId>
    <version>SNAPSHOT</version>
  </parent>

  <groupId>org.acme</groupId>
  <artifactId>vehicle-routing</artifactId>
  <version>${revision}</version>

  <properties>
    <revision>1.0.0-SNAPSHOT</revision>
    <maven.compiler.release>21</maven.compiler.release>
  </properties>

  <dependencies>
    <dependency>
      <groupId>ai.timefold.solver</groupId>
      <artifactId>timefold-solver-service-with-maps</artifactId>
    </dependency>

    <dependency>
      <groupId>ai.timefold.solver</groupId>
      <artifactId>timefold-solver-service-maps-service-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>
</project>

The parent POM brings in Quarkus, the REST layer, the OpenAPI tooling and the usual test libraries, so you do not need to declare them yourself.

2. Model the domain objects

Your goal is to assign each visit to a vehicle, in the order that vehicle services them. You will create these classes:

vehicleRoutingClassDiagramPure

2.1. Location

Every location in the model, whether a vehicle’s home location or a visit’s destination, is an ai.timefold.solver.service.maps.api.model.Location. This class comes with the timefold-solver-service-with-maps dependency, so you do not create it yourself.

A Location holds a latitude and a longitude. Its getTravelTimeTo(Location) method returns the driving time to another location. That driving time comes from a travel time matrix that the map service builds before solving starts. You never compute distances in your own code.

2.2. Vehicle

Vehicle has a route of visits to make. Each vehicle has a specific departure time and starting location. It returns to its home location after completing the route and has a maximum capacity that must not be exceeded.

During solving, Timefold Solver updates the visits field of the Vehicle class to assign a list of visits. Because Timefold Solver changes this field, Vehicle is a planning entity:

vehicleRoutingClassDiagramAnnotated

Based on the diagram, the visits field is a genuine variable that changes during the solving process. To ensure that Timefold Solver recognizes it as a sequence of connected variables, the field must have an @PlanningListVariable annotation indicating that the solver can distribute a subset of the available visits to it. The objective is to create an ordered route for each vehicle.

allowsUnassignedValues = true lets the solver leave a visit off every route. When the fleet cannot service every visit, for example because it lacks capacity, the solver still produces a plan. The plan leaves out the visits that do not fit, instead of breaking a hard constraint to squeeze them in.

Create the src/main/java/org/acme/vehiclerouting/domain/Vehicle.java class:

package org.acme.vehiclerouting.domain;

import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

import ai.timefold.solver.core.api.domain.common.PlanningId;
import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
import ai.timefold.solver.core.api.domain.variable.PlanningListVariable;
import ai.timefold.solver.service.maps.api.model.Location;

@PlanningEntity
public class Vehicle {

    @PlanningId
    private String id;
    private int capacity;
    private Location homeLocation;
    private OffsetDateTime departureTime;

    /**
     * The route of this vehicle: the visits it services, in the order it services them. The
     * assignment <em>is</em> this list, so a visit that appears in no vehicle's list is unassigned.
     */
    @PlanningListVariable(allowsUnassignedValues = true)
    private List<Visit> visits;

    public Vehicle() {
    }

    public Vehicle(String id, int capacity, Location homeLocation, OffsetDateTime departureTime) {
        this.id = id;
        this.capacity = capacity;
        this.homeLocation = homeLocation;
        this.departureTime = departureTime;
        this.visits = new ArrayList<>();
    }

    /**
     * @return the demand of every visit on the route added up; 0 while it is not computed yet
     */
    public int getTotalDemand() {
        if (visits.isEmpty()) {
            return 0;
        }

        Visit lastVisit = visits.get(visits.size() - 1);
        Integer cumulativeDemand = lastVisit.getCumulativeDemand();
        return cumulativeDemand == null ? 0 : cumulativeDemand;
    }

    /**
     * @return the driving time of the whole route, home location to home location, in seconds;
     *         0 while it is not computed yet
     */
    public long getTotalDrivingTimeSeconds() {
        if (visits.isEmpty()) {
            return 0;
        }

        Visit lastVisit = visits.get(visits.size() - 1);
        Long cumulativeDrivingTime = lastVisit.getCumulativeDrivingTimeSeconds();
        if (cumulativeDrivingTime == null) {
            return 0;
        }
        return cumulativeDrivingTime + lastVisit.getLocation().getTravelTimeTo(homeLocation).seconds();
    }

    /**
     * @return the time this vehicle is back at its home location, or its departure time when it has
     *         no visits to make; null while the timings of its last visit are not computed yet
     */
    public OffsetDateTime arrivalTime() {
        if (visits.isEmpty()) {
            return departureTime;
        }

        Visit lastVisit = visits.get(visits.size() - 1);
        OffsetDateTime lastDepartureTime = lastVisit.getDepartureTime();
        if (lastDepartureTime == null) {
            return null;
        }
        return lastDepartureTime.plusSeconds(lastVisit.getLocation().getTravelTimeTo(homeLocation).seconds());
    }

    // Getters, setters, equals() and hashCode() (based on id) excluded

    @Override
    public String toString() {
        return id;
    }
}

The Vehicle class has an @PlanningEntity annotation, so Timefold Solver knows that this class changes during solving because it contains one or more planning variables.

Notice the toString() method keeps the output short, so it is easier to read Timefold Solver’s DEBUG or TRACE log.

Determining the @PlanningListVariable fields for an arbitrary constraint solving use case is often challenging the first time. Read the domain modeling guidelines to avoid common pitfalls.

2.3. Visit

The Visit class represents a delivery that needs to be made by vehicles. A visit includes a destination location, a delivery time window represented by [minStartTime, maxEndTime], a demand that needs to be fulfilled by the vehicle, and a service duration time.

The Visit class has an @PlanningEntity annotation but no genuine variables, so it is called a shadow entity.

Create the src/main/java/org/acme/vehiclerouting/domain/Visit.java class:

package org.acme.vehiclerouting.domain;

import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Objects;

import ai.timefold.solver.core.api.domain.common.PlanningId;
import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable;
import ai.timefold.solver.core.api.domain.variable.PreviousElementShadowVariable;
import ai.timefold.solver.core.api.domain.variable.ShadowSources;
import ai.timefold.solver.core.api.domain.variable.ShadowVariable;
import ai.timefold.solver.service.maps.api.model.Location;

@PlanningEntity
public class Visit {

    @PlanningId
    private String id;
    private String name;
    private Location location;
    private int demand;
    private OffsetDateTime minStartTime;
    private OffsetDateTime maxEndTime;
    private Duration serviceDuration;

    @InverseRelationShadowVariable(sourceVariableName = "visits")
    private Vehicle vehicle;
    @PreviousElementShadowVariable(sourceVariableName = "visits")
    private Visit previousVisit;
    @ShadowVariable(supplierName = "timingsSupplier")
    private Timings timings;
    @ShadowVariable(supplierName = "cumulativeDemandSupplier")
    private Integer cumulativeDemand;

    public Visit() {
    }

    public Visit(String id, String name, Location location, int demand,
            OffsetDateTime minStartTime, OffsetDateTime maxEndTime, Duration serviceDuration) {
        this.id = id;
        this.name = name;
        this.location = location;
        this.demand = demand;
        this.minStartTime = minStartTime;
        this.maxEndTime = maxEndTime;
        this.serviceDuration = serviceDuration;
    }

    /**
     * Computes the arrival, start service and departure time, and the driving time so far, in one go:
     * they are all derived from the same predecessor's timings, so a single supplier keeps them consistent.
     *
     * @return null while this visit is unassigned or its predecessor is not timed yet
     */
    @ShadowSources({ "vehicle", "previousVisit.timings" })
    public Timings timingsSupplier() {
        if (previousVisit == null && vehicle == null) {
            return null;
        }
        OffsetDateTime previousDepartureTime =
                previousVisit == null ? vehicle.getDepartureTime() : previousVisit.getDepartureTime();
        if (previousDepartureTime == null) {
            return null;
        }
        long drivingTimeSeconds = getDrivingTimeSecondsFromPreviousStandstill();
        long previousCumulativeDrivingTimeSeconds =
                previousVisit == null ? 0 : previousVisit.getCumulativeDrivingTimeSeconds();
        var arrivalTime = previousDepartureTime.plusSeconds(drivingTimeSeconds);
        var startServiceTime = arrivalTime.isBefore(minStartTime) ? minStartTime : arrivalTime;
        return new Timings(arrivalTime, startServiceTime, startServiceTime.plus(serviceDuration),
                previousCumulativeDrivingTimeSeconds + drivingTimeSeconds);
    }

    /**
     * @return the demand of this visit and every visit before it on the route added up,
     *         or null while this visit is unassigned
     */
    @ShadowSources({ "vehicle", "previousVisit.cumulativeDemand" })
    public Integer cumulativeDemandSupplier() {
        if (vehicle == null) {
            return null;
        }
        if (previousVisit == null) {
            return demand;
        }
        Integer previousCumulativeDemand = previousVisit.getCumulativeDemand();
        return previousCumulativeDemand == null ? null : previousCumulativeDemand + demand;
    }

    public OffsetDateTime getArrivalTime() {
        return timings == null ? null : timings.arrivalTime();
    }

    public OffsetDateTime getStartServiceTime() {
        return timings == null ? null : timings.startServiceTime();
    }

    public OffsetDateTime getDepartureTime() {
        return timings == null ? null : timings.departureTime();
    }

    /**
     * @return the driving time from the vehicle's home location up to this visit, in seconds,
     *         or null while this visit is not timed yet
     */
    public Long getCumulativeDrivingTimeSeconds() {
        return timings == null ? null : timings.cumulativeDrivingTimeSeconds();
    }

    /**
     * @return the demand of this visit and every visit before it on the route added up,
     *         or null while this visit is unassigned
     */
    public Integer getCumulativeDemand() {
        return cumulativeDemand;
    }

    public boolean isAssigned() {
        return vehicle != null;
    }

    public boolean isServiceFinishedAfterMaxEndTime() {
        var serviceStart = getStartServiceTime();
        return serviceStart != null
                && serviceStart.plus(serviceDuration).isAfter(maxEndTime);
    }

    public long getServiceFinishedDelayInMinutes() {
        var departureTime = getDepartureTime();
        if (departureTime == null) {
            return 0;
        }
        return roundDurationToNextOrEqualMinutes(Duration.between(maxEndTime, departureTime));
    }

    private static long roundDurationToNextOrEqualMinutes(Duration duration) {
        var remainder = duration.minus(duration.truncatedTo(ChronoUnit.MINUTES));
        var minutes = duration.toMinutes();
        if (remainder.equals(Duration.ZERO)) {
            return minutes;
        }
        return minutes + 1;
    }

    public long getDrivingTimeSecondsFromPreviousStandstill() {
        if (vehicle == null) {
            throw new IllegalStateException(
                    "This method must not be called when the shadow variables are not initialized yet.");
        }
        if (previousVisit == null) {
            return vehicle.getHomeLocation().getTravelTimeTo(location).seconds();
        }
        return previousVisit.getLocation().getTravelTimeTo(location).seconds();
    }

    /**
     * @return the same driving time as {@link #getDrivingTimeSecondsFromPreviousStandstill()}, but
     *         null instead of an exception while this visit is still unassigned
     */
    public Long getDrivingTimeSecondsFromPreviousStandstillOrNull() {
        if (vehicle == null) {
            return null;
        }
        return getDrivingTimeSecondsFromPreviousStandstill();
    }

    // Getters, setters, equals() and hashCode() (based on id) excluded

    @Override
    public String toString() {
        return id;
    }

    /**
     * The times at which this visit is serviced, all derived from the route this visit is in.
     *
     * @param cumulativeDrivingTimeSeconds the driving time from the vehicle's home location up to this visit
     */
    public record Timings(OffsetDateTime arrivalTime, OffsetDateTime startServiceTime,
            OffsetDateTime departureTime, long cumulativeDrivingTimeSeconds) {
    }
}

The fields vehicle, previousVisit, timings and cumulativeDemand are shadow variables. Timefold Solver updates them automatically whenever the visits list of a vehicle changes.

The field vehicle has an @InverseRelationShadowVariable annotation, creating a bi-directional relationship with the Vehicle. It holds a reference to the Vehicle where the visit is scheduled, or null if the visit is unassigned. Let’s say the visit Ann was scheduled to the vehicle V1 during the solving process. The field then holds a reference to V1.

The field previousVisit is annotated with @PreviousElementShadowVariable. The solver will update this field with a reference of the visit preceding the current visit instance. Assuming that vehicle V1 is assigned the visits of Ann, Beth, and Carl, the previousVisit field will be filled with Ann for the visit of Beth.

@NextElementShadowVariable also exists, which can be used to get a reference to the successor element.

The timings field is a custom shadow variable. @ShadowVariable(supplierName = "timingsSupplier") tells Timefold Solver to compute it with the timingsSupplier() method. @ShadowSources on that method lists what the result depends on: the vehicle of this visit and the timings of the previous visit. Whenever one of those changes, Timefold Solver recalculates the timings of this visit, and in turn those of every visit after it on the route.

The arrival time, the start of service and the departure time all derive from the departure time of the previous stop. So the Timings record computes them together, in a single shadow variable (see updating multiple fields at once). A vehicle that arrives before minStartTime waits, so servicing starts at minStartTime rather than at the arrival time. Timings also keeps the cumulative driving time: the driving time from the vehicle’s home location up to this visit. It adds the driving time from the previous stop to the cumulative driving time of the previous visit, so it builds on the same previousVisit.timings source.

The cumulativeDemand field is a second custom shadow variable, computed by cumulativeDemandSupplier(). It holds the demand of this visit plus the cumulative demand of the previous visit, so it depends on vehicle and previousVisit.cumulativeDemand. The demand does not depend on the timings, so it is a separate shadow variable: a change that only affects the timings, such as a different departure time, does not recalculate it.

Because every visit carries these running totals, the last visit on a route holds the totals of the whole route. That is why Vehicle.getTotalDemand() and Vehicle.getTotalDrivingTimeSeconds() only look at the last visit instead of looping over the entire route. getTotalDrivingTimeSeconds() still adds the drive from the last visit back to the home location.

3. Define the constraints and calculate the score

A score represents the quality of a specific solution. The higher the better. Timefold Solver looks for the best solution, which is the solution with the highest score found in the available time. It might be the optimal solution.

Because this use case has hard, medium and soft constraints, use the HardMediumSoftScore class to represent the score:

  • Hard constraints must not be broken. For example: The vehicle capacity must not be exceeded.

  • Medium constraints should not be broken. For example: As many visits as possible should be assigned to a vehicle.

  • Soft constraints should not be broken either, but are only considered once the medium constraints are as good as they get. For example: The sum total of travel time.

Hard constraints are weighted against other hard constraints. Medium and soft constraints are weighted too, against other constraints of the same level. Hard constraints always outweigh medium constraints, and medium constraints always outweigh soft constraints, regardless of their respective weights.

The medium level is what makes unassigned visits work. Assigning a visit is always worth more than any saving in travel time, but never worth breaking a hard constraint. So the solver only leaves a visit unassigned when it cannot fit on any route.

3.1. Constraint names

The service module exposes constraints through its REST API, for example in the score analysis and in the constraint weight overrides. To reference them consistently, keep the constraint names in one place.

Create the src/main/java/org/acme/vehiclerouting/domain/VehicleRoutePlanConstraintProperties.java class:

package org.acme.vehiclerouting.domain;

public final class VehicleRoutePlanConstraintProperties {

    public static final String VEHICLE_CAPACITY = "Vehicle capacity";
    public static final String SERVICE_FINISHED_AFTER_MAX_END_TIME = "Service finished after max end time";

    public static final String MAXIMIZE_VISITS_ASSIGNED = "Maximize visits assigned";

    public static final String MINIMIZE_TRAVEL_TIME = "Minimize travel time";

    private VehicleRoutePlanConstraintProperties() {
    }
}

Constraints are also organized in groups, which the Timefold Platform uses to present them. Create the src/main/java/org/acme/vehiclerouting/solver/VehicleRoutePlanConstraintGroup.java class:

package org.acme.vehiclerouting.solver;

import ai.timefold.solver.service.definition.api.description.ConstraintGroupInfo;

public final class VehicleRoutePlanConstraintGroup {

    public static final ConstraintGroupInfo VEHICLE_CAPACITY = new ConstraintGroupInfo("vehicleCapacity",
            "Vehicle capacity",
            "Keep the total demand of the visits on a vehicle's route within the capacity that vehicle has.",
            "IconTruckLoading",
            new String[] { "vehicle capacity" });

    public static final ConstraintGroupInfo TIME_WINDOWS = new ConstraintGroupInfo("timeWindows",
            "Time windows",
            "Service every visit inside the time window it accepts a vehicle in.",
            "IconClock",
            new String[] { "time windows" });

    public static final ConstraintGroupInfo VISIT_ASSIGNMENT = new ConstraintGroupInfo("visitAssignment",
            "Visit assignment",
            "Get as many visits as possible onto a vehicle's route, rather than leaving them unserviced.",
            "IconMapPin",
            new String[] { "visit assignment" });

    public static final ConstraintGroupInfo TRAVEL_TIME = new ConstraintGroupInfo("travelTime",
            "Travel time",
            "Keep the fleet on the road for as little time as possible.",
            "IconRoute",
            new String[] { "travel time" });

    private VehicleRoutePlanConstraintGroup() {
    }
}

3.2. Constraint justifications

Every constraint match can carry a justification: an object that explains why the constraint matched. The service module returns these justifications in the score analysis, so a user of your service can see exactly which vehicle is overloaded or which visit is late.

Each justification is a record that implements ModelConstraintJustification.

Create the src/main/java/org/acme/vehiclerouting/domain/justification/VehicleRoutePlanJustification.java interface:

package org.acme.vehiclerouting.domain.justification;

import ai.timefold.solver.service.definition.api.ModelConstraintJustification;

import org.acme.vehiclerouting.domain.Vehicle;

public interface VehicleRoutePlanJustification extends ModelConstraintJustification {

    String getDescription();

    default String description() {
        return getDescription();
    }

    record VehicleCapacityJustification(String vehicle, int capacity, int totalDemand, int excessDemand)
            implements VehicleRoutePlanJustification {

        public static VehicleCapacityJustification of(Vehicle vehicle) {
            return new VehicleCapacityJustification(vehicle.getId(), vehicle.getCapacity(), vehicle.getTotalDemand(),
                    vehicle.getTotalDemand() - vehicle.getCapacity());
        }

        @Override
        public String getDescription() {
            return "Vehicle '%s' carries a demand of %d, which is %d over its capacity of %d."
                    .formatted(vehicle, totalDemand, excessDemand, capacity);
        }
    }

    // ServiceFinishedAfterMaxEndTimeJustification, VisitNotAssignedJustification
    // and TravelTimeJustification follow the same pattern and are excluded
}

As with the API classes, the complete quickstart annotates the justifications with @Schema so they are documented in the generated OpenAPI specification. There, the interface also lists every justification record in @Schema(oneOf = …​): a record that is not listed does not show up in the specification.

See the quickstart source code for the other three justification records.

3.3. The constraint provider

To calculate the score, create a VehicleRoutePlanConstraintProvider class to perform incremental score calculation. It uses Timefold Solver’s Constraint Streams API which is inspired by Java Streams and SQL.

Each constraint is registered with a ConstraintInfo, which gives it a name, a description and a group. The service module uses this information to describe the constraints of your model in its REST API.

Create the src/main/java/org/acme/vehiclerouting/solver/VehicleRoutePlanConstraintProvider.java class:

package org.acme.vehiclerouting.solver;

import ai.timefold.solver.core.api.score.HardMediumSoftScore;
import ai.timefold.solver.core.api.score.stream.Constraint;
import ai.timefold.solver.core.api.score.stream.ConstraintFactory;
import ai.timefold.solver.core.api.score.stream.ConstraintProvider;
import ai.timefold.solver.service.definition.api.description.ConstraintInfo;

import org.acme.vehiclerouting.domain.Vehicle;
import org.acme.vehiclerouting.domain.VehicleRoutePlanConstraintProperties;
import org.acme.vehiclerouting.domain.Visit;
import org.acme.vehiclerouting.domain.justification.VehicleRoutePlanJustification.ServiceFinishedAfterMaxEndTimeJustification;
import org.acme.vehiclerouting.domain.justification.VehicleRoutePlanJustification.TravelTimeJustification;
import org.acme.vehiclerouting.domain.justification.VehicleRoutePlanJustification.VehicleCapacityJustification;
import org.acme.vehiclerouting.domain.justification.VehicleRoutePlanJustification.VisitNotAssignedJustification;

public class VehicleRoutePlanConstraintProvider implements ConstraintProvider {

    @Override
    public Constraint[] defineConstraints(ConstraintFactory factory) {
        return new Constraint[] {
                // Hard constraints
                vehicleCapacity(factory),
                serviceFinishedAfterMaxEndTime(factory),

                // Medium constraints
                maximizeVisitsAssigned(factory),

                // Soft constraints
                minimizeTravelTime(factory)
        };
    }

    public Constraint vehicleCapacity(ConstraintFactory factory) {
        return factory.forEach(Vehicle.class)
                .filter(vehicle -> vehicle.getTotalDemand() > vehicle.getCapacity())
                .penalize(HardMediumSoftScore.ONE_HARD,
                        vehicle -> vehicle.getTotalDemand() - vehicle.getCapacity())
                .justifyWith((vehicle, score) -> VehicleCapacityJustification.of(vehicle))
                .asConstraint(new ConstraintInfo(VehicleRoutePlanConstraintProperties.VEHICLE_CAPACITY,
                        VehicleRoutePlanConstraintProperties.VEHICLE_CAPACITY,
                        "The total demand of all visits assigned to a vehicle must not exceed its capacity.",
                        VehicleRoutePlanConstraintGroup.VEHICLE_CAPACITY));
    }

    public Constraint serviceFinishedAfterMaxEndTime(ConstraintFactory factory) {
        return factory.forEach(Visit.class)
                .filter(Visit::isServiceFinishedAfterMaxEndTime)
                .penalize(HardMediumSoftScore.ONE_HARD,
                        Visit::getServiceFinishedDelayInMinutes)
                .justifyWith((visit, score) -> ServiceFinishedAfterMaxEndTimeJustification.of(visit))
                .asConstraint(
                        new ConstraintInfo(VehicleRoutePlanConstraintProperties.SERVICE_FINISHED_AFTER_MAX_END_TIME,
                                VehicleRoutePlanConstraintProperties.SERVICE_FINISHED_AFTER_MAX_END_TIME,
                                "A visit must be serviced before its maximum end time.",
                                VehicleRoutePlanConstraintGroup.TIME_WINDOWS));
    }

    public Constraint maximizeVisitsAssigned(ConstraintFactory factory) {
        return factory.forEachIncludingUnassigned(Visit.class)
                .filter(visit -> visit.getVehicle() == null)
                .penalize(HardMediumSoftScore.ONE_MEDIUM, visit -> visit.getServiceDuration().toMinutes())
                .justifyWith((visit, score) -> VisitNotAssignedJustification.of(visit))
                .asConstraint(new ConstraintInfo(VehicleRoutePlanConstraintProperties.MAXIMIZE_VISITS_ASSIGNED,
                        VehicleRoutePlanConstraintProperties.MAXIMIZE_VISITS_ASSIGNED,
                        "As many visits as possible should be assigned to a vehicle.",
                        VehicleRoutePlanConstraintGroup.VISIT_ASSIGNMENT));
    }

    public Constraint minimizeTravelTime(ConstraintFactory factory) {
        return factory.forEach(Vehicle.class)
                .penalize(HardMediumSoftScore.ONE_SOFT,
                        Vehicle::getTotalDrivingTimeSeconds)
                .justifyWith((vehicle, score) -> TravelTimeJustification.of(vehicle))
                .asConstraint(new ConstraintInfo(VehicleRoutePlanConstraintProperties.MINIMIZE_TRAVEL_TIME,
                        VehicleRoutePlanConstraintProperties.MINIMIZE_TRAVEL_TIME,
                        "Minimize the total travel time of all vehicles.",
                        VehicleRoutePlanConstraintGroup.TRAVEL_TIME));
    }
}

Notice that maximizeVisitsAssigned starts from forEachIncludingUnassigned(Visit.class). A plain forEach(Visit.class) skips visits that are not on any route, which are exactly the visits this constraint needs to penalize. The penalty is the service duration of the unassigned visit, so the solver prefers to leave out a short visit rather than a long one.

4. Gather the domain objects in a planning solution

A VehicleRoutePlan wraps all Vehicle and Visit instances of a single dataset. Furthermore, because it contains all vehicles and visits, each with a specific planning variable state, it is a planning solution and it has a score:

  • If it breaks hard constraints, then it is an infeasible solution, for example, a solution with the score -2hard/0medium/-3soft.

  • If it adheres to all hard constraints, then it is a feasible solution, for example, a solution with the score 0hard/-30medium/-7soft.

  • If it is feasible and every visit is assigned, the medium score is 0, for example, 0hard/0medium/-7soft.

VehicleRoutePlan implements LocationsAwareSolverModel<HardMediumSoftScore>. This interface extends the service module’s SolverModel and tells the map service which locations to include in the travel time matrix.

Create the src/main/java/org/acme/vehiclerouting/domain/VehicleRoutePlan.java class:

package org.acme.vehiclerouting.domain;

import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;

import ai.timefold.solver.core.api.domain.solution.ConstraintWeightOverrides;
import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty;
import ai.timefold.solver.core.api.domain.solution.PlanningScore;
import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider;
import ai.timefold.solver.core.api.score.HardMediumSoftScore;
import ai.timefold.solver.service.maps.api.model.Location;
import ai.timefold.solver.service.maps.service.integration.api.LocationsAwareSolverModel;

@PlanningSolution
public class VehicleRoutePlan implements LocationsAwareSolverModel<HardMediumSoftScore> {

    @PlanningEntityCollectionProperty
    private List<Vehicle> vehicles;

    @PlanningEntityCollectionProperty
    @ValueRangeProvider
    private List<Visit> visits;

    @PlanningScore
    private HardMediumSoftScore score;

    private ConstraintWeightOverrides<HardMediumSoftScore> constraintWeightOverrides = ConstraintWeightOverrides.none();

    // Reported back by the map service: the locations it could not resolve, if any.
    private List<Location> locationsNotInMap = List.of();

    public VehicleRoutePlan() {
    }

    public VehicleRoutePlan(List<Vehicle> vehicles, List<Visit> visits) {
        this.vehicles = vehicles;
        this.visits = visits;
    }

    public long getTotalDrivingTimeSeconds() {
        return vehicles == null ? 0 : vehicles.stream().mapToLong(Vehicle::getTotalDrivingTimeSeconds).sum();
    }

    // ── LocationsAwareSolverModel ──

    @Override
    public List<Location> getLocations() {
        if (vehicles == null || visits == null) {
            return List.of();
        }
        return Stream.concat(
                vehicles.stream().map(Vehicle::getHomeLocation),
                visits.stream().map(Visit::getLocation)).toList();
    }

    // Every solve builds its own one-off matrix rather than reusing a named, pre-built one.
    @Override
    public Optional<String> getLocationSetName() {
        return Optional.empty();
    }

    @Override
    public void setLocationsNotInMap(List<Location> locationsNotInMap) {
        this.locationsNotInMap = locationsNotInMap == null ? List.of() : locationsNotInMap;
    }

    @Override
    public List<Location> getLocationsNotInMap() {
        return locationsNotInMap;
    }

    @Override
    public ConstraintWeightOverrides<HardMediumSoftScore> getConstraintWeightOverrides() {
        return constraintWeightOverrides;
    }

    // Other getters and setters excluded
}

The VehicleRoutePlan class has an @PlanningSolution annotation, so Timefold Solver knows that this class contains all of the input and output data.

Specifically, these classes are the input of the problem:

  • The vehicles field with all vehicles

    • This is a list of planning entities, because they change during solving.

    • For each Vehicle:

      • The value of the visits is typically still empty, so unassigned. It is a planning variable.

      • The other fields, such as capacity, homeLocation and departureTime, are filled in. These fields are problem properties.

  • The visits field with all visits

    • This is a list of planning entities, because they change during solving.

    • For each Visit:

      • The values of vehicle, previousVisit and timings are typically still null for a fresh solution. They are shadow variables.

      • The other fields, such as name, location and demand, are filled in. These fields are problem properties.

However, this class is also the output of the solution:

  • The vehicles field for which each Vehicle instance has its visits filled in after solving.

  • The score field that represents the quality of the output solution, for example, 0hard/0medium/-5soft.

getConstraintWeightOverrides() is required by the SolverModel interface. The model convertor fills it in when a request overrides a constraint weight.

4.1. The value range providers

The visits field is a value range provider. It holds the Visit instances which Timefold Solver can pick from to assign to the visits field of Vehicle instances. The visits field has an @ValueRangeProvider annotation to connect the @PlanningListVariable with the @ValueRangeProvider, by matching the type of the planning list variable with the type returned by the value range provider.

4.2. Driving times from the map service

A matrix of driving times between each pair of locations has to be available before the solver starts. You do not build that matrix yourself: the map service of the service module builds it.

Before every solve, the service module enriches the solver model. Because VehicleRoutePlan implements LocationsAwareSolverModel, the map service is one of those enrichers:

  • getLocations() returns every location the matrix needs to cover: every vehicle’s home location plus every visit’s location.

  • getLocationSetName() returns empty, so each solve builds its own one-off matrix rather than reusing a named, pre-built one.

  • setLocationsNotInMap() lets the map service report back any locations it could not resolve, so the model retains that information instead of silently dropping it.

After that, Location.getTravelTimeTo(otherLocation) returns the driving time between any two of those locations. Vehicle.getTotalDrivingTimeSeconds() and Visit.getDrivingTimeSecondsFromPreviousStandstill() both rely on it.

Two properties control how the matrix gets built:

timefold.platform.map-service.use-remote=false
timefold.platform.map-service.enable-fallback=true
  • use-remote switches between the remote map service of the Timefold Platform, which uses real road-network driving times, and a local computation.

  • enable-fallback allows falling back to the local computation when the remote one is disabled or unavailable. The local computation estimates driving times from the great-circle (Haversine) distance.

Running locally, you use the local computation. When you deploy the model to the Timefold Platform, the maps service of the platform provides real road-network driving times without any change to your code.

5. Define the API of the service

The domain classes are built for the solver: they hold object references, shadow variables and a travel time matrix. The users of your service should not have to know about any of that. So the service has its own API classes, and a ModelConvertor translates between the two.

  • ModelInput: the problem a user submits.

  • ModelOutput: the solution the service returns.

  • ModelConfigOverrides: the settings a user can change per request, such as constraint weights.

In the complete quickstart, every class and field below also carries a MicroProfile OpenAPI @Schema annotation, for example @Schema(description = "Unique identifier of the vehicle.", required = true, minLength = 1). These annotations only serve to generate the OpenAPI specification: they describe each field and mark which fields are required and which values are allowed. The service module validates incoming requests against that specification, so a request with a missing required field or a value out of range is rejected before it reaches your code.

To keep the listings short, this guide leaves the @Schema annotations out. Add them to your own DTOs to get a documented and validated API.

5.1. The input

In the input, a route is a list of visit IDs. A new problem has empty routes, but a user can also submit an existing plan, for example to improve it further. A visit that appears in no vehicle’s visitIds is unassigned.

Create the src/main/java/org/acme/vehiclerouting/dto/input/LocationInputDTO.java record:

package org.acme.vehiclerouting.dto.input;

public record LocationInputDTO(Double latitude, Double longitude) {
}

Create the src/main/java/org/acme/vehiclerouting/dto/input/VehicleInputDTO.java record:

package org.acme.vehiclerouting.dto.input;

import static java.util.Collections.emptyList;

import java.time.OffsetDateTime;
import java.util.List;

public record VehicleInputDTO(
        String id,
        Integer capacity,
        LocationInputDTO homeLocation,
        OffsetDateTime departureTime,
        // The visits on this vehicle's route, in order. Empty when the vehicle has no route yet.
        List<String> visitIds) {

    public VehicleInputDTO {
        visitIds = visitIds != null ? visitIds : emptyList();
    }

    public VehicleInputDTO withVisitIds(List<String> visitIds) {
        return new VehicleInputDTO(id, capacity, homeLocation, departureTime, visitIds);
    }
}

Create the src/main/java/org/acme/vehiclerouting/dto/input/VisitInputDTO.java record:

package org.acme.vehiclerouting.dto.input;

import java.time.OffsetDateTime;

public record VisitInputDTO(
        String id,
        String name,
        LocationInputDTO location,
        Integer demand,
        OffsetDateTime minStartTime,
        OffsetDateTime maxEndTime,
        Integer serviceDurationMinutes) {
}

Finally, the input itself wraps the vehicles and the visits and implements ModelInput. Create the src/main/java/org/acme/vehiclerouting/dto/input/VehicleRoutePlanInput.java record:

package org.acme.vehiclerouting.dto.input;

import java.time.OffsetDateTime;
import java.util.List;

import ai.timefold.solver.service.definition.api.ModelInput;

public record VehicleRoutePlanInput(
        OffsetDateTime startDateTime,
        OffsetDateTime endDateTime,
        List<VehicleInputDTO> vehicles,
        List<VisitInputDTO> visits)
        implements
            ModelInput {

    public VehicleRoutePlanInput withVehicles(List<VehicleInputDTO> vehicles) {
        return new VehicleRoutePlanInput(startDateTime, endDateTime, vehicles, visits);
    }
}

All date-times are OffsetDateTime, so the JSON always carries an offset, for example 2026-02-10T07:30:00Z.

5.2. The configuration overrides

ModelConfigOverrides lists what a user can tune per request. In this model, that is the weight of the Minimize travel time constraint. @ConstraintReference links the field to that constraint. A weight left unset (null) is not overridden, so the value from the configuration profile (or the constraint’s default) applies. Read Model configuration overrides to learn more.

Create the src/main/java/org/acme/vehiclerouting/dto/input/VehicleRoutePlanConfigOverrides.java record:

package org.acme.vehiclerouting.dto.input;

import ai.timefold.solver.service.definition.api.ModelConfigOverrides;
import ai.timefold.solver.service.definition.api.domain.ConstraintReference;

import org.acme.vehiclerouting.domain.VehicleRoutePlanConstraintProperties;

import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record VehicleRoutePlanConfigOverrides(
        @ConstraintReference(VehicleRoutePlanConstraintProperties.MINIMIZE_TRAVEL_TIME) Long minimizeTravelTimeWeight)
        implements
            ModelConfigOverrides {

    // Required by the service module to generate the default configuration profile.
    public VehicleRoutePlanConfigOverrides() {
        this(null);
    }
}

5.3. The output

The output mirrors the input. For each vehicle, it returns the route as a list of visit IDs, along with its total demand and driving time. For each visit, it returns the vehicle that services it and when it does so. The fields of an unassigned visit are null.

Create the src/main/java/org/acme/vehiclerouting/dto/output/VehicleOutputDTO.java record:

package org.acme.vehiclerouting.dto.output;

import java.time.OffsetDateTime;
import java.util.List;

import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.ALWAYS)
public record VehicleOutputDTO(
        String id,
        List<String> visitIds,
        Integer totalDemand,
        Long totalDrivingTimeSeconds,
        OffsetDateTime arrivalTime) {
}

Create the src/main/java/org/acme/vehiclerouting/dto/output/VisitOutputDTO.java record:

package org.acme.vehiclerouting.dto.output;

import java.time.OffsetDateTime;

import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.ALWAYS)
public record VisitOutputDTO(
        String id,
        String vehicleId,
        OffsetDateTime arrivalTime,
        OffsetDateTime startServiceTime,
        OffsetDateTime departureTime,
        Long drivingTimeSecondsFromPreviousStandstill) {
}

Create the src/main/java/org/acme/vehiclerouting/dto/output/VehicleRoutePlanOutput.java record:

package org.acme.vehiclerouting.dto.output;

import java.util.List;

import ai.timefold.solver.service.definition.api.ModelOutput;

public record VehicleRoutePlanOutput(
        List<VehicleOutputDTO> vehicles,
        List<VisitOutputDTO> visits)
        implements
            ModelOutput {
}

5.4. The model convertor

The ModelConvertor connects the API classes to the domain classes. The service module calls it at three moments:

  • toSolverModel(): before solving, to turn the input (and the configuration overrides) into a VehicleRoutePlan. When the service resumes a run that was interrupted, lastModelOutput holds the last known solution, so the routes continue from there instead of starting over.

  • toModelOutput(): whenever a new best solution is found, to turn the VehicleRoutePlan into the output.

  • applyOutputToInput(): to overlay a solution on the original input, for example to submit the result of one run as the starting point of the next.

Create the src/main/java/org/acme/vehiclerouting/service/VehicleRoutePlanModelConvertor.java class:

package org.acme.vehiclerouting.service;

import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

import jakarta.enterprise.context.ApplicationScoped;

import ai.timefold.solver.core.api.domain.solution.ConstraintWeightOverrides;
import ai.timefold.solver.core.api.score.HardMediumSoftScore;
import ai.timefold.solver.service.definition.api.ModelConvertor;
import ai.timefold.solver.service.definition.api.domain.ModelConfig;
import ai.timefold.solver.service.maps.api.model.Location;

import org.acme.vehiclerouting.domain.Vehicle;
import org.acme.vehiclerouting.domain.VehicleRoutePlan;
import org.acme.vehiclerouting.domain.VehicleRoutePlanConstraintProperties;
import org.acme.vehiclerouting.domain.Visit;
import org.acme.vehiclerouting.dto.input.LocationInputDTO;
import org.acme.vehiclerouting.dto.input.VehicleInputDTO;
import org.acme.vehiclerouting.dto.input.VehicleRoutePlanConfigOverrides;
import org.acme.vehiclerouting.dto.input.VehicleRoutePlanInput;
import org.acme.vehiclerouting.dto.input.VisitInputDTO;
import org.acme.vehiclerouting.dto.output.VehicleOutputDTO;
import org.acme.vehiclerouting.dto.output.VehicleRoutePlanOutput;
import org.acme.vehiclerouting.dto.output.VisitOutputDTO;

@ApplicationScoped
public class VehicleRoutePlanModelConvertor implements
        ModelConvertor<HardMediumSoftScore, VehicleRoutePlanInput, VehicleRoutePlanConfigOverrides, VehicleRoutePlan, VehicleRoutePlanOutput> {

    @Override
    public VehicleRoutePlan toSolverModel(VehicleRoutePlanInput modelInput,
            ModelConfig<VehicleRoutePlanConfigOverrides> modelConfig,
            Optional<VehicleRoutePlanOutput> lastModelOutput) {
        Map<String, Visit> visitMap = modelInput.visits().stream()
                .map(VehicleRoutePlanModelConvertor::toVisit)
                .collect(Collectors.toMap(Visit::getId, visit -> visit, (first, second) -> first, LinkedHashMap::new));
        List<Vehicle> vehicles = modelInput.vehicles().stream()
                .map(VehicleRoutePlanModelConvertor::toVehicle)
                .toList();

        VehicleRoutePlan routePlan = new VehicleRoutePlan(vehicles, List.copyOf(visitMap.values()));
        applyConstraintWeightOverrides(routePlan, modelConfig);
        applyRoutes(vehicles, visitMap, modelInput, lastModelOutput);
        return routePlan;
    }

    @Override
    public VehicleRoutePlanOutput toModelOutput(VehicleRoutePlan solverModel) {
        List<VehicleOutputDTO> vehicles = solverModel.getVehicles().stream()
                .map(vehicle -> new VehicleOutputDTO(vehicle.getId(),
                        vehicle.getVisits().stream().map(Visit::getId).toList(),
                        vehicle.getTotalDemand(), vehicle.getTotalDrivingTimeSeconds(), vehicle.arrivalTime()))
                .toList();
        List<VisitOutputDTO> visits = solverModel.getVisits().stream()
                .map(visit -> new VisitOutputDTO(visit.getId(),
                        visit.getVehicle() == null ? null : visit.getVehicle().getId(),
                        visit.getArrivalTime(), visit.getStartServiceTime(), visit.getDepartureTime(),
                        visit.getDrivingTimeSecondsFromPreviousStandstillOrNull()))
                .toList();
        return new VehicleRoutePlanOutput(vehicles, visits);
    }

    @Override
    public VehicleRoutePlanInput applyOutputToInput(VehicleRoutePlanInput modelInput,
            VehicleRoutePlanOutput modelOutput) {
        // The assignment is the route list, so overlaying the output means replacing one list per vehicle.
        Map<String, VehicleOutputDTO> routeByVehicleId = modelOutput.vehicles().stream()
                .collect(Collectors.toMap(VehicleOutputDTO::id, vehicle -> vehicle));
        List<VehicleInputDTO> updatedVehicles = modelInput.vehicles().stream()
                .map(vehicle -> {
                    VehicleOutputDTO solved = routeByVehicleId.get(vehicle.id());
                    return solved == null || solved.visitIds() == null ? vehicle : vehicle.withVisitIds(solved.visitIds());
                })
                .toList();
        return modelInput.withVehicles(updatedVehicles);
    }

    private static Location toLocation(LocationInputDTO dto) {
        return new Location(dto.latitude(), dto.longitude());
    }

    private static Vehicle toVehicle(VehicleInputDTO dto) {
        return new Vehicle(dto.id(), dto.capacity(), toLocation(dto.homeLocation()), dto.departureTime());
    }

    private static Visit toVisit(VisitInputDTO dto) {
        return new Visit(dto.id(), dto.name(), toLocation(dto.location()), dto.demand(), dto.minStartTime(),
                dto.maxEndTime(), Duration.ofMinutes(dto.serviceDurationMinutes()));
    }

    private static void applyConstraintWeightOverrides(VehicleRoutePlan routePlan,
            ModelConfig<VehicleRoutePlanConfigOverrides> modelConfig) {
        if (modelConfig == null || modelConfig.overrides() == null) {
            return;
        }
        // A null weight means the input did not override it,
        // so the configuration profile value (or the constraint's default) is kept.
        Long minimizeTravelTimeWeight = modelConfig.overrides().minimizeTravelTimeWeight();
        if (minimizeTravelTimeWeight != null) {
            Map<String, HardMediumSoftScore> weights = new HashMap<>();
            weights.put(VehicleRoutePlanConstraintProperties.MINIMIZE_TRAVEL_TIME,
                    HardMediumSoftScore.ofSoft(minimizeTravelTimeWeight));
            routePlan.setConstraintWeightOverrides(ConstraintWeightOverrides.of(weights));
        }
    }

    /**
     * Fills the list variable of every vehicle: from lastModelOutput when a halted run is being
     * recovered, and from the input's own routes otherwise. The shadow variables are deliberately
     * not set here - the solver derives them when it loads the solution.
     */
    private static void applyRoutes(List<Vehicle> vehicles, Map<String, Visit> visitMap,
            VehicleRoutePlanInput modelInput, Optional<VehicleRoutePlanOutput> lastModelOutput) {
        Map<String, List<String>> routeByVehicleId = lastModelOutput
                .map(output -> output.vehicles().stream()
                        .filter(vehicle -> vehicle.visitIds() != null)
                        .collect(Collectors.toMap(VehicleOutputDTO::id, VehicleOutputDTO::visitIds)))
                .orElseGet(() -> modelInput.vehicles().stream()
                        .collect(Collectors.toMap(VehicleInputDTO::id, VehicleInputDTO::visitIds)));

        for (Vehicle vehicle : vehicles) {
            List<String> visitIds = routeByVehicleId.get(vehicle.getId());
            if (visitIds == null || visitIds.isEmpty()) {
                continue;
            }
            // The solver mutates this list, so it cannot be an immutable copy of the input's.
            List<Visit> route = new ArrayList<>(visitIds.size());
            for (String visitId : visitIds) {
                Visit visit = visitMap.get(visitId);
                if (visit == null) {
                    throw new IllegalArgumentException("Unknown visit '%s'.".formatted(visitId));
                }
                route.add(visit);
            }
            vehicle.setVisits(route);
        }
    }
}

Notice that the convertor does not touch the travel time matrix. The service module calls the map service after toSolverModel(), as part of model enrichment, so the VehicleRoutePlan already has its driving times by the time the solver starts.

6. Expose the REST API

To expose the service, provide an interface which extends the ModelRest interface. The service module generates all the REST endpoints from it: submitting a problem, polling for the solution, terminating a run early, score analysis and more.

Create the src/main/java/org/acme/vehiclerouting/rest/VehicleRoutePlanResource.java interface:

package org.acme.vehiclerouting.rest;

import jakarta.ws.rs.Path;

import ai.timefold.solver.service.rest.api.ModelRest;

// Endpoints are automatically added by the service module.
@Path("/route-plans")
public interface VehicleRoutePlanResource extends ModelRest {
}

The @Path annotation configures the base path of all those endpoints. The service module prefixes it with the API version, so the endpoints are served at /v1/route-plans.

7. Configure the service

Without a termination setting, the solver runs forever. You also need to provide some basic metadata about your service.

Create the src/main/resources/application.properties file:

########################
# Timefold Solver properties
########################

# The solver runs for 30 seconds. To run for 5 minutes use "PT5M" and for 2 hours use "PT2H".
timefold.model.termination.spent-limit=PT30S

########################
# Model information
########################

timefold.model.id=vehicle-routing
quarkus.application.name=${timefold.model.id}
timefold.model.name=Vehicle Routing
model.api.version=v1
timefold.model.api-version=${model.api.version}

timefold.model.contact.email=example@acme.com
timefold.model.contact.name=A.C.M.E.
timefold.model.contact.url=https://acme.com

########################
# Model settings
########################

timefold.model.max-thread-count=16
timefold.model.default-config.max-thread-count=1
timefold.platform.map-service.use-remote=false
timefold.platform.map-service.enable-fallback=true

########################
# Test overrides
########################

%test.timefold.model.termination.spent-limit=PT30S
%test.timefold.model.termination.best-score-limit=0hard/0medium/*soft
  • timefold.model.termination.spent-limit sets the default maximum time the solver runs, in ISO 8601 duration format. A request can override it with config.run.termination.spentLimit.

  • timefold.model.name and the contact fields are required metadata. They identify your service and populate the generated OpenAPI specification.

  • The timefold.platform.map-service properties are explained in Driving times from the map service.

  • The test overrides stop the solver as soon as every visit is assigned without breaking a hard constraint (0hard/0medium/*soft). The spent limit remains as a backstop, for a dataset where the fleet cannot absorb every visit.

Timefold Solver returns the best solution found in the available termination time. Due to the nature of NP-hard problems, the best solution might not be optimal, especially for larger datasets. Increase the termination time to potentially find a better solution.

8. Run the application

First start the application:

$ mvn quarkus:dev

The solver runs considerably slower in dev mode since the JVM C2 compiler is disabled to decrease live reload times. Do not use dev mode to benchmark the solver or assess solution quality. See FAQ: Why is Timefold Solver so much slower in Quarkus Dev mode? for details.

Open the Swagger UI to inspect the generated endpoints.

8.1. Try the application

Now that the application is running, you can test the REST service. You can use any REST client you wish. The following example uses the Linux command curl to send a POST request:

$ curl -X POST http://localhost:8080/v1/route-plans -H "Content-Type: application/json" -d '{
  "config": {
    "run": {
      "termination": {
        "spentLimit": "PT5S"
      }
    }
  },
  "modelInput": {
    "startDateTime": "2026-02-10T07:30:00Z",
    "endDateTime": "2026-02-11T00:00:00Z",
    "vehicles": [
      {"id": "1", "capacity": 15, "homeLocation": {"latitude": 40.6059, "longitude": -75.6810}, "departureTime": "2026-02-10T07:30:00Z"},
      {"id": "2", "capacity": 25, "homeLocation": {"latitude": 40.3219, "longitude": -75.6978}, "departureTime": "2026-02-10T07:30:00Z"}
    ],
    "visits": [
      {"id": "1", "name": "Dan Green", "location": {"latitude": 40.7610, "longitude": -75.1605}, "demand": 1, "minStartTime": "2026-02-10T13:00:00Z", "maxEndTime": "2026-02-10T18:00:00Z", "serviceDurationMinutes": 20},
      {"id": "2", "name": "Ivy King", "location": {"latitude": 40.1375, "longitude": -75.4925}, "demand": 1, "minStartTime": "2026-02-10T13:00:00Z", "maxEndTime": "2026-02-10T18:00:00Z", "serviceDurationMinutes": 20},
      {"id": "3", "name": "Flo Li", "location": {"latitude": 39.8712, "longitude": -75.6452}, "demand": 2, "minStartTime": "2026-02-10T08:00:00Z", "maxEndTime": "2026-02-10T12:00:00Z", "serviceDurationMinutes": 10},
      {"id": "4", "name": "Flo Cole", "location": {"latitude": 40.4612, "longitude": -75.1825}, "demand": 1, "minStartTime": "2026-02-10T13:00:00Z", "maxEndTime": "2026-02-10T18:00:00Z", "serviceDurationMinutes": 40},
      {"id": "5", "name": "Carl Green", "location": {"latitude": 40.6135, "longitude": -75.8330}, "demand": 1, "minStartTime": "2026-02-10T08:00:00Z", "maxEndTime": "2026-02-10T12:00:00Z", "serviceDurationMinutes": 30}
    ]
  }
}'

The service does not wait for the solver to finish. It answers right away with 202 Accepted and the metadata of the new run:

{
  "id": "7f3a91bc-4e2d-4c1a-b8f6-1234567890ab",
  "name": "Dataset-2026-02-09T10:15:30.123+01:00",
  "submitDateTime": "2026-02-09T10:15:30.123+01:00",
  "solverStatus": "DATASET_CREATED"
}

Use the id to retrieve the (intermediate) solution:

$ curl http://localhost:8080/v1/route-plans/7f3a91bc-4e2d-4c1a-b8f6-1234567890ab

After about five seconds, according to the spentLimit of the request, the service returns an output similar to the following example:

{
  "metadata": {
    "id": "7f3a91bc-4e2d-4c1a-b8f6-1234567890ab",
    ...
    "solverStatus": "SOLVING_COMPLETED",
    "score": "0hard/0medium/-18716soft"
  },
  "modelOutput": {
    "vehicles": [
      {"id": "1", "visitIds": ["5", "1", "4"], "totalDemand": 3, "totalDrivingTimeSeconds": 10826, "arrivalTime": "2026-02-10T15:34:11Z"},
      {"id": "2", "visitIds": ["3", "2"], "totalDemand": 3, "totalDrivingTimeSeconds": 7890, "arrivalTime": "2026-02-10T13:52:18Z"}
    ],
    "visits": [
      {"id": "1", "vehicleId": "1", "arrivalTime": "2026-02-10T09:40:50Z", "startServiceTime": "2026-02-10T13:00:00Z", "departureTime": "2026-02-10T13:20:00Z", "drivingTimeSecondsFromPreviousStandstill": 4250},
      ...
    ]
  }
}

Notice that your application assigned all five visits to one of the two vehicles, so the medium score is 0. Also notice that it conforms to all hard constraints. For example, visits 5, 1, and 4 were scheduled, in that order, to vehicle 1.

The exact numbers depend on the driving times. Running locally, the map service estimates them from the straight-line distance.

To see which constraints contribute to the score, call the score analysis endpoint:

$ curl http://localhost:8080/v1/route-plans/7f3a91bc-4e2d-4c1a-b8f6-1234567890ab/score-analysis

Every constraint match in the response carries its justification, for example "Vehicle '1' drives 180 minute(s) to service 3 visit(s).".

See the service consumer guide for everything a client of your service can do, including polling versus Server-Sent Events and terminating a run early.

8.2. Test the application

A good application includes test coverage. The parent POM already brings in JUnit, REST Assured, Awaitility and AssertJ.

8.2.1. Test the constraints

To test each constraint in isolation, use a ConstraintVerifier in unit tests. It tests each constraint’s corner cases in isolation from the other tests, which lowers maintenance when adding a new constraint with proper test coverage.

A ConstraintVerifier test builds the domain objects directly, so it bypasses the model enrichment step in which the map service builds the travel time matrix. The timefold-solver-service-maps-service-test dependency provides the classes to build that matrix yourself: HaversineTravelTimeAndDistanceMatrixProvider, the same straight-line calculation the map service uses locally, and TestDistanceCalculator, which fills in the matrix for a list of locations.

Create the src/test/java/org/acme/vehiclerouting/solver/VehicleRoutePlanConstraintProviderTest.java class:

package org.acme.vehiclerouting.solver;

import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;

import jakarta.inject.Inject;

import ai.timefold.solver.core.api.score.stream.test.ConstraintVerifier;
import ai.timefold.solver.core.api.solver.SolutionManager;
import ai.timefold.solver.service.maps.api.model.Location;
import ai.timefold.solver.service.maps.haversine.impl.HaversineTravelTimeAndDistanceMatrixProvider;
import ai.timefold.solver.service.maps.service.test.api.TestDistanceCalculator;

import org.acme.vehiclerouting.domain.Vehicle;
import org.acme.vehiclerouting.domain.VehicleRoutePlan;
import org.acme.vehiclerouting.domain.Visit;
import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.databind.ObjectMapper;

import io.quarkus.test.junit.QuarkusTest;

@QuarkusTest
class VehicleRoutePlanConstraintProviderTest {

    private static final OffsetDateTime DAY_START = OffsetDateTime.of(2024, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC);

    private static final HaversineTravelTimeAndDistanceMatrixProvider PROVIDER =
            new HaversineTravelTimeAndDistanceMatrixProvider(new ObjectMapper());

    @Inject
    ConstraintVerifier<VehicleRoutePlanConstraintProvider, VehicleRoutePlan> constraintVerifier;

    @Test
    void vehicleCapacity() {
        Vehicle vehicle = new Vehicle("1", 10, new Location(51.00, 3.65), DAY_START.withHour(7));
        Visit visit1 = aVisit("1", new Location(51.01, 3.66), 5);
        Visit visit2 = aVisit("2", new Location(51.02, 3.68), 8);
        vehicle.getVisits().addAll(List.of(visit1, visit2));

        // Three over capacity: 5 + 8 of 10.
        constraintVerifier.verifyThat(VehicleRoutePlanConstraintProvider::vehicleCapacity)
                .givenSolution(aRoutePlan(List.of(vehicle), List.of(visit1, visit2)))
                .penalizesBy(3);
    }

    private static Visit aVisit(String id, Location location, int demand) {
        return new Visit(id, "Visit " + id, location, demand,
                DAY_START.withHour(8), DAY_START.withHour(18), Duration.ofMinutes(10));
    }

    private static VehicleRoutePlan aRoutePlan(List<Vehicle> vehicles, List<Visit> visits) {
        VehicleRoutePlan plan = new VehicleRoutePlan(vehicles, visits);
        // Build the travel time matrix the map service would otherwise build.
        TestDistanceCalculator.initDistanceMaps(plan.getLocations(),
                PROVIDER::calculateDistance,
                PROVIDER::calculateTravelTime);
        SolutionManager.updateShadowVariables(plan);
        return plan;
    }
}

This test verifies that the constraint VehicleRoutePlanConstraintProvider::vehicleCapacity, when given two visits assigned to the same vehicle, penalizes with a match weight of 3 (exceeded capacity). So with a constraint weight of 1hard it would reduce the score by -3hard.

Notice how ConstraintVerifier ignores the constraint weight during testing - even if those constraint weights are hard coded in the ConstraintProvider - because constraints weights change regularly before going into production. This way, constraint weight tweaking does not break the unit tests.

8.2.2. Test the service

In a JUnit test, send a small dataset to the REST API and wait until the run finishes.

Create the src/test/java/org/acme/vehiclerouting/rest/VehicleRoutePlanResourceTest.java class:

package org.acme.vehiclerouting.rest;

import static io.restassured.RestAssured.get;
import static io.restassured.RestAssured.given;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;

import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Set;

import jakarta.inject.Inject;

import org.acme.vehiclerouting.dto.input.LocationInputDTO;
import org.acme.vehiclerouting.dto.input.VehicleInputDTO;
import org.acme.vehiclerouting.dto.input.VehicleRoutePlanInput;
import org.acme.vehiclerouting.dto.input.VisitInputDTO;
import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.databind.ObjectMapper;

import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;

@QuarkusTest
class VehicleRoutePlanResourceTest {

    private static final OffsetDateTime DAY_START = OffsetDateTime.of(2024, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC);

    private static final Set<String> TERMINAL_STATUSES = Set.of(
            "DATASET_INVALID",
            "SOLVING_COMPLETED",
            "SOLVING_FAILED",
            "SOLVING_INCOMPLETE");

    // The ObjectMapper of the application, which knows how to serialize OffsetDateTime.
    @Inject
    ObjectMapper mapper;

    @Test
    void solveUntilFeasible() throws Exception {
        List<VehicleInputDTO> vehicles = List.of(
                new VehicleInputDTO("1", 20, new LocationInputDTO(51.00, 3.65), DAY_START.withHour(7), List.of()),
                new VehicleInputDTO("2", 20, new LocationInputDTO(51.05, 3.75), DAY_START.withHour(7), List.of()));
        List<VisitInputDTO> visits = List.of(
                aVisit("1", 51.01, 3.66),
                aVisit("2", 51.02, 3.68),
                aVisit("3", 51.06, 3.76),
                aVisit("4", 51.07, 3.78));
        var input = new VehicleRoutePlanInput(DAY_START.withHour(7), DAY_START.plusDays(1), vehicles, visits);

        String datasetId = given()
                .contentType(ContentType.JSON)
                .body(mapper.writeValueAsString(Map.of("modelInput", input)))
                .when().post("/v1/route-plans")
                .then()
                .statusCode(202)
                .extract().jsonPath().getString("id");

        await()
                .atMost(Duration.ofMinutes(1))
                .pollInterval(Duration.ofMillis(500L))
                .until(() -> TERMINAL_STATUSES.contains(
                        get("/v1/route-plans/" + datasetId).jsonPath().getString("metadata.solverStatus")));

        var response = get("/v1/route-plans/" + datasetId).then().extract().jsonPath();
        assertThat(response.getString("metadata.solverStatus")).isEqualTo("SOLVING_COMPLETED");
        assertThat(response.getString("metadata.score")).startsWith("0hard/0medium/");
    }

    private static VisitInputDTO aVisit(String id, double latitude, double longitude) {
        return new VisitInputDTO(id, "Visit " + id, new LocationInputDTO(latitude, longitude), 1,
                DAY_START.withHour(8), DAY_START.withHour(18), 10);
    }
}

This test verifies that after solving, the service found a solution that assigns every visit without breaking a hard constraint.

The %test properties in application.properties terminate the solver as soon as such a solution (0hard/0medium/*soft) is found. This avoids hard coding a solver time, because the test might run on arbitrary hardware. This approach ensures that the test runs long enough to find a feasible solution, even on slow machines. But it does not run a millisecond longer than it strictly must, even on fast machines.

8.3. Generate the initial OpenAPI specification

Before running a full build with mvn install, generate the initial OpenAPI specification file. The build compares the generated specification against src/build/openapi.json to catch accidental API changes, so the build fails if that file does not exist yet.

Run the following command once to create it:

$ mvn clean package -Dupdate-api

We recommend committing src/build/openapi.json to version control. See Deliberate API changes for more details.

8.4. Logging

When adding constraints in your ConstraintProvider, keep an eye on the move evaluation speed in the info log, after solving for the same amount of time, to assess the performance impact:

... Solving ended: ..., move evaluation speed (29455/sec), ...

To understand how Timefold Solver is solving your problem internally, change the logging in the application.properties file or with a -D system property:

quarkus.log.category."ai.timefold.solver".level=debug

Use debug logging to show every step and trace logging to show every step and every move per step.

9. Going further

The complete quickstart adds more on top of what this guide covers.

9.1. Nearby selection (Enterprise Edition)

Nearby selection is a Timefold Solver Enterprise Edition feature. It makes the solver focus on moves between visits that are close to each other, which makes a big difference for routing problems.

Nearby selection needs a NearbyDistanceMeter that measures how close a visit is to another visit or to a vehicle. To cover both with a single class, first let Vehicle and Visit expose their location through a common interface.

Create the src/main/java/org/acme/vehiclerouting/domain/LocationAware.java interface:

package org.acme.vehiclerouting.domain;

import ai.timefold.solver.service.maps.api.model.Location;

public interface LocationAware {

    Location getLocation();
}

Make Vehicle and Visit implement it. Visit already has a getLocation() method. For Vehicle, add one that returns its home location:

@PlanningEntity
public class Vehicle implements LocationAware {

    ...

    @Override
    public Location getLocation() {
        return homeLocation;
    }
}
@PlanningEntity
public class Visit implements LocationAware {
    ...
}

Then create the src/main/java/org/acme/vehiclerouting/domain/LocationDistanceMeter.java class:

package org.acme.vehiclerouting.domain;

import ai.timefold.solver.core.impl.heuristic.selector.common.nearby.NearbyDistanceMeter;

public class LocationDistanceMeter implements NearbyDistanceMeter<Visit, LocationAware> {

    @Override
    public double getNearbyDistance(Visit origin, LocationAware destination) {
        return origin.getLocation().getTravelTimeTo(destination.getLocation()).seconds();
    }
}

Finally, register it in application.properties:

%enterprise.quarkus.timefold.solver.nearby-distance-meter-class=org.acme.vehiclerouting.domain.LocationDistanceMeter

The %enterprise prefix only applies the property under the enterprise Maven profile (mvn quarkus:dev -Denterprise), so the model still runs under the Community Edition.

9.2. Everything else

  • Input validation: a ModelValidator rejects inputs that are well-formed but make no sense, such as duplicate IDs, a route referring to a visit that does not exist, or a time window too short for its service duration. See Validating REST input.

  • Demo data: a DemoDataGenerator publishes ready-to-solve datasets under /v1/demo-data, which the web UI of the quickstart uses. See Demo data.

  • Metrics: VehicleRoutePlan also implements InputMetricsAware and OutputMetricsAware, to report figures such as the number of unassigned visits and the total driving time. See Exposing metrics.

  • Recommended assignments: a custom endpoint next to the generated ones, POST /v1/route-plans/recommendation, answers the question "where would this new visit fit best into the plan we already have?" It uses the Assignment Recommendation API, which is a Timefold Solver Enterprise Edition feature.

  • Deploying to the Timefold Platform: see Deploying to Timefold Platform.

Summary

Congratulations! You have just developed a vehicle routing optimization service with Timefold!

For the full implementation with a web UI, demo data, validation and recommendations, check out the quickstart source code.

  • © 2026 Timefold BV
  • Timefold.ai
  • Documentation
  • Changelog
  • Send feedback
  • Privacy
  • Legal
    • Light mode
    • Dark mode
    • System default