diff --git a/pom.xml b/pom.xml
index 96b597390cb..6a239d54510 100644
--- a/pom.xml
+++ b/pom.xml
@@ -22,7 +22,7 @@
     </scm>
 
     <properties>
-        <otp.serialization.version.id>15</otp.serialization.version.id>
+        <otp.serialization.version.id>16</otp.serialization.version.id>
         <!-- Lib versions - keep list sorted on property name -->
         <geotools.version>25.2</geotools.version>
         <jackson.version>2.12.5</jackson.version>
diff --git a/src/ext-test/java/org/opentripplanner/ext/flex/ScheduledDeviatedTripTest.java b/src/ext-test/java/org/opentripplanner/ext/flex/ScheduledDeviatedTripTest.java
index dd6731bccc2..06677ce570d 100644
--- a/src/ext-test/java/org/opentripplanner/ext/flex/ScheduledDeviatedTripTest.java
+++ b/src/ext-test/java/org/opentripplanner/ext/flex/ScheduledDeviatedTripTest.java
@@ -203,7 +203,7 @@ public void shouldNotInterpolateFlexTimes() {
         var feedId = graph.getFeedIds().iterator().next();
         var pattern = graph.tripPatternForId.get(new FeedScopedId(feedId, "090z:0:01"));
 
-        assertEquals(3, pattern.getStops().size());
+        assertEquals(3, pattern.numberOfStops());
 
         var tripTimes = pattern.getScheduledTimetable().getTripTimes(0);
         var arrivalTime = tripTimes.getArrivalTime(1);
diff --git a/src/ext-test/java/org/opentripplanner/ext/flex/UnscheduledTripTest.java b/src/ext-test/java/org/opentripplanner/ext/flex/UnscheduledTripTest.java
index 0873c0823e7..53b7dc0a09b 100644
--- a/src/ext-test/java/org/opentripplanner/ext/flex/UnscheduledTripTest.java
+++ b/src/ext-test/java/org/opentripplanner/ext/flex/UnscheduledTripTest.java
@@ -3,7 +3,6 @@
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 
-import java.net.URISyntaxException;
 import java.util.List;
 import java.util.Set;
 import java.util.stream.Collectors;
diff --git a/src/ext/java/org/opentripplanner/ext/reportapi/model/CsvReportBuilder.java b/src/ext/java/org/opentripplanner/ext/reportapi/model/CsvReportBuilder.java
index f9d3d8b8cf9..cfd01782b24 100644
--- a/src/ext/java/org/opentripplanner/ext/reportapi/model/CsvReportBuilder.java
+++ b/src/ext/java/org/opentripplanner/ext/reportapi/model/CsvReportBuilder.java
@@ -6,7 +6,7 @@
 /**
  * A very simple CSV builder to create CSV reports.
  * <p>
- * This class helps formatting common types like time, duration and enums.
+ * This class helps to format common types like time, duration and enums.
  */
 class CsvReportBuilder {
     private final String sep;
@@ -51,12 +51,12 @@ void addText(String text) {
     }
 
     void addNumber(Number num) {
-        buf.append(num.toString());
+        buf.append(num == null ? "" : num.toString());
         sep();
     }
 
     void addBoolean(Boolean b) {
-        buf.append(b.toString());
+        buf.append(b == null ? "" : b.toString());
         sep();
     }
 
diff --git a/src/ext/java/org/opentripplanner/ext/reportapi/model/TransfersReport.java b/src/ext/java/org/opentripplanner/ext/reportapi/model/TransfersReport.java
index 9dabe644984..975da258b25 100644
--- a/src/ext/java/org/opentripplanner/ext/reportapi/model/TransfersReport.java
+++ b/src/ext/java/org/opentripplanner/ext/reportapi/model/TransfersReport.java
@@ -4,22 +4,31 @@
 import static org.opentripplanner.util.time.DurationUtils.durationToStr;
 
 import java.util.List;
-import org.locationtech.jts.geom.Coordinate;
 import org.opentripplanner.common.geometry.SphericalDistanceLibrary;
-import org.opentripplanner.model.Stop;
+import org.opentripplanner.model.Station;
+import org.opentripplanner.model.StopLocation;
+import org.opentripplanner.model.Trip;
+import org.opentripplanner.model.TripPattern;
+import org.opentripplanner.model.WgsCoordinate;
 import org.opentripplanner.model.transfer.ConstrainedTransfer;
+import org.opentripplanner.model.transfer.RouteStationTransferPoint;
+import org.opentripplanner.model.transfer.RouteStopTransferPoint;
+import org.opentripplanner.model.transfer.StationTransferPoint;
 import org.opentripplanner.model.transfer.StopTransferPoint;
 import org.opentripplanner.model.transfer.TransferPoint;
+import org.opentripplanner.model.transfer.TripTransferPoint;
 import org.opentripplanner.routing.graph.GraphIndex;
 
 
 /**
  * This class is used to export transfers for human verification to a CSV file. This is useful
  * when trying to debug the rather complicated NeTEx data format or to get the GTFS transfers in a
- * more human readable form. It can also be used to test transfer functionality, since it is easy
+ * more human-readable form. It can also be used to test transfer functionality, since it is easy
  * to read and find special test-cases when needed.
  */
 public class TransfersReport {
+    private static final boolean BOARD = true;
+    private static final boolean ALIGHT = false;
     private static final int NOT_SET = -1;
 
 
@@ -41,32 +50,42 @@ public static String export(List<ConstrainedTransfer> transfers, GraphIndex inde
 
     String export() {
         buf.addHeader(
-                "Id", "Operator", "FromTripId", "FromTrip", "FromStop",
-                "ToTripId", "ToTrip", "ToStop", "ArrivalTime", "DepartureTime", "TransferTime",
-                "Walk", "Priority", "MaxWaitTime", "StaySeated", "Guaranteed"
+                "Id", "Operator", "From", "FromId", "FromRoute", "FromTrip", "FromStop",
+                "FromSpecificity", "To", "ToId", "ToRoute", "ToTrip", "ToStop", "ToSpecificity",
+                "ArrivalTime", "DepartureTime", "TransferTime", "Walk", "Priority", "MaxWaitTime",
+                "StaySeated", "Guaranteed"
         );
 
         transfers.forEach(t -> {
-            var from = pointInfo(t.getFrom(), true);
-            var to = pointInfo(t.getTo(), false);
-            var dist = (from.c == null || to.c == null)
+            var from = pointInfo(t.getFrom(), ALIGHT);
+            var to = pointInfo(t.getTo(), BOARD);
+            var dist = (from.coordinate == null || to.coordinate == null)
                     ? ""
                     : String.format(
                             "%.0fm",
-                            SphericalDistanceLibrary.fastDistance(from.c, to.c)
+                            SphericalDistanceLibrary.fastDistance(
+                                    from.coordinate.asJtsCoordinate(),
+                                    to.coordinate.asJtsCoordinate()
+                            )
                     );
             var duration = (from.time == NOT_SET || to.time == NOT_SET)
                     ? "" : durationToStr(to.time - from.time);
             var c = t.getTransferConstraint();
 
             buf.addText(t.getId() == null ? "" : t.getId().getId());
-            buf.addText(t.getFrom().getTrip().getOperator().getId().getId());
-            buf.addText(from.tripId);
+            buf.addText((from.operator.isEmpty() ? to : from).operator);
+            buf.addText(from.type);
+            buf.addText(from.entityId);
+            buf.addText(from.route);
             buf.addText(from.trip);
-            buf.addText(from.loc);
-            buf.addText(to.tripId);
+            buf.addText(from.location());
+            buf.addNumber(from.specificity);
+            buf.addText(to.type);
+            buf.addText(to.entityId);
+            buf.addText(to.route);
             buf.addText(to.trip);
-            buf.addText(to.loc);
+            buf.addText(to.location());
+            buf.addNumber(to.specificity);
             buf.addTime(from.time, NOT_SET);
             buf.addTime(to.time, NOT_SET);
             buf.addText(duration);
@@ -80,47 +99,106 @@ String export() {
         return buf.toString();
     }
 
-    private TxPoint pointInfo(
-            TransferPoint p,
-            boolean arrival
-    ) {
+    private TxPoint pointInfo(TransferPoint p, boolean boarding) {
         var r = new TxPoint();
-        if (p instanceof StopTransferPoint) {
-            r.loc = p.getStop().getName();
-            return r;
-        }
-        var ptn = index.getPatternForTrip().get(p.getTrip());
-        var trip = p.getTrip();
-        var route = trip.getRoute();
-
-        r.tripId = trip.getId().getId();
-        r.trip = route.getName() + " " + route.getMode() + " " + route.getLongName()
-                + " " + trip.getTripHeadsign();
-        r.c = null;
 
+        if(p instanceof TripTransferPoint) {
+            var tp = (TripTransferPoint)p;
+            var trip = tp.getTrip();
+            var route = trip.getRoute();
+            var ptn = index.getPatternForTrip().get(trip);
+            r.operator = trip.getOperator().getId().getId();
+            r.type = "Trip";
+            r.entityId = trip.getId().getId();
+            r.route = route.getName() + " " + route.getMode() + " " + route.getLongName();
+            r.trip = trip.getTripHeadsign();
+            var stop = ptn.getStop(tp.getStopPositionInPattern());
+            addLocation(r, ptn, stop, trip, boarding);
+        }
+        else if(p instanceof RouteStopTransferPoint) {
+            var rp = (RouteStopTransferPoint)p;
+            var route = rp.getRoute();
+            var ptn = index.getPatternsForRoute().get(route).stream().findFirst().orElse(null);
+            r.operator = route.getOperator().getId().getId();
+            r.type = "Route";
+            r.entityId = route.getId().getId();
+            r.route = route.getName() + " " + route.getMode() + " " + route.getLongName();
+            addLocation(r, ptn, rp.getStop(), null, boarding);
+        }
+        else if(p instanceof RouteStationTransferPoint) {
+            var rp = (RouteStationTransferPoint)p;
+            var route = rp.getRoute();
+            r.operator = route.getOperator().getId().getId();
+            r.type = "Route";
+            r.entityId = route.getId().getId();
+            r.route = route.getName() + " " + route.getMode() + " " + route.getLongName();
+            r.loc += rp.getStation().getName();
+            r.coordinate = rp.getStation().getCoordinate();
+        }
+        else if(p instanceof StopTransferPoint) {
+            var sp = (StopTransferPoint)p;
+            StopLocation stop = sp.getStop();
+            r.type = "Stop";
+            r.entityId = stop.getId().getId();
+            r.loc = stop.getName();
+            r.coordinate = stop.getCoordinate();
+        }
+        else if(p instanceof StationTransferPoint) {
+            var sp = (StationTransferPoint)p;
+            Station station = sp.getStation();
+            r.type = "Station";
+            r.entityId = station.getId().getId();
+            r.loc = station.getName();
+            r.coordinate = station.getCoordinate();
+        }
 
+        r.specificity = p.getSpecificityRanking();
+        r.coordinate = null;
+        return r;
+    }
 
+    private static void addLocation(
+            TxPoint r,
+            TripPattern pattern,
+            StopLocation stop,
+            Trip trip,
+            boolean boarding
+    ) {
+        if(pattern == null) {
+            r.loc += stop.getName() + " [Pattern no found]";
+            return;
+        }
+        int stopPosition = pattern.findStopPosition(stop);
+        r.coordinate = stop.getCoordinate();
 
-        if (ptn.getStops().size() > p.getStopPosition()) {
-            int pos = p.getStopPosition();
-            var stop = ptn.getStops().get(pos);
-            var tt = ptn.getScheduledTimetable().getTripTimes(trip);
-            r.loc += stop.getName() + " [" + pos + "]" +  " " + stop.getCoordinate();
-            r.time = arrival ? tt.getScheduledArrivalTime(pos) : tt.getScheduledDepartureTime(pos);
-            r.c = stop.getCoordinate().asJtsCoordinate();
+        if(stopPosition<0) {
+            r.loc += "[Stop not found in pattern: " + stop.getName() + "]";
+            return;
         }
-        else {
-            r.loc += "[Stop index not found: " + p.getStopPosition() + "]";
+        r.loc += stop.getName() + " [" + stopPosition + "]";
+
+        if(trip != null) {
+            var tt = pattern.getScheduledTimetable().getTripTimes(trip);
+            r.time = boarding
+                    ? tt.getScheduledDepartureTime(stopPosition)
+                    : tt.getScheduledArrivalTime(stopPosition)
+            ;
         }
-        r.loc += " " + p.getSpecificityRanking();
-        return r;
     }
 
     static class TxPoint {
+        private String operator = "";
+        private String type = "";
+        private String entityId = "";
         private String loc = "";
-        private String tripId = "";
         private String trip = "";
-        private Coordinate c = null;
+        private String route = "";
+        private Integer specificity = null;
+        private WgsCoordinate coordinate = null;
         private int time = NOT_SET;
+
+        String location() {
+            return coordinate == null ? loc : loc + " " + coordinate;
+        }
     }
 }
diff --git a/src/ext/java/org/opentripplanner/ext/siri/SiriFuzzyTripMatcher.java b/src/ext/java/org/opentripplanner/ext/siri/SiriFuzzyTripMatcher.java
index 4f48f61dac5..e4980ef728c 100644
--- a/src/ext/java/org/opentripplanner/ext/siri/SiriFuzzyTripMatcher.java
+++ b/src/ext/java/org/opentripplanner/ext/siri/SiriFuzzyTripMatcher.java
@@ -1,12 +1,12 @@
 package org.opentripplanner.ext.siri;
 
 import java.time.ZonedDateTime;
-import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.ArrayList;
 import org.opentripplanner.model.FeedScopedId;
 import org.opentripplanner.model.Route;
 import org.opentripplanner.model.Station;
@@ -234,7 +234,7 @@ private static void initCache(RoutingService index) {
                         }
                     }
                 }
-                String lastStopId = tripPattern.getStops().get(tripPattern.getStops().size()-1).getId().getId();
+                String lastStopId = tripPattern.lastStop().getId().getId();
 
                 TripTimes tripTimes = tripPattern.getScheduledTimetable().getTripTimes(trip);
                 if (tripTimes != null) {
@@ -385,7 +385,7 @@ public int getTripArrivalTime(FeedScopedId tripId) {
      * Returns a match of tripIds that match the provided values.
      */
     public List<FeedScopedId> getTripIdForInternalPlanningCodeServiceDateAndMode(
-            String internalPlanningCode, 
+            String internalPlanningCode,
             ServiceDate serviceDate,
             TransitMode mode,
             String transportSubmode
diff --git a/src/ext/java/org/opentripplanner/ext/siri/SiriTimetableSnapshotSource.java b/src/ext/java/org/opentripplanner/ext/siri/SiriTimetableSnapshotSource.java
index ed8908fb732..60ab16b4be7 100644
--- a/src/ext/java/org/opentripplanner/ext/siri/SiriTimetableSnapshotSource.java
+++ b/src/ext/java/org/opentripplanner/ext/siri/SiriTimetableSnapshotSource.java
@@ -1,6 +1,21 @@
 package org.opentripplanner.ext.siri;
 
+import static org.opentripplanner.ext.siri.TimetableHelper.createModifiedStopTimes;
+import static org.opentripplanner.ext.siri.TimetableHelper.createModifiedStops;
+import static org.opentripplanner.ext.siri.TimetableHelper.createUpdatedTripTimes;
+import static org.opentripplanner.model.PickDrop.NONE;
+import static org.opentripplanner.model.PickDrop.SCHEDULED;
+
 import com.google.common.base.Preconditions;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.BitSet;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.TimeZone;
+import java.util.concurrent.locks.ReentrantLock;
 import org.opentripplanner.common.model.T2;
 import org.opentripplanner.model.Agency;
 import org.opentripplanner.model.FeedScopedId;
@@ -787,7 +802,7 @@ private boolean handleModifiedTrip(Graph graph, String feedId, EstimatedVehicleJ
         for (TripTimes tripTimes : times) {
             Trip trip = tripTimes.getTrip();
             for (TripPattern pattern : patterns) {
-                if (tripTimes.getNumStops() == pattern.getStopPattern().getStops().length) {
+                if (tripTimes.getNumStops() == pattern.numberOfStops()) {
                     if (!tripTimes.isCanceled()) {
                         /*
                           UPDATED and MODIFIED tripTimes should be handled the same way to always allow latest realtime-update
@@ -1015,7 +1030,7 @@ private Set<TripPattern> getPatternsForTrip(Set<Trip> matches, VehicleActivitySt
             }
 
             var firstStop = tripPattern.getStop(0);
-            var lastStop = tripPattern.getStop(tripPattern.getStops().size() - 1);
+            var lastStop = tripPattern.lastStop();
 
             String siriOriginRef = monitoredVehicleJourney.getOriginRef().getValue();
 
@@ -1130,8 +1145,8 @@ private TripPattern getPatternForTrip(Trip trip, EstimatedVehicleJourney journey
         }
 
 
-        var firstStop = tripPattern.getStop(0);
-        var lastStop = tripPattern.getStop(tripPattern.getStops().size() - 1);
+        var firstStop = tripPattern.firstStop();
+        var lastStop = tripPattern.lastStop();
 
         if (serviceDates.contains(journeyDate)) {
             boolean firstStopIsMatch = firstStop.getId().getId().equals(journeyFirstStopId);
@@ -1256,9 +1271,9 @@ private Set<Trip> getTripForJourney(Set<Trip> trips, EstimatedVehicleJourney jou
 
                 TripPattern pattern = routingService.getPatternForTrip().get(trip);
 
-                if (stopNumber < pattern.getStopPattern().getStops().length) {
+                if (stopNumber < pattern.numberOfStops()) {
                     boolean firstReportedStopIsFound = false;
-                    var stop = pattern.getStopPattern().getStops()[stopNumber - 1];
+                    var stop = pattern.getStop(stopNumber - 1);
                     if (firstStopId.equals(stop.getId().getId())) {
                         firstReportedStopIsFound = true;
                     }
diff --git a/src/ext/java/org/opentripplanner/ext/siri/SiriTripPatternCache.java b/src/ext/java/org/opentripplanner/ext/siri/SiriTripPatternCache.java
index 3fab23b0578..d6b1f058535 100644
--- a/src/ext/java/org/opentripplanner/ext/siri/SiriTripPatternCache.java
+++ b/src/ext/java/org/opentripplanner/ext/siri/SiriTripPatternCache.java
@@ -3,6 +3,11 @@
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Multimaps;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.validation.constraints.NotNull;
 import org.opentripplanner.model.Stop;
 import org.opentripplanner.model.StopLocation;
 import org.opentripplanner.model.StopPattern;
@@ -13,12 +18,6 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import javax.validation.constraints.NotNull;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
 /**
  * A synchronized cache of trip patterns that are added to the graph due to GTFS-realtime messages.
  */
@@ -129,9 +128,10 @@ public synchronized TripPattern getOrCreateTripPattern(
              * Remove previously added TripPatterns for the trip currently being updated - if the stopPattern does not match
              */
             TripPattern cachedTripPattern = updatedTripPatternsForTripCache.get(tripServiceDateKey);
-            if (cachedTripPattern != null && !tripPattern
-                .getStopPattern()
-                .equals(cachedTripPattern.getStopPattern())) {
+            if (
+                    cachedTripPattern != null &&
+                    !tripPattern.stopPatternIsEqual(cachedTripPattern)
+            ) {
                 int sizeBefore = patternsForStop.values().size();
                 long t1 = System.currentTimeMillis();
                 patternsForStop.values().removeAll(Arrays.asList(cachedTripPattern));
diff --git a/src/ext/java/org/opentripplanner/ext/siri/TimetableHelper.java b/src/ext/java/org/opentripplanner/ext/siri/TimetableHelper.java
index ab72dd0be9a..5703ddf8290 100644
--- a/src/ext/java/org/opentripplanner/ext/siri/TimetableHelper.java
+++ b/src/ext/java/org/opentripplanner/ext/siri/TimetableHelper.java
@@ -1,15 +1,27 @@
 package org.opentripplanner.ext.siri;
 
+import static java.util.Collections.EMPTY_LIST;
+import static org.opentripplanner.model.PickDrop.NONE;
+import static org.opentripplanner.model.PickDrop.SCHEDULED;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.TimeZone;
+import javax.xml.datatype.Duration;
+import lombok.val;
 import org.opentripplanner.model.FeedScopedId;
-import org.opentripplanner.model.Stop;
 import org.opentripplanner.model.StopLocation;
 import org.opentripplanner.model.StopTime;
 import org.opentripplanner.model.Timetable;
 import org.opentripplanner.model.TimetableSnapshot;
 import org.opentripplanner.model.Trip;
+import org.opentripplanner.routing.RoutingService;
 import org.opentripplanner.routing.algorithm.raptor.transit.mappers.DateMapper;
 import org.opentripplanner.routing.graph.Graph;
-import org.opentripplanner.routing.RoutingService;
 import org.opentripplanner.routing.trippattern.RealTimeState;
 import org.opentripplanner.routing.trippattern.TripTimes;
 import org.slf4j.Logger;
@@ -25,19 +37,6 @@
 import uk.org.siri.siri20.RecordedCall;
 import uk.org.siri.siri20.VehicleActivityStructure;
 
-import javax.xml.datatype.Duration;
-import java.time.ZoneId;
-import java.time.ZonedDateTime;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import java.util.TimeZone;
-
-import static java.util.Collections.EMPTY_LIST;
-import static org.opentripplanner.model.PickDrop.NONE;
-import static org.opentripplanner.model.PickDrop.SCHEDULED;
-
 public class TimetableHelper {
 
     private static final Logger LOG = LoggerFactory.getLogger(TimetableHelper.class);
@@ -95,8 +94,6 @@ public static TripTimes createUpdatedTripTimes(final Graph graph, Timetable time
 
         boolean stopPatternChanged = false;
 
-        var modifiedStops = timetable.getPattern().getStopPattern().getStops();
-
         Trip trip = getTrip(tripId, timetable);
 
         List<StopTime> modifiedStopTimes = createModifiedStopTimes(timetable, oldTimes, journey, trip, new RoutingService(graph));
@@ -119,7 +116,7 @@ public static TripTimes createUpdatedTripTimes(final Graph graph, Timetable time
         int departureFromPreviousStop = 0;
         int lastArrivalDelay = 0;
         int lastDepartureDelay = 0;
-        for (var stop : modifiedStops) {
+        for (var stop : timetable.getPattern().getStops()) {
             boolean foundMatch = false;
 
             for (RecordedCall recordedCall : recordedCalls) {
@@ -286,8 +283,7 @@ public static TripTimes createUpdatedTripTimes(final Graph graph, Timetable time
             }
             if (!foundMatch) {
 
-                if (timetable.getPattern().getStopPattern().getPickup(callCounter) == NONE &&
-                        timetable.getPattern().getStopPattern().getDropoff(callCounter) == NONE) {
+                if (timetable.getPattern().isBoardAndAlightAt(callCounter, NONE)) {
                     // When newTimes contains stops without pickup/dropoff - set both arrival/departure to previous stop's departure
                     // This necessary to accommodate the case when delay is reduced/eliminated between to stops with pickup/dropoff, and
                     // multiple non-pickup/dropoff stops are in between.
@@ -331,7 +327,7 @@ public static TripTimes createUpdatedTripTimes(final Graph graph, Timetable time
             return null;
         }
 
-        if (newTimes.getNumStops() != timetable.getPattern().getStopPattern().getStops().length) {
+        if (newTimes.getNumStops() != timetable.getPattern().numberOfStops()) {
             return null;
         }
 
@@ -372,15 +368,15 @@ public static List<StopLocation> createModifiedStops(Timetable timetable, Estima
         }
 
         //Get all scheduled stops
-        var stops = timetable.getPattern().getStopPattern().getStops();
+        val pattern = timetable.getPattern();
 
         // Keeping track of visited stop-objects to allow multiple visits to a stop.
         List<Object> alreadyVisited = new ArrayList<>();
 
         List<StopLocation> modifiedStops = new ArrayList<>();
 
-        for (int i = 0; i < stops.length; i++) {
-            StopLocation stop = stops[i];
+        for (int i = 0; i < pattern.numberOfStops(); i++) {
+            StopLocation stop = pattern.getStop(i);
 
             boolean foundMatch = false;
             if (i < recordedCalls.size()) {
@@ -481,8 +477,8 @@ public static List<StopTime> createModifiedStopTimes(Timetable timetable, TripTi
             stopTime.setStop(stop);
             stopTime.setTrip(trip);
             stopTime.setStopSequence(i);
-            stopTime.setDropOffType(timetable.getPattern().getStopPattern().getDropoff(i));
-            stopTime.setPickupType(timetable.getPattern().getStopPattern().getPickup(i));
+            stopTime.setDropOffType(timetable.getPattern().getAlightType(i));
+            stopTime.setPickupType(timetable.getPattern().getBoardType(i));
             stopTime.setArrivalTime(oldTimes.getScheduledArrivalTime(i));
             stopTime.setDepartureTime(oldTimes.getScheduledDepartureTime(i));
             stopTime.setStopHeadsign(oldTimes.getHeadsign(i));
@@ -597,7 +593,6 @@ public static TripTimes createUpdatedTripTimes(Timetable timetable, Graph graph,
         if (update == null) {
             return null;
         }
-        final List<StopLocation> stops = timetable.getPattern().getStops();
 
         VehicleActivityStructure.MonitoredVehicleJourney monitoredVehicleJourney = activity.getMonitoredVehicleJourney();
 
@@ -615,11 +610,12 @@ public static TripTimes createUpdatedTripTimes(Timetable timetable, Graph graph,
 
                 int arrivalDelay = 0;
                 int departureDelay = 0;
+                val pattern = timetable.getPattern();
 
                 for (int index = 0; index < newTimes.getNumStops(); ++index) {
                     if (!matchFound) {
                         // Delay is set on a single stop at a time. When match is found - propagate delay on all following stops
-                        final var stop = stops.get(index);
+                        final var stop =  pattern.getStop(index);
 
                         matchFound = stop.getId().getId().equals(monitoredCall.getStopPointRef().getValue());
 
diff --git a/src/ext/java/org/opentripplanner/ext/transmodelapi/model/timetable/InterchangeType.java b/src/ext/java/org/opentripplanner/ext/transmodelapi/model/timetable/InterchangeType.java
index 506186272f8..563ffb95c20 100644
--- a/src/ext/java/org/opentripplanner/ext/transmodelapi/model/timetable/InterchangeType.java
+++ b/src/ext/java/org/opentripplanner/ext/transmodelapi/model/timetable/InterchangeType.java
@@ -5,9 +5,13 @@
 import graphql.schema.GraphQLFieldDefinition;
 import graphql.schema.GraphQLObjectType;
 import graphql.schema.GraphQLOutputType;
+import java.util.function.Function;
 import org.opentripplanner.ext.transmodelapi.model.EnumTypes;
+import org.opentripplanner.model.Route;
+import org.opentripplanner.model.Trip;
 import org.opentripplanner.model.transfer.ConstrainedTransfer;
 import org.opentripplanner.model.transfer.TransferConstraint;
+import org.opentripplanner.model.transfer.TransferPoint;
 
 public class InterchangeType {
 
@@ -52,36 +56,36 @@ public static GraphQLObjectType create(
                         .deprecate(
                                 "This is the same as using the `fromServiceJourney { line }` field.")
                         .type(lineType)
-                        .dataFetcher(env -> transfer(env).getFrom().getTrip().getRoute())
+                        .dataFetcher(env -> transferRoute(env, ConstrainedTransfer::getFrom))
                         .build())
                 .field(GraphQLFieldDefinition.newFieldDefinition()
                         .name("ToLine")
                         .deprecate(
                                 "This is the same as using the `toServiceJourney { line }` field.")
                         .type(lineType)
-                        .dataFetcher(env -> transfer(env).getTo().getTrip().getRoute())
+                        .dataFetcher(env -> transferRoute(env, ConstrainedTransfer::getTo))
                         .build())
                 .field(GraphQLFieldDefinition.newFieldDefinition()
-                        .name("FromServiceJourney")
+                        .name("fromServiceJourney")
                         .type(serviceJourneyType)
-                        .deprecate("Use fromServiceJourney instead")
-                        .dataFetcher(env -> transfer(env).getFrom().getTrip())
+                        .dataFetcher(env -> transferTrip(env, ConstrainedTransfer::getFrom))
                         .build())
                 .field(GraphQLFieldDefinition.newFieldDefinition()
-                        .name("ToServiceJourney")
+                        .name("toServiceJourney")
                         .type(serviceJourneyType)
-                        .deprecate("Use toServiceJourney instead")
-                        .dataFetcher(env -> transfer(env).getTo().getTrip())
+                        .dataFetcher(env -> transferTrip(env, ConstrainedTransfer::getTo))
                         .build())
                 .field(GraphQLFieldDefinition.newFieldDefinition()
-                        .name("fromServiceJourney")
+                        .name("FromServiceJourney")
                         .type(serviceJourneyType)
-                        .dataFetcher(env -> transfer(env).getFrom().getTrip())
+                        .deprecate("Use fromServiceJourney instead")
+                        .dataFetcher(env -> transferTrip(env, ConstrainedTransfer::getFrom))
                         .build())
                 .field(GraphQLFieldDefinition.newFieldDefinition()
-                        .name("toServiceJourney")
+                        .name("ToServiceJourney")
                         .type(serviceJourneyType)
-                        .dataFetcher(env -> transfer(env).getTo().getTrip())
+                        .deprecate("Use toServiceJourney instead")
+                        .dataFetcher(env -> transferTrip(env, ConstrainedTransfer::getTo))
                         .build())
                 .build();
     }
@@ -90,6 +94,27 @@ private static ConstrainedTransfer transfer(DataFetchingEnvironment environment)
         return environment.getSource();
     }
 
+    private static TransferPoint transferPoint(
+            DataFetchingEnvironment environment,
+            Function<ConstrainedTransfer, TransferPoint> fromTo
+    ) {
+        return fromTo.apply(transfer(environment));
+    }
+
+    private static Trip transferTrip(
+            DataFetchingEnvironment environment,
+            Function<ConstrainedTransfer, TransferPoint> fromTo
+    ) {
+        return TransferPoint.getTrip(transferPoint(environment, fromTo));
+    }
+
+    private static Route transferRoute(
+            DataFetchingEnvironment environment,
+            Function<ConstrainedTransfer, TransferPoint> fromTo
+    ) {
+        return TransferPoint.getRoute(transferPoint(environment, fromTo));
+    }
+
     private static TransferConstraint constraint(DataFetchingEnvironment environment) {
         return transfer(environment).getTransferConstraint();
     }
diff --git a/src/ext/java/org/opentripplanner/ext/vectortiles/layers/stops/DigitransitStopPropertyMapper.java b/src/ext/java/org/opentripplanner/ext/vectortiles/layers/stops/DigitransitStopPropertyMapper.java
index 07c81f4e379..5fb77cd7f7c 100644
--- a/src/ext/java/org/opentripplanner/ext/vectortiles/layers/stops/DigitransitStopPropertyMapper.java
+++ b/src/ext/java/org/opentripplanner/ext/vectortiles/layers/stops/DigitransitStopPropertyMapper.java
@@ -1,5 +1,10 @@
 package org.opentripplanner.ext.vectortiles.layers.stops;
 
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
 import org.json.simple.JSONArray;
 import org.json.simple.JSONObject;
 import org.opentripplanner.common.model.T2;
@@ -9,12 +14,6 @@
 import org.opentripplanner.routing.graph.Graph;
 import org.opentripplanner.routing.vertextype.TransitStopVertex;
 
-import java.util.Collection;
-import java.util.List;
-import java.util.Map;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-
 public class DigitransitStopPropertyMapper extends PropertyMapper<TransitStopVertex> {
   private final Graph graph;
 
@@ -41,11 +40,14 @@ public Collection<T2<String, Object>> map(TransitStopVertex input) {
         .orElse(null);
 
     String patterns = JSONArray.toJSONString(patternsForStop.stream().map(tripPattern -> {
-      JSONObject pattern = new JSONObject();
-      pattern.put("headsign", tripPattern.getScheduledTimetable().getTripTimes().get(0).getHeadsign(tripPattern.getStopIndex(stop)));
-      pattern.put("type", tripPattern.getRoute().getMode().name());
-      pattern.put("shortName", tripPattern.getRoute().getShortName());
-      return pattern;
+      int stopPos = tripPattern.findStopPosition(stop);
+      var headsign  = stopPos < 0 ? "Not Available" :
+              tripPattern.getScheduledTimetable().getTripTimes().get(0).getHeadsign(stopPos);
+      return new JSONObject(Map.of(
+              "headsign", headsign,
+              "type", tripPattern.getRoute().getMode().name(),
+              "shortName", tripPattern.getRoute().getShortName()
+      ));
     }).collect(Collectors.toList()));
 
     return List.of(
diff --git a/src/main/java/org/opentripplanner/common/model/T2.java b/src/main/java/org/opentripplanner/common/model/T2.java
index 8a73ea3f30f..3b4744c6d32 100644
--- a/src/main/java/org/opentripplanner/common/model/T2.java
+++ b/src/main/java/org/opentripplanner/common/model/T2.java
@@ -1,6 +1,7 @@
 package org.opentripplanner.common.model;
 
 import java.io.Serializable;
+import java.util.Objects;
 
 /**
  * An ordered pair of objects of potentially different types
@@ -25,22 +26,8 @@ public int hashCode() {
     @Override
     public boolean equals(Object object) {
         if (!(object instanceof T2)) { return false; }
-
-        var other = (T2) object;
-
-        if (first == null) {
-            if (other.first != null) { return false; }
-        } else {
-            if (!first.equals(other.first)) { return false; }
-        }
-
-        if (second == null) {
-            if (other.second != null) { return false; }
-        } else {
-            if (!second.equals(other.second)) { return false; }
-        }
-
-        return true;
+        var other = (T2<?,?>) object;
+        return Objects.equals(first, other.first) && Objects.equals(second, other.second);
     }
 
     @Override
diff --git a/src/main/java/org/opentripplanner/graph_builder/module/geometry/GeometryAndBlockProcessor.java b/src/main/java/org/opentripplanner/graph_builder/module/geometry/GeometryAndBlockProcessor.java
index f3bac8976d6..a3622ad732b 100644
--- a/src/main/java/org/opentripplanner/graph_builder/module/geometry/GeometryAndBlockProcessor.java
+++ b/src/main/java/org/opentripplanner/graph_builder/module/geometry/GeometryAndBlockProcessor.java
@@ -238,8 +238,8 @@ private void interline(Collection<TripPattern> tripPatterns, Graph graph) {
                     }
                     TripPattern prevPattern = patternForTripTimes.get(prev);
                     TripPattern currPattern = patternForTripTimes.get(curr);
-                    var fromStop = prevPattern.getStop(prevPattern.getStops().size() - 1);
-                    var toStop = currPattern.getStop(0);
+                    var fromStop = prevPattern.lastStop();
+                    var toStop = currPattern.firstStop();
                     double teleportationDistance = SphericalDistanceLibrary.fastDistance(
                             fromStop.getLat(),
                             fromStop.getLon(),
diff --git a/src/main/java/org/opentripplanner/gtfs/mapping/TransferMapper.java b/src/main/java/org/opentripplanner/gtfs/mapping/TransferMapper.java
index 2b6582fa3a7..fee50cf1f0e 100644
--- a/src/main/java/org/opentripplanner/gtfs/mapping/TransferMapper.java
+++ b/src/main/java/org/opentripplanner/gtfs/mapping/TransferMapper.java
@@ -1,26 +1,30 @@
 package org.opentripplanner.gtfs.mapping;
 
-import java.util.ArrayList;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Multimap;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.List;
-import java.util.Map;
-import java.util.function.BiFunction;
+import java.util.Objects;
+import java.util.function.Predicate;
 import java.util.stream.Collectors;
-import javax.annotation.Nullable;
 import org.onebusaway.gtfs.model.Transfer;
 import org.opentripplanner.model.Route;
+import org.opentripplanner.model.Station;
 import org.opentripplanner.model.Stop;
 import org.opentripplanner.model.StopLocation;
 import org.opentripplanner.model.StopTime;
 import org.opentripplanner.model.Trip;
 import org.opentripplanner.model.TripStopTimes;
 import org.opentripplanner.model.transfer.ConstrainedTransfer;
+import org.opentripplanner.model.transfer.RouteStationTransferPoint;
+import org.opentripplanner.model.transfer.RouteStopTransferPoint;
+import org.opentripplanner.model.transfer.StationTransferPoint;
 import org.opentripplanner.model.transfer.StopTransferPoint;
 import org.opentripplanner.model.transfer.TransferConstraint;
 import org.opentripplanner.model.transfer.TransferPoint;
 import org.opentripplanner.model.transfer.TransferPriority;
 import org.opentripplanner.model.transfer.TripTransferPoint;
+import org.opentripplanner.util.logging.ThrottleLogger;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -33,6 +37,7 @@
 class TransferMapper {
 
   private static final Logger LOG = LoggerFactory.getLogger(TransferMapper.class);
+  private static final Logger FIXED_ROUTE_ERROR = ThrottleLogger.throttle(LOG);
 
   /**
    * This transfer is recommended over other transfers. The routing algorithm should prefer this
@@ -70,6 +75,9 @@ class TransferMapper {
 
   private final TripStopTimes stopTimesByTrip;
 
+  private final Multimap<Route, Trip> tripsByRoute = ArrayListMultimap.create();
+
+
   TransferMapper(
       RouteMapper routeMapper,
       StationMapper stationMapper,
@@ -98,69 +106,45 @@ static TransferPriority mapTypeToPriority(int type) {
   }
 
   Collection<ConstrainedTransfer> map(Collection<org.onebusaway.gtfs.model.Transfer> allTransfers) {
-    List<ConstrainedTransfer> result = new ArrayList<>();
+    setup(!allTransfers.isEmpty());
 
-    for (org.onebusaway.gtfs.model.Transfer it : allTransfers) {
-      result.addAll(map(it));
-    }
-    return result;
+    return allTransfers.stream().map(this::map)
+            .filter(Objects::nonNull)
+            .collect(Collectors.toList());
   }
 
-  /**
-   * Map from GTFS to OTP model, {@code null} safe.
-   */
-  Collection<ConstrainedTransfer> map(org.onebusaway.gtfs.model.Transfer original) {
-    return original == null ? List.of() : doMap(original);
-  }
-
-  private Collection<ConstrainedTransfer> doMap(org.onebusaway.gtfs.model.Transfer rhs) {
-
+  ConstrainedTransfer map(org.onebusaway.gtfs.model.Transfer rhs) {
     Trip fromTrip = tripMapper.map(rhs.getFromTrip());
     Trip toTrip = tripMapper.map(rhs.getToTrip());
-    Route fromRoute = routeMapper.map(rhs.getFromRoute());
-    Route toRoute = routeMapper.map(rhs.getToRoute());
+
     TransferConstraint constraint = mapConstraint(rhs, fromTrip, toTrip);
 
     // TODO TGR - Create a transfer for this se issue #3369
     int transferTime = rhs.getMinTransferTime();
 
     // If this transfer do not give any advantages in the routing, then drop it
-    if(constraint.noConstraints()) {
+    if(constraint.isRegularTransfer()) {
       if(transferTime > 0) {
         LOG.info("Transfer skipped, issue #3369: " + rhs);
       }
       else {
         LOG.warn("Transfer skipped - no effect on routing: " + rhs);
       }
-      return List.of();
+      return null;
     }
 
-    // Transfers may be specified using parent stations
-    // (https://developers.google.com/transit/gtfs/reference/transfers-file)
-    // "If the stop ID refers to a station that contains multiple stops, this transfer rule
-    // applies to all stops in that station." we thus expand transfers that use parent stations
-    // to all the member stops.
-
-    var fromStops = getStopOrChildStops(rhs.getFromStop());
-    var toStops = getStopOrChildStops(rhs.getToStop());
-
-    Collection<TransferPoint> fromPoints = mapTransferPoints(fromStops, fromTrip, fromRoute);
-    Collection<TransferPoint> toPoints = mapTransferPoints(toStops, toTrip, toRoute);
-
-    Collection<ConstrainedTransfer> result = new ArrayList<>();
-
-    for (TransferPoint fromPoint : fromPoints) {
-      for (TransferPoint toPoint : toPoints) {
-        var transfer = new ConstrainedTransfer(
-                null,
-                fromPoint,
-                toPoint,
-                constraint
-        );
-        result.add(transfer);
-      }
+    TransferPoint fromPoint = mapTransferPoint(rhs.getFromStop(), rhs.getFromRoute(), fromTrip, false);
+    TransferPoint toPoint = mapTransferPoint(rhs.getToStop(), rhs.getToRoute(), toTrip, true);
+
+    return new ConstrainedTransfer(null, fromPoint, toPoint, constraint);
+  }
+
+  private void setup(boolean run) {
+    if(!run) { return; }
+
+    for (Trip trip : tripMapper.getMappedTrips()) {
+      tripsByRoute.put(trip.getRoute(), trip);
     }
-    return result;
   }
 
   private TransferConstraint mapConstraint(Transfer rhs, Trip fromTrip, Trip toTrip) {
@@ -173,64 +157,71 @@ private TransferConstraint mapConstraint(Transfer rhs, Trip fromTrip, Trip toTri
     return builder.build();
   }
 
-  private Collection<TransferPoint> mapTransferPoints(
-      Collection<StopLocation> stops,
-      Trip trip,
-      Route route
+  private TransferPoint mapTransferPoint(
+          org.onebusaway.gtfs.model.Stop rhsStopOrStation,
+          org.onebusaway.gtfs.model.Route rhsRoute,
+          Trip trip,
+          boolean boardTrip
   ) {
-    Collection<TransferPoint> result = new ArrayList<>();
-    if (trip != null) {
-      result.addAll(createTransferPointForTrip(stops, trip, TripTransferPoint::new));
-    }
-    else if (route != null) {
-      /*
-      TODO - This code result in a OutOfMemory exception, fin out why and fix it
-           - See issue https://github.com/opentripplanner/OpenTripPlanner/issues/3429
-      for (Trip tripInRoute : tripsByRoute.get(route)) {
-        result.addAll(
-            createTransferPointForTrip(
-              stops,
-              tripInRoute,
-              (t,i) -> new RouteTransferPoint(route, t, i)
-            )
-        );
-      }
-       */
+    Route route = routeMapper.map(rhsRoute);
+    Station station = null;
+    Stop stop = null;
+
+    // A transfer is specified using Stops and/or Station, according to the GTFS specification:
+    //
+    //    If the stop ID refers to a station that contains multiple stops, this transfer rule
+    //    applies to all stops in that station.
+    //
+    // Source: https://developers.google.com/transit/gtfs/reference/transfers-file
+
+    if (rhsStopOrStation.getLocationType() == 0) {
+      stop  = stopMapper.map(rhsStopOrStation);
     }
     else {
-      for (var stop : stops) {
-        result.add(new StopTransferPoint(stop));
-      }
-
+      station = stationMapper.map(rhsStopOrStation);
     }
-    return result;
+    if(trip != null) {
+      // A trip may visit the same stop twice, but we ignore that and only add the first stop
+      // we find. Pattern that start and end at the same stop is supported.
+      int stopPositionInPattern = stopPosition(trip, stop, station, boardTrip);
+      return stopPositionInPattern < 0 ? null : new TripTransferPoint(trip, stopPositionInPattern);
+    }
+    else if(route != null) {
+      if(stop != null) { return new RouteStopTransferPoint(route, stop); }
+      else if(station != null) { return new RouteStationTransferPoint(route, station); }
+    }
+    else if(stop != null) {
+      return new StopTransferPoint(stop);
+    }
+    else if(station != null) {
+      return new StationTransferPoint(station);
+    }
+
+    throw new IllegalStateException("Should not get here!");
   }
 
-  private Collection<TransferPoint> createTransferPointForTrip(
-      Collection<StopLocation> stops,
-      Trip trip,
-      BiFunction<Trip, Integer, TransferPoint> createPoint
-  ) {
-    Collection<TransferPoint> result = new ArrayList<>();
+  private int stopPosition(Trip trip, Stop stop, Station station, boolean boardTrip) {
     List<StopTime> stopTimes = stopTimesByTrip.get(trip);
-    for (int i = 0; i < stopTimes.size(); ++i) {
+
+    // We can board at the first stop, but not alight.
+    final int firstStopPos = boardTrip ? 0 : 1;
+    // We can alight at the last stop, but not board, the lastStopPos is exclusive
+    final int lastStopPos =  stopTimes.size() - (boardTrip ? 1 : 0);
+
+    Predicate<StopLocation> stopMatches = station != null
+            ? (s) -> (s instanceof Stop && ((Stop)s).getParentStation() == station)
+            : (s) -> s == stop;
+
+    for (int i = firstStopPos; i < lastStopPos; i++) {
       StopTime stopTime = stopTimes.get(i);
+      if(boardTrip && !stopTime.getPickupType().isRoutable()) { continue; }
+      if(!boardTrip && !stopTime.getDropOffType().isRoutable()) { continue; }
 
-      //noinspection SuspiciousMethodCalls
-      if (stops.contains(stopTime.getStop())) {
-        result.add(createPoint.apply(trip, i));
+      if(stopMatches.test(stopTime.getStop())) {
+        return i;
       }
     }
-    return result;
-  }
-
-  private Collection<StopLocation> getStopOrChildStops(org.onebusaway.gtfs.model.Stop gtfsStop) {
-    if (gtfsStop.getLocationType() == 0) {
-      return Collections.singletonList(stopMapper.map(gtfsStop));
-    }
-    else {
-      return stationMapper.map(gtfsStop).getChildStops();
-    }
+    return -1;
   }
 
   private boolean sameBlockId(Trip a, Trip b) {
@@ -239,16 +230,4 @@ private boolean sameBlockId(Trip a, Trip b) {
     }
     return a.getBlockId() != null && a.getBlockId().equals(b.getBlockId());
   }
-
-  @Nullable
-  private Map<Route,List<Trip>> createTripsByRouteMapIfRouteTransfersExist(
-      Collection<Trip> trips,
-      Collection<org.onebusaway.gtfs.model.Transfer> allTransfers
-  ) {
-    if(allTransfers.stream().anyMatch(t -> t.getFromRoute() != null || t.getToRoute() != null)) {
-      return trips.stream().collect(Collectors.groupingBy(Trip::getRoute));
-    }
-    // Return null, not an empty map to enforce NPE if used when no Route exist
-    return null;
-  }
 }
diff --git a/src/main/java/org/opentripplanner/gtfs/mapping/TripMapper.java b/src/main/java/org/opentripplanner/gtfs/mapping/TripMapper.java
index 5709e758fb5..fe25987874e 100644
--- a/src/main/java/org/opentripplanner/gtfs/mapping/TripMapper.java
+++ b/src/main/java/org/opentripplanner/gtfs/mapping/TripMapper.java
@@ -3,7 +3,6 @@
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
-import javax.annotation.Nullable;
 import org.opentripplanner.model.Direction;
 import org.opentripplanner.model.Trip;
 import org.opentripplanner.util.MapUtils;
@@ -31,6 +30,11 @@ Trip map(org.onebusaway.gtfs.model.Trip orginal) {
         return orginal == null ? null : mappedTrips.computeIfAbsent(orginal, this::doMap);
     }
 
+    Collection<Trip> getMappedTrips() {
+        return mappedTrips.values();
+    }
+
+
     private Trip doMap(org.onebusaway.gtfs.model.Trip rhs) {
         Trip lhs = new Trip(AgencyAndIdMapper.mapAgencyAndId(rhs.getId()));
 
@@ -49,7 +53,6 @@ private Trip doMap(org.onebusaway.gtfs.model.Trip rhs) {
         return lhs;
     }
 
-    @Nullable
     private static int mapDirectionId(org.onebusaway.gtfs.model.Trip trip) {
         try {
             String directionId = trip.getDirectionId();
diff --git a/src/main/java/org/opentripplanner/model/PickDrop.java b/src/main/java/org/opentripplanner/model/PickDrop.java
index a350a76b367..989be759743 100644
--- a/src/main/java/org/opentripplanner/model/PickDrop.java
+++ b/src/main/java/org/opentripplanner/model/PickDrop.java
@@ -16,6 +16,10 @@ public enum PickDrop {
     this.gtfsCode = gtfsCode;
   }
 
+  public boolean is(PickDrop value) {
+    return this == value;
+  }
+
   public boolean isRoutable() {
     return routable;
   }
diff --git a/src/main/java/org/opentripplanner/model/Route.java b/src/main/java/org/opentripplanner/model/Route.java
index 799f891bc84..1766db99ddb 100644
--- a/src/main/java/org/opentripplanner/model/Route.java
+++ b/src/main/java/org/opentripplanner/model/Route.java
@@ -175,7 +175,7 @@ public String getName() {
 
     @Override
     public String toString() {
-        return "<Route " + getId() + " " + shortName + ">";
+        return "<Route " + getId() + " " + getName() + ">";
     }
 
     public String getNetexSubmode() {
diff --git a/src/main/java/org/opentripplanner/model/Station.java b/src/main/java/org/opentripplanner/model/Station.java
index 29dfa02c2ed..2b070c26832 100644
--- a/src/main/java/org/opentripplanner/model/Station.java
+++ b/src/main/java/org/opentripplanner/model/Station.java
@@ -63,14 +63,37 @@ public Station(
     this.url = url;
     this.timezone = timezone;
     this.priority = priority == null ? DEFAULT_PRIORITY : priority;
-    this.geometry = computeGeometry(coordinate, childStops);
+    // Initialize the geometry with an empty set of children
+    this.geometry = computeGeometry(coordinate, Set.of());
+  }
+
+  /**
+   * Create a minimal Station object for unit-test use, where the test only care about id, name and
+   * coordinate. The feedId is static set to "F"
+   */
+  public static Station stationForTest(String idAndName, double lat, double lon) {
+    return new Station(
+            new FeedScopedId("F", idAndName),
+            idAndName,
+            new WgsCoordinate(lat, lon),
+            idAndName,
+            "Station " + idAndName,
+            null,
+            null,
+            StopTransferPriority.ALLOWED
+    );
   }
 
+
   public void addChildStop(Stop stop) {
     this.childStops.add(stop);
     this.geometry = computeGeometry(coordinate, childStops);
   }
 
+  public boolean includes(StopLocation stop) {
+    return childStops.contains(stop);
+  }
+
   @Override
   public String toString() {
     return "<Station " + getId() + ">";
diff --git a/src/main/java/org/opentripplanner/model/Stop.java b/src/main/java/org/opentripplanner/model/Stop.java
index 3dac1f7b23c..ccd53d0eba4 100644
--- a/src/main/java/org/opentripplanner/model/Stop.java
+++ b/src/main/java/org/opentripplanner/model/Stop.java
@@ -7,8 +7,6 @@
 import java.util.TimeZone;
 import javax.validation.constraints.NotNull;
 import org.locationtech.jts.geom.Geometry;
-import org.locationtech.jts.geom.GeometryCollection;
-import org.locationtech.jts.geom.Point;
 import org.opentripplanner.common.geometry.GeometryUtils;
 
 /**
@@ -68,12 +66,17 @@ public Stop(
     this.netexSubmode = netexSubmode;
   }
 
-  /**
-   * Create a minimal Stop object for unit-test use, where the test only care about id, name and
-   * coordinate. The feedId is static set to "TEST"
-   */
+  /** @see #stopForTest(String, double, double, Station) */
   public static Stop stopForTest(String idAndName, double lat, double lon) {
-    return new Stop(
+    return stopForTest(idAndName, lat, lon, null);
+  }
+
+    /**
+     * Create a minimal Stop object for unit-test use, where the test only care about id, name and
+     * coordinate. The feedId is static set to "F"
+     */
+  public static Stop stopForTest(String idAndName, double lat, double lon, Station parent) {
+    var stop = new Stop(
         new FeedScopedId("F", idAndName),
         idAndName,
         idAndName,
@@ -88,9 +91,10 @@ public static Stop stopForTest(String idAndName, double lat, double lon) {
         null,
         null
     );
+    stop.setParentStation(parent);
+    return stop;
   }
 
-
   public void addBoardingArea(BoardingArea boardingArea) {
     if (boardingAreas == null) {
       boardingAreas = new HashSet<>();
diff --git a/src/main/java/org/opentripplanner/model/StopPattern.java b/src/main/java/org/opentripplanner/model/StopPattern.java
index 47d6e2fc6cc..f8adc5732f0 100644
--- a/src/main/java/org/opentripplanner/model/StopPattern.java
+++ b/src/main/java/org/opentripplanner/model/StopPattern.java
@@ -7,6 +7,8 @@
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Iterator;
+import java.util.List;
+import java.util.function.Predicate;
 
 /**
  * This class represents what is called a JourneyPattern in Transmodel: the sequence of stops at
@@ -32,10 +34,11 @@
  * A StopPattern is very closely related to a TripPattern -- it essentially serves as the unique
  * key for a TripPattern. Should the route be included in the StopPattern?
  */
-public class StopPattern implements Serializable {
+public final class StopPattern implements Serializable {
 
     private static final long serialVersionUID = 20140101L;
-    
+    public static final int NOT_FOUND = -1;
+
     private final StopLocation[] stops;
     private final PickDrop[]  pickups;
     private final PickDrop[]  dropoffs;
@@ -63,30 +66,32 @@ public StopPattern (Collection<StopTime> stopTimes) {
         }
     }
 
-    /**
-     * Raptor should not be allowed to board or alight flex stops because they have fake
-     * coordinates (centroids) and might not have times.
-     */
-    private static PickDrop computePickDrop(StopLocation stop, PickDrop pickDrop) {
-        if(stop instanceof FlexStopLocation) {
-            return PickDrop.NONE;
-        }
-        else {
-            return pickDrop;
+    int getSize() {
+        return stops.length;
+    }
+
+    /** Find the given stop position in the sequence, return -1 if not found. */
+    int findStopPosition(StopLocation stop) {
+        for (int i=0; i<stops.length; ++i) {
+            if(stops[i] == stop) { return i; }
         }
+        return -1;
     }
 
-    /**
-     * @param stopId in agency_id format
-     */
-    public boolean containsStop (String stopId) {
-        if (stopId == null) { return false; }
-        for (StopLocation stop : stops) if (stopId.equals(stop.getId().toString())) { return true; }
-        return false;
+    int findBoardingPosition(StopLocation stop) {
+        return findStopPosition(0, stops.length-1, (s) -> s == stop, stop);
     }
 
-    public int getSize() {
-        return stops.length;
+    int findAlightPosition(StopLocation stop) {
+        return findStopPosition(1, stops.length, (s) -> s == stop, stop);
+    }
+
+    int findBoardingPosition(Station station) {
+        return findStopPosition(0, stops.length-1, station::includes, station);
+    }
+
+    int findAlightPosition(Station station) {
+        return findStopPosition(1, stops.length, station::includes, station);
     }
 
     public boolean equals(Object other) {
@@ -126,7 +131,7 @@ public String toString() {
      * want a way to consistently identify trips across versions of a GTFS feed, when the feed
      * publisher cannot ensure stable trip IDs. Therefore we define some additional hash functions.
      */
-    public HashCode semanticHash(HashFunction hashFunction) {
+    HashCode semanticHash(HashFunction hashFunction) {
         Hasher hasher = hashFunction.newHasher();
         int size = stops.length;
         for (int s = 0; s < size; s++) {
@@ -145,19 +150,63 @@ public HashCode semanticHash(HashFunction hashFunction) {
         return hasher.hash();
     }
 
-    public StopLocation[] getStops() {
-        return stops;
+    /** Get a copy of the internal collection of stops. */
+    List<StopLocation> getStops() {
+        return List.of(stops);
+    }
+
+    StopLocation getStop(int stopPosInPattern) {
+        return stops[stopPosInPattern];
+    }
+
+    PickDrop getPickup(int stopPosInPattern) {
+        return pickups[stopPosInPattern];
+    }
+
+    PickDrop getDropoff(int stopPosInPattern) {
+        return dropoffs[stopPosInPattern];
     }
 
-    public StopLocation getStop(int i) {
-        return stops[i];
+    /** Returns whether passengers can alight at a given stop */
+    boolean canAlight(int stopPosInPattern) {
+        return dropoffs[stopPosInPattern].isRoutable();
     }
 
-    public PickDrop getPickup(int i) {
-        return pickups[i];
+    /** Returns whether passengers can board at a given stop */
+    boolean canBoard(int stopPosInPattern) {
+        return pickups[stopPosInPattern].isRoutable();
     }
 
-    public PickDrop getDropoff(int i) {
-        return dropoffs[i];
+    /**
+     * Returns whether passengers can board at a given stop.
+     * This is an inefficient method iterating over the stops, do not use it in routing.
+     */
+    boolean canBoard(StopLocation stop) {
+        // We skip the last stop, not allowed for boarding
+        for (int i=0; i<stops.length-1; ++i) {
+            if(stop == stops[i] && canBoard(i)) { return true; }
+        }
+        return false;
+    }
+
+    /**
+     * Raptor should not be allowed to board or alight flex stops because they have fake
+     * coordinates (centroids) and might not have times.
+     */
+    private static PickDrop computePickDrop(StopLocation stop, PickDrop pickDrop) {
+        if(stop instanceof FlexStopLocation) { return PickDrop.NONE; }
+        else { return pickDrop; }
+    }
+
+    private int findStopPosition(
+            final int start,
+            final int end,
+            final Predicate<StopLocation> match,
+            final Object entity
+    ) {
+        for (int i=start; i<end; ++i) {
+            if(match.test(stops[i])) { return i; }
+        }
+        throw new IllegalArgumentException("Stop/Station not found: " + entity);
     }
 }
diff --git a/src/main/java/org/opentripplanner/model/Timetable.java b/src/main/java/org/opentripplanner/model/Timetable.java
index 2986e35c587..5f730e74534 100644
--- a/src/main/java/org/opentripplanner/model/Timetable.java
+++ b/src/main/java/org/opentripplanner/model/Timetable.java
@@ -5,6 +5,11 @@
 import com.google.transit.realtime.GtfsRealtime.TripUpdate;
 import com.google.transit.realtime.GtfsRealtime.TripUpdate.StopTimeEvent;
 import com.google.transit.realtime.GtfsRealtime.TripUpdate.StopTimeUpdate;
+import java.io.Serializable;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.TimeZone;
 import org.opentripplanner.model.calendar.ServiceDate;
 import org.opentripplanner.routing.core.ServiceDay;
 import org.opentripplanner.routing.trippattern.FrequencyEntry;
@@ -12,12 +17,6 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.Serializable;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import java.util.TimeZone;
-
 
 /**
  * Timetables provide most of the TripPattern functionality. Each TripPattern may possess more than
@@ -93,7 +92,7 @@ public boolean temporallyViable(ServiceDay sd, long searchTime, int bestWait, bo
      * actions to compact the data structure such as trimming and deduplicating arrays.
      */
     public void finish() {
-        int nStops = pattern.getStopPattern().getSize();
+        int nStops = pattern.numberOfStops();
 
         // Concatenate raw TripTimes and those referenced from FrequencyEntries
         List<TripTimes> allTripTimes = Lists.newArrayList(tripTimes);
diff --git a/src/main/java/org/opentripplanner/model/Trip.java b/src/main/java/org/opentripplanner/model/Trip.java
index 694241caaf2..5035e51f3c6 100644
--- a/src/main/java/org/opentripplanner/model/Trip.java
+++ b/src/main/java/org/opentripplanner/model/Trip.java
@@ -136,8 +136,8 @@ public void setTripShortName(String tripShortName) {
     }
 
     /**
-     * Return human friendly short info to identify the trip when mode, from/to stop and
-     * times are known. This method is meant for logging, and should not be exposed in any API.
+     * Return human friendly short info to identify the trip when mode, from/to stop and times are
+     * known. This method is meant for debug/logging, and should not be exposed in any API.
      */
     public String logInfo() {
         if(hasValue(tripShortName)) { return tripShortName; }
diff --git a/src/main/java/org/opentripplanner/model/TripPattern.java b/src/main/java/org/opentripplanner/model/TripPattern.java
index ca6ef3348e4..710e35becbe 100644
--- a/src/main/java/org/opentripplanner/model/TripPattern.java
+++ b/src/main/java/org/opentripplanner/model/TripPattern.java
@@ -7,22 +7,10 @@
 import com.google.common.hash.HashFunction;
 import com.google.common.hash.Hashing;
 import com.google.common.io.BaseEncoding;
-import org.locationtech.jts.geom.Coordinate;
-import org.locationtech.jts.geom.LineString;
-import org.opentripplanner.common.geometry.CompactLineString;
-import org.opentripplanner.common.geometry.GeometryUtils;
-import org.opentripplanner.graph_builder.DataImportIssueStore;
-import org.opentripplanner.graph_builder.issues.NonUniqueRouteName;
-import org.opentripplanner.routing.trippattern.FrequencyEntry;
-import org.opentripplanner.routing.trippattern.TripTimes;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.io.IOException;
 import java.io.ObjectInputStream;
 import java.io.Serializable;
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.BitSet;
 import java.util.Collection;
 import java.util.HashSet;
@@ -31,6 +19,16 @@
 import java.util.Set;
 import java.util.function.Predicate;
 import java.util.stream.Collectors;
+import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.LineString;
+import org.opentripplanner.common.geometry.CompactLineString;
+import org.opentripplanner.common.geometry.GeometryUtils;
+import org.opentripplanner.graph_builder.DataImportIssueStore;
+import org.opentripplanner.graph_builder.issues.NonUniqueRouteName;
+import org.opentripplanner.routing.trippattern.FrequencyEntry;
+import org.opentripplanner.routing.trippattern.TripTimes;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Represents a group of trips on a route, with the same direction id that all call at the same
@@ -46,7 +44,7 @@
  * generated in the format FeedId:Agency:RouteId:DirectionId:PatternNumber. For NeTEx the
  * JourneyPattern id is used.
  */
-public class TripPattern extends TransitEntity implements Cloneable, Serializable {
+public final class TripPattern extends TransitEntity implements Cloneable, Serializable {
 
     private static final Logger LOG = LoggerFactory.getLogger(TripPattern.class);
 
@@ -56,6 +54,12 @@ public class TripPattern extends TransitEntity implements Cloneable, Serializabl
 
     private final Route route;
 
+    /**
+     * The stop-pattern help us reuse the same stops in several trip-patterns; Hence
+     * saving memory. The field should not be accessible outside the class, and all access
+     * is done through method delegation, like the {@link #numberOfStops()} and
+     * {@link #canBoard(int)} methods.
+     */
     private final StopPattern stopPattern;
 
     private final Timetable scheduledTimetable = new Timetable(this);
@@ -85,14 +89,26 @@ public TripPattern(FeedScopedId id, Route route, StopPattern stopPattern) {
         this.stopPattern = stopPattern;
     }
 
+    /** The human-readable, unique name for this trip pattern. */
+    public String getName() {
+        return name;
+    }
+
     public void setName(String name) {
         this.name = name;
     }
 
+    /**
+     * The GTFS Route of all trips in this pattern.
+     */
+    public Route getRoute() {
+        return route;
+    }
+
     /**
      * Convenience method to get the route traverse mode, the mode for all trips in this pattern.
      */
-    public final TransitMode getMode() {
+    public TransitMode getMode() {
         return route.getMode();
     }
 
@@ -100,17 +116,17 @@ public final String getNetexSubmode() {
         return route.getNetexSubmode();
     }
 
-    public LineString getHopGeometry(int stopIndex) {
+    public LineString getHopGeometry(int stopPosInPattern) {
         if (hopGeometries != null) {
             return CompactLineString.uncompactLineString(
-                    hopGeometries[stopIndex],
+                    hopGeometries[stopPosInPattern],
                     false
             );
         } else {
             return GeometryUtils.getGeometryFactory().createLineString(
                     new Coordinate[]{
-                            coordinate(stopPattern.getStops()[stopIndex]),
-                            coordinate(stopPattern.getStops()[stopIndex + 1])
+                            coordinate(stopPattern.getStop(stopPosInPattern)),
+                            coordinate(stopPattern.getStop(stopPosInPattern + 1))
                     }
             );
         }
@@ -136,13 +152,13 @@ public void setHopGeometry(int i, LineString hopGeometry) {
      * @param other TripPattern to copy geometry from
      */
     public void setHopGeometriesFromPattern(TripPattern other) {
-        this.hopGeometries = new byte[this.getStops().size() - 1][];
+        this.hopGeometries = new byte[numberOfStops() - 1][];
 
         // This accounts for the new TripPattern provided by a real-time update and the one that is
         // being replaced having a different number of stops. In that case the geometry will be
         // preserved up until the first mismatching stop, and a straight line will be used for
         // all segments after that.
-        int sizeOfShortestPattern = Math.min(this.getStops().size(), other.getStops().size());
+        int sizeOfShortestPattern = Math.min(numberOfStops(), other.numberOfStops());
 
         for (int i = 0; i < sizeOfShortestPattern - 1; i++) {
             if (other.getHopGeometry(i) != null
@@ -155,8 +171,8 @@ public void setHopGeometriesFromPattern(TripPattern other) {
                 this.setHopGeometry(i,
                     GeometryUtils.getGeometryFactory().createLineString(
                         new Coordinate[]{
-                            coordinate(getStopPattern().getStops()[i]),
-                            coordinate(getStopPattern().getStops()[i + 1])
+                            coordinate(stopPattern.getStop(i)),
+                            coordinate(stopPattern.getStop(i + 1))
                         }
                     )
                 );
@@ -178,43 +194,63 @@ public int numHopGeometries() {
         return hopGeometries.length;
     }
 
-    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
-        in.defaultReadObject();
-        // The serialized graph contains cyclic references TripPattern <--> Timetable.
-        // The Timetable must be indexed from here (rather than in its own readObject method)
-        // to ensure that the stops field it uses in TripPattern is already deserialized.
-        scheduledTimetable.finish();
+    public int numberOfStops() {
+        return stopPattern.getSize();
     }
 
-    public StopLocation getStop(int stopIndex) {
-        return stopPattern.getStops()[stopIndex];
+    public StopLocation getStop(int stopPosInPattern) {
+        return stopPattern.getStop(stopPosInPattern);
     }
 
+    public StopLocation firstStop() {
+        return getStop(0);
+    }
 
-    public int getStopIndex(Stop stop) {
-        return Arrays.asList(stopPattern.getStops()).indexOf(stop);
+    public StopLocation lastStop() {
+        return getStop(stopPattern.getSize()-1);
     }
 
+    /** Read only list of stops */
     public List<StopLocation> getStops() {
-        return Arrays.asList(stopPattern.getStops());
+        return stopPattern.getStops();
     }
 
-    public Trip getTrip(int tripIndex) {
-        return getTrips().get(tripIndex);
+    public int findStopPosition(StopLocation stop) {
+        return stopPattern.findStopPosition(stop);
     }
 
-    public int getTripIndex(Trip trip) {
-        return getTrips().indexOf(trip);
+    public int findBoardingStopPositionInPattern(Station station) {
+        return stopPattern.findBoardingPosition(station);
+    }
+
+    public int findAlightStopPositionInPattern(Station station) {
+        return stopPattern.findAlightPosition(station);
+    }
+
+    public int findBoardingStopPositionInPattern(StopLocation stop) {
+        return stopPattern.findBoardingPosition(stop);
+    }
+
+    public int findAlightStopPositionInPattern(StopLocation stop) {
+        return stopPattern.findAlightPosition(stop);
     }
 
     /** Returns whether passengers can alight at a given stop */
     public boolean canAlight(int stopIndex) {
-        return stopPattern.getDropoff(stopIndex).isRoutable();
+        return stopPattern.canAlight(stopIndex);
     }
 
     /** Returns whether passengers can board at a given stop */
     public boolean canBoard(int stopIndex) {
-        return stopPattern.getPickup(stopIndex).isRoutable();
+        return stopPattern.canBoard(stopIndex);
+    }
+
+    /**
+     * Returns whether passengers can board at a given stop.
+     * This is an inefficient method iterating over the stops, do not use it in routing.
+     */
+    public boolean canBoard(StopLocation stop) {
+        return stopPattern.canBoard(stop);
     }
 
     /** Returns whether a given stop is wheelchair-accessible. */
@@ -230,6 +266,22 @@ public PickDrop getBoardType(int stopIndex) {
         return stopPattern.getPickup(stopIndex);
     }
 
+    public boolean isBoardAndAlightAt(int stopIndex, PickDrop value) {
+        return getBoardType(stopIndex).is(value) && getAlightType(stopIndex).is(value);
+    }
+
+    public boolean stopPatternIsEqual(TripPattern other) {
+        return stopPattern.equals(other.stopPattern);
+    }
+
+    public Trip getTrip(int tripIndex) {
+        return getTrips().get(tripIndex);
+    }
+
+    public int getTripIndex(Trip trip) {
+        return getTrips().indexOf(trip);
+    }
+
     /* METHODS THAT DELEGATE TO THE SCHEDULED TIMETABLE */
 
     // TODO: These should probably be deprecated. That would require grabbing the scheduled timetable,
@@ -240,9 +292,10 @@ public PickDrop getBoardType(int stopIndex) {
      * trip as one of the scheduled trips on this pattern.
      */
     public void add(TripTimes tt) {
-        // Only scheduled trips (added at graph build time, rather than directly to the timetable via updates) are in this list.
-        getTrips().add(tt.getTrip());
+        // Only scheduled trips (added at graph build time, rather than directly to the timetable
+        // via updates) are in this list.
         scheduledTimetable.addTripTimes(tt);
+
         // Check that all trips added to this pattern are on the initially declared route.
         // Identity equality is valid on GTFS entity objects.
         if (this.route != tt.getTrip().getRoute()) {
@@ -306,27 +359,7 @@ public Direction getDirection() {
      * to search for trips/TripIds in the Timetable rather than the enclosing TripPattern.
      */
     public List<Trip> getTrips() {
-        return scheduledTimetable.getTripTimes().stream().map(t -> t.getTrip()).collect(Collectors.toList());
-    }
-
-    /** The human-readable, unique name for this trip pattern. */
-    public String getName() {
-        return name;
-    }
-
-    /**
-     * The GTFS Route of all trips in this pattern.
-     */
-    public Route getRoute() {
-        return route;
-    }
-
-    /**
-     * All trips in this pattern call at this sequence of stops. This includes information about GTFS
-     * pick-up and drop-off types.
-     */
-    public StopPattern getStopPattern() {
-        return stopPattern;
+        return scheduledTimetable.getTripTimes().stream().map(TripTimes::getTrip).collect(Collectors.toList());
     }
 
     /**
@@ -433,19 +466,19 @@ public static void generateUniqueNames (
             Multimap<StopLocation, TripPattern> vias    = ArrayListMultimap.create();
 
             for (TripPattern pattern : routeTripPatterns) {
-                List<StopLocation> stops = pattern.getStops();
-                StopLocation start = stops.get(0);
-                StopLocation end   = stops.get(stops.size() - 1);
+                StopLocation start = pattern.firstStop();
+                StopLocation end   = pattern.lastStop();
                 starts.put(start, pattern);
                 ends.put(end, pattern);
-                for (StopLocation stop : stops) vias.put(stop, pattern);
+                for (StopLocation stop : pattern.getStops()) {
+                    vias.put(stop, pattern);
+                }
             }
             PATTERN : for (TripPattern pattern : routeTripPatterns) {
-                List<StopLocation> stops = pattern.getStops();
                 StringBuilder sb = new StringBuilder(routeName);
 
                 /* First try to name with destination. */
-                var end = stops.get(stops.size() - 1);
+                var end = pattern.lastStop();
                 sb.append(" to " + stopNameAndId(end));
                 if (ends.get(end).size() == 1) {
                     pattern.setName(sb.toString());
@@ -453,7 +486,7 @@ public static void generateUniqueNames (
                 }
 
                 /* Then try to name with origin. */
-                var start = stops.get(0);
+                var start = pattern.firstStop();
                 sb.append(" from " + stopNameAndId(start));
                 if (starts.get(start).size() == 1) {
                     pattern.setName((sb.toString()));
@@ -470,7 +503,7 @@ public static void generateUniqueNames (
                 }
 
                 /* Still not unique; try (end, start, via) for each via. */
-                for (var via : stops) {
+                for (var via : pattern.getStops()) {
                     if (via.equals(start) || via.equals(end)) continue;
                     Set<TripPattern> intersection = new HashSet<>();
                     intersection.addAll(remainingPatterns);
@@ -612,6 +645,14 @@ public String getFeedId() {
         return route.getId().getFeedId();
     }
 
+    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
+        in.defaultReadObject();
+        // The serialized graph contains cyclic references TripPattern <--> Timetable.
+        // The Timetable must be indexed from here (rather than in its own readObject method)
+        // to ensure that the stops field it uses in TripPattern is already deserialized.
+        scheduledTimetable.finish();
+    }
+
     private static Coordinate coordinate(StopLocation s) {
         return new Coordinate(s.getLon(), s.getLat());
     }
diff --git a/src/main/java/org/opentripplanner/model/TripTimeOnDate.java b/src/main/java/org/opentripplanner/model/TripTimeOnDate.java
index 742496e7f60..9242cc630a1 100644
--- a/src/main/java/org/opentripplanner/model/TripTimeOnDate.java
+++ b/src/main/java/org/opentripplanner/model/TripTimeOnDate.java
@@ -1,12 +1,11 @@
 package org.opentripplanner.model;
 
-import org.opentripplanner.routing.core.ServiceDay;
-import org.opentripplanner.routing.trippattern.RealTimeState;
-import org.opentripplanner.routing.trippattern.TripTimes;
-
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.List;
+import org.opentripplanner.routing.core.ServiceDay;
+import org.opentripplanner.routing.trippattern.RealTimeState;
+import org.opentripplanner.routing.trippattern.TripTimes;
 
 /**
  * Represents a Trip at a specific stop index and on a specific service day. This is a read-only
@@ -58,7 +57,7 @@ public static Comparator<TripTimeOnDate> compareByDeparture() {
     }
 
     public FeedScopedId getStopId() {
-        return tripPattern.getStopPattern().getStop(stopIndex).getId();
+        return tripPattern.getStop(stopIndex).getId();
     }
 
     public int getStopIndex() {
@@ -119,8 +118,7 @@ public boolean isRealtime() {
 
     public boolean isCancelledStop() {
         return tripTimes.isCancelledStop(stopIndex) ||
-            tripPattern.getStopPattern().getPickup(stopIndex) == PickDrop.CANCELLED
-            && tripPattern.getStopPattern().getDropoff(stopIndex) == PickDrop.CANCELLED;
+            tripPattern.isBoardAndAlightAt(stopIndex, PickDrop.CANCELLED);
     }
 
     /** Return {code true} if stop is cancelled, or trip is canceled/replaced */
@@ -153,13 +151,13 @@ public String getHeadsign() {
     public PickDrop getPickupType() {
         return tripTimes.isCanceled() || tripTimes.isCancelledStop(stopIndex)
             ? PickDrop.CANCELLED
-            : tripPattern.getStopPattern().getPickup(stopIndex);
+            : tripPattern.getBoardType(stopIndex);
     }
 
     public PickDrop getDropoffType() {
         return tripTimes.isCanceled() || tripTimes.isCancelledStop(stopIndex)
             ? PickDrop.CANCELLED
-            : tripPattern.getStopPattern().getDropoff(stopIndex);
+            : tripPattern.getAlightType(stopIndex);
     }
 
     public StopTimeKey getStopTimeKey() {
diff --git a/src/main/java/org/opentripplanner/model/impl/OtpTransitServiceBuilder.java b/src/main/java/org/opentripplanner/model/impl/OtpTransitServiceBuilder.java
index c863c221a97..65abc21aed0 100644
--- a/src/main/java/org/opentripplanner/model/impl/OtpTransitServiceBuilder.java
+++ b/src/main/java/org/opentripplanner/model/impl/OtpTransitServiceBuilder.java
@@ -338,19 +338,24 @@ private void fixOrRemovePatternsWhichReferenceNoneExistingTrips() {
     /** Remove all transfers witch reference none existing trips */
     private void removeTransfersForNoneExistingTrips() {
         int orgSize = transfers.size();
-        transfers.removeIf(this::transferTripsDoesNotExist);
+        transfers.removeIf(this::transferTripReferencesDoNotExist);
         logRemove("Trip", orgSize, transfers.size(), "Transfer to/from trip does not exist.");
     }
 
     /** Return {@code true} if the from/to trip reference is none null, but do not exist. */
-    private boolean transferTripsDoesNotExist(ConstrainedTransfer t) {
-        return transferTripPointDoesNotExist(t.getFrom())
-            || transferTripPointDoesNotExist(t.getTo());
+    private boolean transferTripReferencesDoNotExist(ConstrainedTransfer t) {
+        return transferPointTripReferenceDoesNotExist(t.getFrom())
+            || transferPointTripReferenceDoesNotExist(t.getTo());
     }
 
-    /** Return true if the trip is a valid reference; {@code null} or exist. */
-    private boolean transferTripPointDoesNotExist(TransferPoint p) {
-        return p.getTrip() != null && !tripsById.containsKey(p.getTrip().getId());
+    /**
+     * Return {@code true} if the the point is a trip-transfer-point and the trip reference
+     * is missing.
+     */
+    private boolean transferPointTripReferenceDoesNotExist(TransferPoint point) {
+        if(!point.isTripTransferPoint()) { return false; }
+        var trip = point.asTripTransferPoint().getTrip();
+        return !tripsById.containsKey(trip.getId());
     }
 
     private static void logRemove(String type, int orgSize, int newSize, String reason) {
diff --git a/src/main/java/org/opentripplanner/model/transfer/ConstrainedTransfer.java b/src/main/java/org/opentripplanner/model/transfer/ConstrainedTransfer.java
index 5bcae61ef81..2fa329c8f0f 100644
--- a/src/main/java/org/opentripplanner/model/transfer/ConstrainedTransfer.java
+++ b/src/main/java/org/opentripplanner/model/transfer/ConstrainedTransfer.java
@@ -1,4 +1,3 @@
-/* This file is based on code copied from project OneBusAway, see the LICENSE file for further information. */
 package org.opentripplanner.model.transfer;
 
 import java.io.Serializable;
@@ -19,6 +18,8 @@
 public final class ConstrainedTransfer implements RaptorConstrainedTransfer, Serializable {
 
     private static final long serialVersionUID = 1L;
+    private static final int FROM_RANKING_COEFFICIENT = 11;
+    private static final int TO_RANKING_COEFFICIENT = 10;
 
     private final FeedScopedId id;
 
@@ -65,20 +66,46 @@ public TransferConstraint getTransferConstraint() {
     }
 
     public boolean noConstraints() {
-        return constraint.noConstraints();
-    }
-
-    public boolean matchesStopPos(int fromStopPos, int toStopPos) {
-        return from.getStopPosition() == fromStopPos && to.getStopPosition() == toStopPos;
+        return constraint.isRegularTransfer();
     }
 
     /**
      * <a href="https://developers.google.com/transit/gtfs/reference/gtfs-extensions#specificity-of-a-transfer">
      * Specificity of a transfer
      * </a>
+     *
+     * The ranking implemented here is slightly modified:
+     * <ul>
+     *     <li>
+     *         The specification do not say anything about Stations even if Stations can be used to
+     *         specify a transfer-point. In OTP stops are more specific than station, so we use the
+     *         following transfer-point ranking:
+     *         <ol>
+     *             <li>Station: 0 (zero)</li>
+     *             <li>Stop: 1</li>
+     *             <li>Route: 2</li>
+     *             <li>Trip: 3</li>
+     *         </ol>
+     *     </li>
+     *     <li>
+     *         Two transfers may have the same ranking if we add together the from-point and
+     *         to-point ranking.
+     *         For example, {@code from trip(3) + to stop(1) == from route(2) + to route(2)}
+     *         have the same ranking. To avoid this problem, we give the from-point a small
+     *         advantage. We multiply the from point with 11 and the to point with 10, this
+     *         break the ties in favor of the from point. In the example above the
+     *         ConstrainedTransfer specificityRanking is:
+     * <pre>
+     * Case 1: from trip to stop :=  11 * 3 + 10 * 1 = 43
+     * Case 2: from route to route :=  11 * 2 + 10 * 2 = 42
+     * </pre>
+     *         Case 1 has the highest ranking.
+     *     </li>
+     * </ul>
      */
     public int getSpecificityRanking() {
-        return from.getSpecificityRanking() + to.getSpecificityRanking();
+        return from.getSpecificityRanking() * FROM_RANKING_COEFFICIENT
+                + to.getSpecificityRanking() * TO_RANKING_COEFFICIENT;
     }
 
     @Override
diff --git a/src/main/java/org/opentripplanner/model/transfer/RouteStationTransferPoint.java b/src/main/java/org/opentripplanner/model/transfer/RouteStationTransferPoint.java
new file mode 100644
index 00000000000..8eeead89c0a
--- /dev/null
+++ b/src/main/java/org/opentripplanner/model/transfer/RouteStationTransferPoint.java
@@ -0,0 +1,49 @@
+package org.opentripplanner.model.transfer;
+
+import java.io.Serializable;
+import org.opentripplanner.model.Route;
+import org.opentripplanner.model.Station;
+import org.opentripplanner.model.base.ValueObjectToStringBuilder;
+
+public final class RouteStationTransferPoint implements TransferPoint, Serializable {
+
+  private static final long serialVersionUID = 1L;
+
+  private final Route route;
+  private final Station station;
+
+  public RouteStationTransferPoint(Route route, Station station) {
+    this.route = route;
+    this.station = station;
+  }
+
+  public Route getRoute() {
+    return route;
+  }
+
+  public Station getStation() {
+    return station;
+  }
+
+  @Override
+  public boolean appliesToAllTrips() {
+    return true;
+  }
+
+  @Override
+  public int getSpecificityRanking() { return 2; }
+
+  @Override
+  public boolean isRouteStationTransferPoint() { return true; }
+
+  @Override
+  public String toString() {
+    return ValueObjectToStringBuilder.of()
+            .addText("<Route ")
+            .addObj(route.getId())
+            .addText(", station ")
+            .addObj(station.getId())
+            .addText(">")
+            .toString();
+  }
+}
diff --git a/src/main/java/org/opentripplanner/model/transfer/RouteStopTransferPoint.java b/src/main/java/org/opentripplanner/model/transfer/RouteStopTransferPoint.java
new file mode 100644
index 00000000000..7ef454c78e4
--- /dev/null
+++ b/src/main/java/org/opentripplanner/model/transfer/RouteStopTransferPoint.java
@@ -0,0 +1,49 @@
+package org.opentripplanner.model.transfer;
+
+import java.io.Serializable;
+import org.opentripplanner.model.Route;
+import org.opentripplanner.model.StopLocation;
+import org.opentripplanner.model.base.ValueObjectToStringBuilder;
+
+public final class RouteStopTransferPoint implements TransferPoint, Serializable {
+
+  private static final long serialVersionUID = 1L;
+
+  private final Route route;
+  private final StopLocation stop;
+
+  public RouteStopTransferPoint(Route route, StopLocation stop) {
+    this.route = route;
+    this.stop = stop;
+  }
+
+  public Route getRoute() {
+    return route;
+  }
+
+  public StopLocation getStop() {
+    return stop;
+  }
+
+  @Override
+  public boolean appliesToAllTrips() {
+    return true;
+  }
+
+  @Override
+  public int getSpecificityRanking() { return 3; }
+
+  @Override
+  public boolean isRouteStopTransferPoint() { return true; }
+
+  @Override
+  public String toString() {
+    return ValueObjectToStringBuilder.of()
+            .addText("<Route ")
+            .addObj(route.getId())
+            .addText(", stop ")
+            .addObj(stop.getId())
+            .addText(">")
+            .toString();
+  }
+}
diff --git a/src/main/java/org/opentripplanner/model/transfer/RouteTransferPoint.java b/src/main/java/org/opentripplanner/model/transfer/RouteTransferPoint.java
deleted file mode 100644
index 7fb190d6417..00000000000
--- a/src/main/java/org/opentripplanner/model/transfer/RouteTransferPoint.java
+++ /dev/null
@@ -1,35 +0,0 @@
-package org.opentripplanner.model.transfer;
-
-import java.io.Serializable;
-import org.opentripplanner.model.Route;
-import org.opentripplanner.model.Trip;
-
-/**
- * This is a specialized version of the {@link TripTransferPoint}, it represent a
- * given trip of the GTFS Route transfer. It override the specificity-ranking. Except for that,
- * it behave like its super type. So, when looking up tran
- * <p>
- * By expanding a route into trips, we can drop expanded-trips(lower specificity ranking)
- * if a "real" trip-transfers-point exist.
- */
-public class RouteTransferPoint extends TripTransferPoint implements Serializable {
-
-  private static final long serialVersionUID = 1L;
-
-  private final Route route;
-
-  public RouteTransferPoint(Route route, Trip trip, int stopPosition) {
-    super(trip, stopPosition);
-    this.route = route;
-  }
-
-  @Override
-  public int getSpecificityRanking() { return 1; }
-
-  @Override
-  public String toString() {
-    return "(route: " + route.getId()
-            + ", trip: " + getTrip().getId()
-            + ", stopPos: " + getStopPosition() + ")";
-  }
-}
diff --git a/src/main/java/org/opentripplanner/model/transfer/StationTransferPoint.java b/src/main/java/org/opentripplanner/model/transfer/StationTransferPoint.java
new file mode 100644
index 00000000000..400ff3e8ad8
--- /dev/null
+++ b/src/main/java/org/opentripplanner/model/transfer/StationTransferPoint.java
@@ -0,0 +1,42 @@
+package org.opentripplanner.model.transfer;
+
+import java.io.Serializable;
+import org.opentripplanner.model.Station;
+import org.opentripplanner.model.base.ValueObjectToStringBuilder;
+
+public final class StationTransferPoint implements TransferPoint, Serializable {
+
+  private static final long serialVersionUID = 1L;
+
+  private final Station station;
+
+
+  public StationTransferPoint(Station station) {
+    this.station = station;
+  }
+
+  public Station getStation() {
+    return station;
+  }
+
+  @Override
+  public boolean appliesToAllTrips() {
+    return true;
+  }
+
+  @Override
+  public int getSpecificityRanking() {
+    return 0;
+  }
+
+  @Override
+  public boolean isStationTransferPoint() { return true; }
+
+  public String toString() {
+    return ValueObjectToStringBuilder.of()
+            .addText("<Station ")
+            .addObj(station.getId())
+            .addText(">")
+            .toString();
+  }
+}
diff --git a/src/main/java/org/opentripplanner/model/transfer/StopTransferPoint.java b/src/main/java/org/opentripplanner/model/transfer/StopTransferPoint.java
index 8c21d77db58..b9b2c0c7a14 100644
--- a/src/main/java/org/opentripplanner/model/transfer/StopTransferPoint.java
+++ b/src/main/java/org/opentripplanner/model/transfer/StopTransferPoint.java
@@ -1,8 +1,6 @@
 package org.opentripplanner.model.transfer;
 
 import java.io.Serializable;
-import java.util.Objects;
-import org.opentripplanner.model.Stop;
 import org.opentripplanner.model.StopLocation;
 
 public class StopTransferPoint implements TransferPoint, Serializable {
@@ -11,35 +9,29 @@ public class StopTransferPoint implements TransferPoint, Serializable {
 
   private final StopLocation stop;
 
+
   public StopTransferPoint(StopLocation stop) {
     this.stop = stop;
   }
 
-  @Override
   public StopLocation getStop() {
     return stop;
   }
 
   @Override
-  public int getSpecificityRanking() {
-    return 0;
+  public boolean appliesToAllTrips() {
+    return true;
   }
 
   @Override
-  public String toString() {
-    return "(stop: " + stop.getId() + ")";
+  public int getSpecificityRanking() {
+    return 1;
   }
 
   @Override
-  public boolean equals(Object o) {
-    if (this == o) { return true; }
-    if (!(o instanceof StopTransferPoint)) { return false; }
-    final StopTransferPoint that = (StopTransferPoint) o;
-    return Objects.equals(stop.getId(), that.stop.getId());
-  }
+  public boolean isStopTransferPoint() { return true; }
 
-  @Override
-  public int hashCode() {
-    return Objects.hash(stop.getId());
+  public String toString() {
+    return "<Stop " + stop.getId() + ">";
   }
 }
diff --git a/src/main/java/org/opentripplanner/model/transfer/TransferConstraint.java b/src/main/java/org/opentripplanner/model/transfer/TransferConstraint.java
index c9ec89e7c25..dcaec3966a1 100644
--- a/src/main/java/org/opentripplanner/model/transfer/TransferConstraint.java
+++ b/src/main/java/org/opentripplanner/model/transfer/TransferConstraint.java
@@ -20,6 +20,12 @@ public class TransferConstraint implements Serializable, RaptorTransferConstrain
 
     private static final long serialVersionUID = 1L;
 
+
+    /**
+     * A regular transfer is a transfer with no constraints.
+     */
+    public static final TransferConstraint REGULAR_TRANSFER = create().build();
+
     /**
      * STAY_SEATED is not a priority, but we assign a cost to it to be able to compare it with other
      * transfers with a priority and the {@link #GUARANTIED_TRANSFER_COST}.
@@ -91,6 +97,9 @@ public TransferPriority getPriority() {
         return priority;
     }
 
+    /**
+     * Also known as interlining of GTFS trips with the same block id.
+     */
     public boolean isStaySeated() {
         return staySeated;
     }
@@ -104,15 +113,40 @@ public boolean isGuaranteed() {
      * if the alight-slack or board-slack is too tight. We ignore slack for facilitated transfers.
      * <p>
      * This is an aggregated field, which encapsulates an OTP specific rule. A facilitated transfer
-     * is either stay-seated or guaranteed. High priority transfers are not.
+     * is either stay-seated or guaranteed. High priority transfers are not facilitated.
      */
     public boolean isFacilitated() {
         return staySeated || guaranteed;
     }
 
+
+    /**
+     * This switch enables transfers in Raptor, ignoring transfer constraints with for example
+     * only priority set.
+     */
+    public boolean useInRaptorRouting() {
+        return isStaySeated() || isGuaranteed() || isNotAllowed();
+    }
+
+    @Override
+    public boolean isNotAllowed() {
+        return priority == NOT_ALLOWED;
+    }
+
+    @Override
+    public boolean isRegularTransfer() {
+        // Note! The 'maxWaitTime' is only valid with the guaranteed flag set, so we
+        // do not need to check it here
+        return !(staySeated || guaranteed || priority.isConstrained());
+    }
+
     /**
      * Maximum time after scheduled departure time the connecting transport is guarantied to wait
      * for the delayed trip.
+     * <p>
+     * THIS IS NOT CONSIDERED IN RAPTOR. OTP relies on real-time data for this, so if the "from"
+     * vehicle is delayed, then the real time system is also responsible for propagating the delay
+     * onto the "to" trip.
      */
     public int getMaxWaitTime() {
         return maxWaitTime;
@@ -135,7 +169,7 @@ public boolean equals(Object o) {
     }
 
     public String toString() {
-        if(noConstraints()) { return "{no constraints}"; }
+        if(isRegularTransfer()) { return "{no constraints}"; }
 
         return ToStringBuilder.of()
                 .addEnum("priority", priority, ALLOWED)
@@ -145,12 +179,6 @@ public String toString() {
                 .toString();
     }
 
-    public boolean noConstraints() {
-        // Note! The 'maxWaitTime' is only valid with the guaranteed flag set, so we
-        // do not need to check it here
-        return !(staySeated || guaranteed || priority.isConstrained());
-    }
-
     /**
      * Calculate a cost for prioritizing transfers in a path, to select the best path with respect to
      * transfers. This cost is not related in any way to the path generalized-cost. It takes only the
@@ -183,14 +211,13 @@ private int facilitatedCost() {
         return NONE_FACILITATED_COST;
     }
 
-
-
-    public static Builder create() { return new Builder(); }
-
     private boolean isMaxWaitTimeSet() {
         return maxWaitTime != MAX_WAIT_TIME_NOT_SET;
     }
 
+
+    public static Builder create() { return new Builder(); }
+
     public static class Builder {
         private TransferPriority priority = ALLOWED;
         private boolean staySeated = false;
diff --git a/src/main/java/org/opentripplanner/model/transfer/TransferPoint.java b/src/main/java/org/opentripplanner/model/transfer/TransferPoint.java
index f476572a77b..dd22f1bc407 100644
--- a/src/main/java/org/opentripplanner/model/transfer/TransferPoint.java
+++ b/src/main/java/org/opentripplanner/model/transfer/TransferPoint.java
@@ -1,13 +1,13 @@
 package org.opentripplanner.model.transfer;
 
-import org.opentripplanner.model.StopLocation;
+import javax.annotation.Nullable;
+import org.opentripplanner.model.Route;
 import org.opentripplanner.model.Trip;
 
-
 /**
  * This interface is used to represent a point or location where a transfer start from or end.
  *
- * <p>There are 3 different Transfer points:
+ * <p>There are 4 different Transfer points:
  * <ol>
  *   <li>
  *     {@link StopTransferPoint} This apply to all trip stopping at the given stop.
@@ -15,63 +15,105 @@
  *     <p>This is the least specific type, and is overridden if a more specific type exist.
  *   </li>
  *   <li>
- *     A {@link RouteTransferPoint} is a from/to point for a Route at the given stop. This only
- *     exist in GTFS, not in the Nordic NeTex profile. To support this we expand the route into
- *     all trips defined for it, and create {@link RouteTransferPoint} for each trip. We do the
- *     expansion because a Route may have more than on TripPattern and we want to use the stop
- *     position in pattern, not the stop for matching actual transfers. The reason is that
- *     real-time updates could invalidate a (route+stop)-transfer-point, since the stop could
- *     change to another platform(very common for railway stations). To account for this the
- *     RT-update would have to patch the (route&stop)-transfer-point. We simplify the RT-updates
- *     by converting expanding (route+stop) to (trip+stop position).
+ *     {@link StationTransferPoint} This applies to all trips stopping at a stop part of the given
+ *     station.
+ *     <p>The specificity-ranking is above {@link StationTransferPoint}s and less than
+ *     {@link RouteStationTransferPoint}.
+ *   </li>
+ *   <li>
+ *     A {@link RouteStationTransferPoint} is a from/to point for a Route at the given stop. This
+ *     only exists in GTFS, not in the Nordic NeTex profile.
  *
  *     <p>The specificity-ranking is above {@link StopTransferPoint}s and less than
+ *     {@link RouteStopTransferPoint}.
+ *   </li>
+ *   <li>
+ *     A {@link RouteStopTransferPoint} is a from/to point for a Route at the given station. This
+ *     only exists in GTFS, not in the Nordic NeTex profile.
+ *
+ *     <p>The specificity-ranking is above {@link RouteStationTransferPoint}s and less than
  *     {@link TripTransferPoint}.
  *   </li>
  *   <li>
  *     {@link TripTransferPoint} A transfer from/to a Trip at the given stop position(not stop).
- *     GTFS Transfers specify a transfer from/to a trip and stop. But in OTP we map the stop to a
- *     stop position in pattern instead. This make sure that the transfer is still valid after a
- *     real-time update where the stop is changed. Especially for train stations changing the
- *     train platform is common and by using the stop position in pattern not the stop this
- *     become more robust. So, the OTP implementation follow the NeTEx Interchange definition
- *     here, not the GTFS specification.
- *
- *     <p>This is the most specific point type, and will override both {@link RouteTransferPoint}
- *     and {@link StopTransferPoint} if more than one match exist.
+ *     The GTFS Transfers may specify a transfer from/to a trip and stop/station. But in OTP we
+ *     map the stop to a stop position in pattern. The OTP model {@link TripTransferPoint} does NOT
+ *     reference the stop/station, but the {@code stopPositionInPattern} instead. There is two
+ *     reasons for this. In NeTEx the an interchange is from a trip and stop-point, so this model
+ *     fits better with NeTEx. The second reason is that real-time updates could invalidate the
+ *     trip-transfer-point, since the stop could change to another platform(common for railway
+ *     stations). To account for this the RT-update would need to patch the trip-transfer-point.
+ *     We simplify the RT-updates by converting the stop to a stop-position-in-pattern.
+ *     <p>
+ *     This is the most specific point type.
  *   </li>
  * </ol>
+ * <p>
  */
 public interface TransferPoint {
 
-  int NOT_AVAILABLE = -1;
+  /** Return {@code true} if this transfer point apply to all trips in pattern */
+  boolean appliesToAllTrips();
 
-  default StopLocation getStop() {
-    return null;
+  /**
+   * <a href="https://developers.google.com/transit/gtfs/reference/gtfs-extensions#specificity-of-a-transfer">
+   * Specificity of a transfer
+   * </a>
+   */
+  int getSpecificityRanking();
+
+  /** is a Trip specific transfer point */
+  default boolean isTripTransferPoint() { return false; }
+
+  default TripTransferPoint asTripTransferPoint() { return (TripTransferPoint) this; }
+
+  /** is a Route specific transfer point */
+  default boolean isRouteStationTransferPoint() { return false; }
+
+  default RouteStationTransferPoint asRouteStationTransferPoint() {
+    return (RouteStationTransferPoint) this;
   }
 
-  default Trip getTrip() {
-    return null;
+  /** is a Route specific transfer point */
+  default boolean isRouteStopTransferPoint() { return false; }
+
+  default RouteStopTransferPoint asRouteStopTransferPoint() {
+    return (RouteStopTransferPoint) this;
   }
 
+  /** is a Stop specific transfer point (no Trip or Route) */
+  default boolean isStopTransferPoint() { return false; }
+
+  default StopTransferPoint asStopTransferPoint() { return (StopTransferPoint) this; }
+
+  /** is a Station specific transfer point (no Trip or Route) */
+  default boolean isStationTransferPoint() { return false; }
+
+  default StationTransferPoint asStationTransferPoint() { return (StationTransferPoint) this; }
+
+
   /**
-   * If the given transfer point is a {@link TripTransferPoint}, this method return the stop
-   * position in the trip pattern. If this transfer point is just a stop or a stop+route this
-   * method return {@link #NOT_AVAILABLE}.
+   * Utility method witch can be used in APIs to get the trip, if it exists, from a transfer point.
    */
-  default int getStopPosition() {
-    return NOT_AVAILABLE;
+  @Nullable
+  static Trip getTrip(TransferPoint point) {
+    return point.isTripTransferPoint() ? point.asTripTransferPoint().getTrip() : null;
   }
 
   /**
-   * <a href="https://developers.google.com/transit/gtfs/reference/gtfs-extensions#specificity-of-a-transfer">
-   * Specificity of a transfer
-   * </a>
+   * Utility method witch can be used in APIs to get the route, if it exists, from a transfer point.
    */
-  int getSpecificityRanking();
-
-  default boolean matches(Trip trip, int stopPos) {
-    // Note! We use "==" here since there should not be duplicate instances of trips
-    return getStopPosition() == stopPos && getTrip() == trip;
+  @Nullable
+  static Route getRoute(TransferPoint point) {
+    if(point.isTripTransferPoint()) {
+      return point.asTripTransferPoint().getTrip().getRoute();
+    }
+    if(point.isRouteStopTransferPoint()) {
+      return point.asRouteStopTransferPoint().getRoute();
+    }
+    if(point.isRouteStationTransferPoint()) {
+      return point.asRouteStationTransferPoint().getRoute();
+    }
+    return null;
   }
 }
diff --git a/src/main/java/org/opentripplanner/model/transfer/TransferPointMap.java b/src/main/java/org/opentripplanner/model/transfer/TransferPointMap.java
new file mode 100644
index 00000000000..87bb8bf1bea
--- /dev/null
+++ b/src/main/java/org/opentripplanner/model/transfer/TransferPointMap.java
@@ -0,0 +1,103 @@
+package org.opentripplanner.model.transfer;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.opentripplanner.common.model.T2;
+import org.opentripplanner.model.Route;
+import org.opentripplanner.model.Station;
+import org.opentripplanner.model.StopLocation;
+import org.opentripplanner.model.Trip;
+
+/**
+ * A map from any TransferPoint to an instances of type E. This is used to look up
+ * entities by trip and stop. The {@link TransferPoint} class only plays a role when the map is
+ * created.
+ */
+class TransferPointMap<E> {
+    private final Map<T2<Trip, Integer>, E> tripMap = new HashMap<>();
+    private final Map<T2<Route, StopLocation>, E> routeStopMap = new HashMap<>();
+    private final Map<T2<Route, Station>, E> routeStationMap = new HashMap<>();
+    private final Map<StopLocation, E> stopMap = new HashMap<>();
+    private final Map<Station, E> stationMap = new HashMap<>();
+
+    void put(TransferPoint point, E e) {
+        if(point.isTripTransferPoint()) {
+            var tp = point.asTripTransferPoint();
+            tripMap.put(tripKey(tp.getTrip(), tp.getStopPositionInPattern()), e);
+        }
+        else if(point.isRouteStopTransferPoint()) {
+            var rp = point.asRouteStopTransferPoint();
+            routeStopMap.put(routeStopKey(rp.getRoute(), rp.getStop()), e);
+        }
+        else if(point.isRouteStationTransferPoint()) {
+            var rp = point.asRouteStationTransferPoint();
+            routeStationMap.put(routeStationKey(rp.getRoute(), rp.getStation()), e);
+        }
+        else if(point.isStopTransferPoint()) {
+            stopMap.put(point.asStopTransferPoint().getStop(), e);
+        }
+        else if(point.isStationTransferPoint()) {
+            stationMap.put(point.asStationTransferPoint().getStation(), e);
+        }
+        else {
+            throw new IllegalArgumentException("Unknown TransferPoint type: " + point);
+        }
+    }
+
+    E computeIfAbsent(TransferPoint point, Supplier<E> creator) {
+        if(point.isTripTransferPoint()) {
+            var tp = point.asTripTransferPoint();
+            return tripMap.computeIfAbsent(tripKey(tp.getTrip(), tp.getStopPositionInPattern()), k -> creator.get());
+        }
+        else if(point.isRouteStopTransferPoint()) {
+            var rp = point.asRouteStopTransferPoint();
+            return routeStopMap.computeIfAbsent(routeStopKey(rp.getRoute(), rp.getStop()), k -> creator.get());
+        }
+        else if(point.isRouteStationTransferPoint()) {
+            var rp = point.asRouteStationTransferPoint();
+            return routeStationMap.computeIfAbsent(routeStationKey(rp.getRoute(), rp.getStation()), k -> creator.get());
+        }
+        else if(point.isStopTransferPoint()) {
+            var sp = point.asStopTransferPoint();
+            return stopMap.computeIfAbsent(sp.getStop(), k -> creator.get());
+        }
+        else if(point.isStationTransferPoint()) {
+            var sp = point.asStationTransferPoint();
+            return stationMap.computeIfAbsent(sp.getStation(), k -> creator.get());
+        }
+        throw new IllegalArgumentException("Unknown TransferPoint type: " + point);
+    }
+
+    /**
+     * List all elements witch matches any of the transfer points added to the map.
+     */
+    List<E> get(Trip trip, StopLocation stop, int stopPointInPattern) {
+        var list = Stream.of(
+                        tripMap.get(tripKey(trip, stopPointInPattern)),
+                        routeStopMap.get(routeStopKey(trip.getRoute(), stop)),
+                        routeStationMap.get(routeStationKey(trip.getRoute(), stop.getParentStation())),
+                        stopMap.get(stop),
+                        stationMap.get(stop.getParentStation())
+                )
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+        return list;
+    }
+
+    private static T2<Trip, Integer> tripKey(Trip trip, int stopPositionInPattern) {
+        return new T2<>(trip, stopPositionInPattern);
+    }
+
+    private static T2<Route, StopLocation> routeStopKey(Route route, StopLocation stop) {
+        return new T2<>(route, stop);
+    }
+
+    private static T2<Route, Station> routeStationKey(Route route, Station station) {
+        return new T2<>(route, station);
+    }
+}
diff --git a/src/main/java/org/opentripplanner/model/transfer/TransferService.java b/src/main/java/org/opentripplanner/model/transfer/TransferService.java
index 8e6d574c36a..8f9674e48b5 100644
--- a/src/main/java/org/opentripplanner/model/transfer/TransferService.java
+++ b/src/main/java/org/opentripplanner/model/transfer/TransferService.java
@@ -1,21 +1,16 @@
 package org.opentripplanner.model.transfer;
 
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.Multimap;
+import static java.util.Comparator.comparingInt;
+
 import java.io.Serializable;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
+import java.util.Set;
 import javax.annotation.Nullable;
-import org.opentripplanner.common.model.P2;
-import org.opentripplanner.common.model.T2;
-import org.opentripplanner.model.Stop;
 import org.opentripplanner.model.StopLocation;
 import org.opentripplanner.model.Trip;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /**
  * This class represents all transfer information in the graph. Transfers are grouped by
@@ -26,166 +21,57 @@
  */
 public class TransferService implements Serializable {
 
-    private static final Logger LOG = LoggerFactory.getLogger(TransferService.class);
-
-    /** Index of guaranteed transfers by the to/destination point. */
-    private final Multimap<TripTransferPoint, ConstrainedTransfer> constrainedTransferByToPoint;
-
-    /**
-     * Table which contains transfers between two trips/routes
-     */
-    private final Map<P2<TripTransferPoint>, ConstrainedTransfer> trip2tripTransfers;
-
-    /**
-     * Table which contains transfers between a trip/route and a stops
-     */
-    private final Map<T2<TripTransferPoint, StopLocation>, ConstrainedTransfer> trip2StopTransfers;
-
-    /**
-     * Table which contains transfers between a stop and a trip/route
-     */
-    private final Map<T2<StopLocation, TripTransferPoint>, ConstrainedTransfer> stop2TripTransfers;
+    private final List<ConstrainedTransfer> transfersList;
 
     /**
-     * Table which contains transfers between two stops
+     * A map of map may seem a bit odd, but the first map have the FROM-transfer-point
+     * as its key, while the second map have the TO-transfer-point as its key. This allows us to
+     * support all combination of (Trip, Route, Stop and Station) in total 16 possible combination
+     * of keys to the ConstrainedTransfer.
      */
-    private final Map<P2<StopLocation>, ConstrainedTransfer> stop2StopTransfers;
+    private final TransferPointMap<TransferPointMap<ConstrainedTransfer>> transfersMap;
 
     public TransferService() {
-        this.constrainedTransferByToPoint = ArrayListMultimap.create();
-        this.trip2tripTransfers = new HashMap<>();
-        this.trip2StopTransfers = new HashMap<>();
-        this.stop2TripTransfers = new HashMap<>();
-        this.stop2StopTransfers = new HashMap<>();
+        this.transfersList = new ArrayList<>();
+        this.transfersMap = new TransferPointMap<>();
     }
 
     public void addAll(Collection<ConstrainedTransfer> transfers) {
+        Set<ConstrainedTransfer> set = new HashSet<>(transfersList);
+
         for (ConstrainedTransfer transfer : transfers) {
-            add(transfer);
+            if(!set.contains(transfer)) {
+                add(transfer);
+                set.add(transfer);
+            }
         }
     }
 
     public List<ConstrainedTransfer> listAll() {
-        var list = new ArrayList<ConstrainedTransfer>();
-        list.addAll(trip2tripTransfers.values());
-        list.addAll(trip2StopTransfers.values());
-        list.addAll(stop2TripTransfers.values());
-        list.addAll(stop2StopTransfers.values());
-        return list;
-    }
-
-    public Collection<ConstrainedTransfer> listConstrainedTransfersTo(Trip toTrip, int toStopIndex) {
-        return constrainedTransferByToPoint.get(new TripTransferPoint(toTrip, toStopIndex));
+        return transfersList;
     }
 
     @Nullable
     public ConstrainedTransfer findTransfer(
-            StopLocation fromStop,
-            StopLocation toStop,
             Trip fromTrip,
-            Trip toTrip,
             int fromStopPosition,
-            int toStopPosition
-    ) {
-        var fromTripKey = new TripTransferPoint(fromTrip, fromStopPosition);
-        var toTripKey = new TripTransferPoint(toTrip, toStopPosition);
-        ConstrainedTransfer result;
-
-        // Check the highest specificity ranked transfers first (trip-2-trip)
-        result = trip2tripTransfers.get(new P2<>(fromTripKey, toTripKey));
-        if (result != null) { return result; }
-
-        // Then check the next specificity ranked transfers (trip-2-stop and stop-2-trip)
-        result = trip2StopTransfers.get(new T2<>(fromTripKey, toStop));
-        if (result != null) { return result; }
-
-        // Then check the next specificity ranked transfers (trip-2-stop and stop-2-trip)
-        result = stop2TripTransfers.get(new T2<>(fromStop, toTripKey));
-        if (result != null) { return result; }
-
-        // If no specificity ranked transfers found return stop-2-stop transfers (lowest ranking)
-        return stop2StopTransfers.get(new P2<>(fromStop, toStop));
-    }
-
-    void add(ConstrainedTransfer transfer) {
-        TransferPoint from = transfer.getFrom();
-        TransferPoint to = transfer.getTo();
-
-        addFacilitatedTransfer(transfer);
-
-        if (from instanceof TripTransferPoint) {
-            var fromTrip = (TripTransferPoint) from;
-            if (to instanceof TripTransferPoint) {
-                var key = new P2<>(fromTrip, (TripTransferPoint) to);
-                if (doAddTransferBasedOnSpecificityRanking(transfer, trip2tripTransfers.get(key))) {
-                    trip2tripTransfers.put(key, transfer);
-                }
-            }
-            else {
-                var key = new T2<>(fromTrip, to.getStop());
-                if (doAddTransferBasedOnSpecificityRanking(transfer, trip2StopTransfers.get(key))) {
-                    trip2StopTransfers.put(key, transfer);
-                }
-            }
-        }
-        else if (to instanceof TripTransferPoint) {
-            var key = new T2<>(from.getStop(), (TripTransferPoint) to);
-            if (doAddTransferBasedOnSpecificityRanking(transfer, stop2TripTransfers.get(key))) {
-                stop2TripTransfers.put(key, transfer);
-            }
-        }
-        else {
-            var key = new P2<>(from.getStop(), to.getStop());
-            if (doAddTransferBasedOnSpecificityRanking(transfer, stop2StopTransfers.get(key))) {
-                stop2StopTransfers.put(key, transfer);
-            }
-        }
-    }
-
-    /**
-     * A transfer goes from/to a stop, route* or trip. Route transfers are expanded to all trips
-     * using the special {@link RouteTransferPoint} subtype of {@link TripTransferPoint}. This
-     * expansion make sure that there can only be one match for each combination of from and to
-     * combination (from -> to):
-     * <ol>
-     *     <li> trip -> trip
-     *     <li> trip -> stop
-     *     <li> stop -> trip
-     *     <li> stop -> stop
-     * </ol>
-     * For each pair of the above combination we can drop the transfers that have a the lowest
-     * specificity-ranking, thus using maps instead of multi-maps.
-     */
-    private boolean doAddTransferBasedOnSpecificityRanking(
-            ConstrainedTransfer newTransfer,
-            ConstrainedTransfer existingTransfer
+            StopLocation fromStop,
+            Trip toTrip,
+            int toStopPosition,
+            StopLocation toStop
     ) {
-        if (existingTransfer == null) { return true; }
-
-        if (existingTransfer.getSpecificityRanking() < newTransfer.getSpecificityRanking()) {
-            return true;
-        }
-        if (existingTransfer.getSpecificityRanking() > newTransfer.getSpecificityRanking()) {
-            return false;
-        }
-        if (existingTransfer.equals(newTransfer)) {
-            return false;
-        }
-        LOG.warn(
-                "Two colliding transfers A and B with the same specificity-ranking is imported, B is "
-                        + "dropped. A={}, B={}", existingTransfer, newTransfer
-        );
-        return false;
+        return transfersMap.get(fromTrip, fromStop, fromStopPosition).stream()
+                .map(map2 -> map2.get(toTrip, toStop, toStopPosition))
+                .flatMap(Collection::stream)
+                .max(comparingInt(ConstrainedTransfer::getSpecificityRanking))
+                .orElse(null);
     }
 
-    private void addFacilitatedTransfer(ConstrainedTransfer transfer) {
-        var c = transfer.getTransferConstraint();
-        var toPoint = transfer.getTo();
+    private void add(ConstrainedTransfer transfer) {
+        var from = transfer.getFrom();
+        var to = transfer.getTo();
 
-        if(c.isFacilitated()) {
-            if(toPoint instanceof TripTransferPoint) {
-                constrainedTransferByToPoint.put((TripTransferPoint) toPoint, transfer);
-            }
-        }
+        transfersMap.computeIfAbsent(from, TransferPointMap::new).put(to, transfer);
+        transfersList.add(transfer);
     }
 }
diff --git a/src/main/java/org/opentripplanner/model/transfer/TransferType.java b/src/main/java/org/opentripplanner/model/transfer/TransferType.java
deleted file mode 100644
index af62c5ac0e3..00000000000
--- a/src/main/java/org/opentripplanner/model/transfer/TransferType.java
+++ /dev/null
@@ -1,41 +0,0 @@
-package org.opentripplanner.model.transfer;
-
-public enum TransferType {
-    /**
-     * This transfer is recommended over other transfers. The routing algorithm should prefer
-     * this transfer compared to other transfers, for example by assigning a lower weight to it.
-     */
-    RECOMMENDED(0),
-    /**
-     * This means the departing vehicle will wait for the arriving one and leave sufficient time
-     * for a rider to transfer between routes.
-     */
-    GUARANTEED(1),
-    /**
-     * This is a regular transfer that is defined in the transit data (as opposed to
-     * OpenStreetMap data). In the case that both are present, this should take precedence.
-     * Because the the duration of the transfer is given and not the distance, walk speed will
-     * have no effect on this.
-     */
-    MIN_TIME(2),
-    /**
-     * Transfers between these stops (and route/trip) is not possible (or not allowed), even if
-     * a transfer is already defined via OpenStreetMap data or in transit data.
-     */
-    FORBIDDEN(3);
-
-    TransferType(int gtfsCode) {
-                this.gtfsCode = gtfsCode;
-        }
-
-    public final int gtfsCode;
-
-    public static TransferType valueOfGtfsCode(int gtfsCode) {
-       for (TransferType value : values()) {
-           if (value.gtfsCode == gtfsCode) {
-               return value;
-           }
-       }
-       throw new IllegalArgumentException("Unknown GTFS TransferType: " + gtfsCode);
-    }
-}
diff --git a/src/main/java/org/opentripplanner/model/transfer/TripTransferPoint.java b/src/main/java/org/opentripplanner/model/transfer/TripTransferPoint.java
index 6c427aaa682..dc27085ddcd 100644
--- a/src/main/java/org/opentripplanner/model/transfer/TripTransferPoint.java
+++ b/src/main/java/org/opentripplanner/model/transfer/TripTransferPoint.java
@@ -1,62 +1,49 @@
 package org.opentripplanner.model.transfer;
 
 import java.io.Serializable;
-import java.util.Objects;
 import org.opentripplanner.model.Trip;
+import org.opentripplanner.model.base.ValueObjectToStringBuilder;
 
-public class TripTransferPoint implements TransferPoint, Serializable {
+public final class TripTransferPoint implements TransferPoint, Serializable {
 
   private static final long serialVersionUID = 1L;
 
   private final Trip trip;
-  private final int stopPosition;
+  private final int stopPositionInPattern;
 
 
-  public TripTransferPoint(Trip trip, int stopPosition) {
+  public TripTransferPoint(Trip trip, int stopPositionInPattern) {
     this.trip = trip;
-    this.stopPosition = stopPosition;
+    this.stopPositionInPattern = stopPositionInPattern;
   }
 
-  @Override
-  public final Trip getTrip() {
+  public Trip getTrip() {
     return trip;
   }
 
-  @Override
-  public final int getStopPosition() {
-    return stopPosition;
+  public int getStopPositionInPattern() {
+    return stopPositionInPattern;
   }
 
-  /**
-   * <a href="https://developers.google.com/transit/gtfs/reference/gtfs-extensions#specificity-of-a-transfer">
-   *     GTFS Specificity of a transfer
-   * </a>
-   * {@link #equals(Object)}
-   */
-  @Override
-  public int getSpecificityRanking() { return 2; }
-
   @Override
-  public String toString() {
-    return "(trip: " + trip.getId() + ", stopPos: " + stopPosition + ")";
+  public boolean appliesToAllTrips() {
+    return false;
   }
 
-  /**
-   * This equals is intentionally final and enforce equality based on the *trip* and
-   * *stop-position*. Any sub-type is equal if the trip and stop-position match, the type is not
-   * used. This allow us to create sub-types and override the {@link #getSpecificityRanking()}.
-   */
   @Override
-  public final boolean equals(Object o) {
-    if (this == o) { return true; }
-    if (!(o instanceof TripTransferPoint)) { return false; }
+  public int getSpecificityRanking() { return 4; }
 
-    TripTransferPoint that = (TripTransferPoint) o;
-    return stopPosition == that.stopPosition && trip.getId().equals(that.trip.getId());
-  }
+  @Override
+  public boolean isTripTransferPoint() { return true; }
 
   @Override
-  public final int hashCode() {
-    return Objects.hash(trip.getId(), stopPosition);
+  public String toString() {
+    return ValueObjectToStringBuilder.of()
+            .addText("<Trip ")
+            .addObj(trip.getId())
+            .addText(", stopPos ")
+            .addNum(stopPositionInPattern)
+            .addText(">")
+            .toString();
   }
 }
diff --git a/src/main/java/org/opentripplanner/netex/mapping/NetexMapper.java b/src/main/java/org/opentripplanner/netex/mapping/NetexMapper.java
index 5f9ac5e1763..be0e3fd9072 100644
--- a/src/main/java/org/opentripplanner/netex/mapping/NetexMapper.java
+++ b/src/main/java/org/opentripplanner/netex/mapping/NetexMapper.java
@@ -22,7 +22,6 @@
 import org.opentripplanner.model.StopTime;
 import org.opentripplanner.model.TransitEntity;
 import org.opentripplanner.model.Trip;
-import org.opentripplanner.model.TripPattern;
 import org.opentripplanner.model.impl.OtpTransitServiceBuilder;
 import org.opentripplanner.netex.index.api.NetexEntityIndexReadOnlyView;
 import org.opentripplanner.netex.mapping.calendar.CalendarServiceBuilder;
@@ -389,8 +388,8 @@ private void mapTripPatterns(Map<String, FeedScopedId> serviceIds) {
                 transitBuilder.getStopTimesSortedByTrip().put(it.getKey(), it.getValue());
                 transitBuilder.getTripsById().add(it.getKey());
             }
-            for (TripPattern it : result.tripPatterns) {
-                transitBuilder.getTripPatterns().put(it.getStopPattern(), it);
+            for (var it : result.tripPatterns.entries()) {
+                transitBuilder.getTripPatterns().put(it.getKey(), it.getValue());
             }
             stopTimesByNetexId.putAll(result.stopTimeByNetexId);
             groupMapper.scheduledStopPointsIndex.putAll(result.scheduledStopPointsIndex);
diff --git a/src/main/java/org/opentripplanner/netex/mapping/StationMapper.java b/src/main/java/org/opentripplanner/netex/mapping/StationMapper.java
index 1eeaf424238..90400c80932 100644
--- a/src/main/java/org/opentripplanner/netex/mapping/StationMapper.java
+++ b/src/main/java/org/opentripplanner/netex/mapping/StationMapper.java
@@ -26,7 +26,7 @@ Station map(StopPlace stopPlace) {
         stopPlace.getDescription() != null ? stopPlace.getDescription().getValue() : null,
         null,
         null,
-        TransferPriorityMapper.mapToDomain(stopPlace.getWeighting())
+        StopTransferPriorityMapper.mapToDomain(stopPlace.getWeighting())
     );
 
     if (station.getCoordinate() == null) {
diff --git a/src/main/java/org/opentripplanner/netex/mapping/TransferPriorityMapper.java b/src/main/java/org/opentripplanner/netex/mapping/StopTransferPriorityMapper.java
similarity index 95%
rename from src/main/java/org/opentripplanner/netex/mapping/TransferPriorityMapper.java
rename to src/main/java/org/opentripplanner/netex/mapping/StopTransferPriorityMapper.java
index 4a4c22af136..53a0e22b50e 100644
--- a/src/main/java/org/opentripplanner/netex/mapping/TransferPriorityMapper.java
+++ b/src/main/java/org/opentripplanner/netex/mapping/StopTransferPriorityMapper.java
@@ -4,7 +4,7 @@
 import org.opentripplanner.model.StopTransferPriority;
 import org.rutebanken.netex.model.InterchangeWeightingEnumeration;
 
-class TransferPriorityMapper {
+class StopTransferPriorityMapper {
 
     @Nullable
     static StopTransferPriority mapToDomain(InterchangeWeightingEnumeration value) {
diff --git a/src/main/java/org/opentripplanner/netex/mapping/TripPatternMapper.java b/src/main/java/org/opentripplanner/netex/mapping/TripPatternMapper.java
index cd6a9ecb9cf..9f330405136 100644
--- a/src/main/java/org/opentripplanner/netex/mapping/TripPatternMapper.java
+++ b/src/main/java/org/opentripplanner/netex/mapping/TripPatternMapper.java
@@ -188,7 +188,7 @@ TripPatternMapperResult mapTripPattern(JourneyPattern journeyPattern) {
 
         createTripTimes(trips, tripPattern);
 
-        result.tripPatterns.add(tripPattern);
+        result.tripPatterns.put(stopPattern, tripPattern);
 
         return result;
     }
diff --git a/src/main/java/org/opentripplanner/netex/mapping/TripPatternMapperResult.java b/src/main/java/org/opentripplanner/netex/mapping/TripPatternMapperResult.java
index bc18a6f1340..3106ee51ef0 100644
--- a/src/main/java/org/opentripplanner/netex/mapping/TripPatternMapperResult.java
+++ b/src/main/java/org/opentripplanner/netex/mapping/TripPatternMapperResult.java
@@ -1,10 +1,11 @@
 package org.opentripplanner.netex.mapping;
 
 import com.google.common.collect.ArrayListMultimap;
-import java.util.ArrayList;
+import com.google.common.collect.Multimap;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import org.opentripplanner.model.StopPattern;
 import org.opentripplanner.model.StopTime;
 import org.opentripplanner.model.Trip;
 import org.opentripplanner.model.TripPattern;
@@ -22,7 +23,7 @@ class TripPatternMapperResult {
 
     final Map<Trip, List<StopTime>> tripStopTimes = new HashMap<>();
 
-    final List<TripPattern> tripPatterns = new ArrayList<>();
+    final Multimap<StopPattern, TripPattern> tripPatterns = ArrayListMultimap.create();
 
     /**
      * stopTimes by the timetabled-passing-time id
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/mapping/RaptorPathToItineraryMapper.java b/src/main/java/org/opentripplanner/routing/algorithm/mapping/RaptorPathToItineraryMapper.java
index 593359484be..fbf041bd0c3 100644
--- a/src/main/java/org/opentripplanner/routing/algorithm/mapping/RaptorPathToItineraryMapper.java
+++ b/src/main/java/org/opentripplanner/routing/algorithm/mapping/RaptorPathToItineraryMapper.java
@@ -350,7 +350,7 @@ private List<StopArrival> extractIntermediateStops(TransitPathLeg<TripSchedule>
         TripSchedule tripSchedule = pathLeg.trip();
 
         for (int i = boardStopIndexInPattern + 1; i < alightStopIndexInPattern; i++) {
-            var stop = tripPattern.getStopPattern().getStops()[i];
+            var stop = tripPattern.getStop(i);
 
             Place place = mapStopToPlace(stop, i, tripSchedule.getOriginalTripTimes());
             StopArrival visit = new StopArrival(
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/FlexAccessEgressAdapter.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/FlexAccessEgressAdapter.java
index ce8c7f1b4af..a4296a54f5a 100644
--- a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/FlexAccessEgressAdapter.java
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/FlexAccessEgressAdapter.java
@@ -12,7 +12,7 @@ public FlexAccessEgressAdapter(
           FlexAccessEgress flexAccessEgress, boolean isEgress, StopIndexForRaptor stopIndex
   ) {
     super(
-        stopIndex.indexByStop.get(flexAccessEgress.stop),
+        stopIndex.indexOf(flexAccessEgress.stop),
         isEgress ? flexAccessEgress.lastState.reverse() : flexAccessEgress.lastState
     );
 
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/StopIndexForRaptor.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/StopIndexForRaptor.java
index 39bbacaca6c..a9f72d398c1 100644
--- a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/StopIndexForRaptor.java
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/StopIndexForRaptor.java
@@ -1,12 +1,12 @@
 package org.opentripplanner.routing.algorithm.raptor.transit;
 
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import org.opentripplanner.model.StopLocation;
 import org.opentripplanner.model.StopTransferPriority;
+import org.opentripplanner.model.TripPattern;
 import org.opentripplanner.routing.algorithm.raptor.transit.cost.RaptorCostConverter;
 
 /**
@@ -26,38 +26,62 @@
  * The scope of instances of this class is limited to the mapping process, the final state is
  * stored in the {@link TransitLayer}.
  */
-public class StopIndexForRaptor {
-    public final List<StopLocation> stopsByIndex;
-    public final Map<StopLocation, Integer> indexByStop = new HashMap<>();
+public final class StopIndexForRaptor {
+    private final List<StopLocation> stopsByIndex;
+    private final Map<StopLocation, Integer> indexByStop = new HashMap<>();
     public final int[] stopBoardAlightCosts;
 
     public StopIndexForRaptor(Collection<StopLocation> stops, TransitTuningParameters tuningParameters) {
-        this.stopsByIndex = new ArrayList<>(stops);
+        this.stopsByIndex = List.copyOf(stops);
         initializeIndexByStop();
         this.stopBoardAlightCosts = createStopBoardAlightCosts(stopsByIndex, tuningParameters);
     }
 
+    public StopLocation stopByIndex(int index) {
+        return stopsByIndex.get(index);
+    }
+
+    public int indexOf(StopLocation stop) {
+        return indexByStop.get(stop);
+    }
+
+    public int size() {
+        return stopsByIndex.size();
+    }
+
     /**
-     * Create map between stop and index used by Raptor to stop objects in original graph
+     * Create a list of stop indexes for a given list of stops.
      */
-    void initializeIndexByStop() {
-        for(int i = 0; i< stopsByIndex.size(); ++i) {
-            indexByStop.put(stopsByIndex.get(i), i);
+    public int[] listStopIndexesForStops(List<StopLocation> stops) {
+        int[] stopIndex = new int[stops.size()];
+
+        for (int i = 0; i < stops.size(); i++) {
+            stopIndex[i] = indexByStop.get(stops.get(i));
         }
+        return stopIndex;
     }
 
     /**
      * Create a list of stop indexes for a given list of stops.
      */
-    public int[] listStopIndexesForStops(StopLocation[] stops) {
-        int[] stopIndex = new int[stops.length];
+    public int[] listStopIndexesForPattern(TripPattern pattern) {
+        int[] stopIndex = new int[pattern.numberOfStops()];
 
-        for (int i = 0; i < stops.length; i++) {
-            stopIndex[i] = indexByStop.get(stops[i]);
+        for (int i = 0; i < pattern.numberOfStops(); i++) {
+            stopIndex[i] = indexByStop.get(pattern.getStop(i));
         }
         return stopIndex;
     }
 
+    /**
+     * Create map between stop and index used by Raptor to stop objects in original graph
+     */
+    private void initializeIndexByStop() {
+        for(int i = 0; i< stopsByIndex.size(); ++i) {
+            indexByStop.put(stopsByIndex.get(i), i);
+        }
+    }
+
     /**
      * Create static board/alight cost for Raptor to include for each stop.
      */
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/TransitLayer.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/TransitLayer.java
index 59574f974db..22f2a46071c 100644
--- a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/TransitLayer.java
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/TransitLayer.java
@@ -76,12 +76,12 @@ public TransitLayer(
   }
 
   public int getIndexByStop(Stop stop) {
-    return stopIndex.indexByStop.get(stop);
+    return stopIndex.indexOf(stop);
   }
 
   @Nullable
   public StopLocation getStopByIndex(int stop) {
-    return stop != -1 ? this.stopIndex.stopsByIndex.get(stop) : null;
+    return stop == -1 ? null : this.stopIndex.stopByIndex(stop);
   }
 
   public StopIndexForRaptor getStopIndex() {
@@ -104,7 +104,7 @@ public ZoneId getTransitDataZoneId() {
   }
 
   public int getStopCount() {
-    return stopIndex.stopsByIndex.size();
+    return stopIndex.size();
   }
 
   @Nullable
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/TripPatternWithRaptorStopIndexes.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/TripPatternWithRaptorStopIndexes.java
index c7607b8c670..4ac400cfa96 100644
--- a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/TripPatternWithRaptorStopIndexes.java
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/TripPatternWithRaptorStopIndexes.java
@@ -1,47 +1,44 @@
 package org.opentripplanner.routing.algorithm.raptor.transit;
 
-import gnu.trove.map.TIntObjectMap;
-import gnu.trove.map.hash.TIntObjectHashMap;
-import java.util.ArrayList;
-import java.util.List;
 import java.util.Objects;
 import org.opentripplanner.model.FeedScopedId;
 import org.opentripplanner.model.TransitMode;
 import org.opentripplanner.model.TripPattern;
-import org.opentripplanner.model.transfer.ConstrainedTransfer;
-import org.opentripplanner.routing.algorithm.raptor.transit.request.ConstrainedBoardingSearch;
+import org.opentripplanner.routing.algorithm.raptor.transit.constrainedtransfer.ConstrainedBoardingSearch;
+import org.opentripplanner.routing.algorithm.raptor.transit.constrainedtransfer.TransferForPattern;
+import org.opentripplanner.routing.algorithm.raptor.transit.constrainedtransfer.TransferForPatternByStopPos;
 import org.opentripplanner.transit.raptor.api.transit.RaptorConstrainedTripScheduleBoardingSearch;
 import org.opentripplanner.transit.raptor.api.transit.RaptorTripPattern;
 
 public class TripPatternWithRaptorStopIndexes {
-    private final TripPattern pattern;
 
+    private final TripPattern pattern;
     private final int[] stopIndexes;
 
     /**
-     * List of transfers TO this pattern for each stop position in pattern used by Raptor during
-     * the FORWARD search.
+     * List of transfers TO this pattern for each stop position in pattern used by Raptor during the
+     * FORWARD search.
      */
-    private final TIntObjectMap<List<ConstrainedTransfer>> constrainedTransfersForwardSearch =
-            new TIntObjectHashMap<>();
+    private final TransferForPatternByStopPos
+            constrainedTransfersForwardSearch = new TransferForPatternByStopPos();
 
     /**
      * List of transfers FROM this pattern for each stop position in pattern used by Raptor during
      * the REVERSE search.
      */
-    private final TIntObjectMap<List<ConstrainedTransfer>> constrainedTransfersReverseSearch =
-            new TIntObjectHashMap<>();
+    private final TransferForPatternByStopPos
+            constrainedTransfersReverseSearch = new TransferForPatternByStopPos();
 
 
     public TripPatternWithRaptorStopIndexes(
-        int[] stopIndexes,
-        TripPattern pattern
+            TripPattern pattern,
+            int[] stopIndexes
     ) {
-        this.stopIndexes = stopIndexes;
         this.pattern = pattern;
+        this.stopIndexes = stopIndexes;
     }
 
-    public FeedScopedId getId() { return pattern.getId(); }
+    public FeedScopedId getId() {return pattern.getId();}
 
     public TransitMode getTransitMode() {
         return pattern.getMode();
@@ -74,20 +71,19 @@ public RaptorConstrainedTripScheduleBoardingSearch<TripSchedule> constrainedTran
         return new ConstrainedBoardingSearch(false, constrainedTransfersReverseSearch);
     }
 
+    @Override
+    public int hashCode() {
+        return Objects.hash(getId());
+    }
 
     @Override
     public boolean equals(Object o) {
-        if (this == o) { return true; }
-        if (o == null || getClass() != o.getClass()) { return false; }
+        if (this == o) {return true;}
+        if (o == null || getClass() != o.getClass()) {return false;}
         TripPatternWithRaptorStopIndexes that = (TripPatternWithRaptorStopIndexes) o;
         return getId() == that.getId();
     }
 
-    @Override
-    public int hashCode() {
-        return Objects.hash(getId());
-    }
-
     @Override
     public String toString() {
         return "TripPattern{" +
@@ -96,26 +92,32 @@ public String toString() {
                 '}';
     }
 
-    /** These are public to allow the mappers to inject transfers */
-    public void addTransferConstraintsForwardSearch(ConstrainedTransfer tx) {
-        // In the Raptor search the transfer is looked up using the target
-        // trip, the trip boarded after the transfer is done for a forward search.
-        add(constrainedTransfersForwardSearch, tx, tx.getTo().getStopPosition());
+    /**
+     * This is public to allow the mappers to inject transfers
+     */
+    public void addTransferConstraintsForwardSearch(
+            int targetStopPosition,
+            TransferForPattern transferForPattern
+    ) {
+        constrainedTransfersForwardSearch.add(targetStopPosition, transferForPattern);
     }
 
-    /** These are public to allow the mappers to inject transfers */
-    public void addTransferConstraintsReverseSearch(ConstrainedTransfer tx) {
-        // In the Raptor search the transfer is looked up using the target
-        // trip. Thus, the transfer "from trip" should be used in a reverse search.
-        add(constrainedTransfersReverseSearch, tx, tx.getFrom().getStopPosition());
+    /**
+     * This is public to allow the mappers to inject transfers
+     */
+    public void addTransferConstraintsReverseSearch(
+            int targetStopPosition,
+            TransferForPattern transferForPattern
+    ) {
+        constrainedTransfersReverseSearch.add(targetStopPosition, transferForPattern);
     }
 
-    private static <T> void add(TIntObjectMap<List<T>> index, T e, int pos) {
-        var list = index.get(pos);
-        if(list == null) {
-            list = new ArrayList<>();
-            index.put(pos, list);
-        }
-        list.add(e);
+    /**
+     * This method should be called AFTER all transfers are added, and before the
+     * pattern is used in a Raptor search.
+     */
+    public void sortConstrainedTransfers() {
+        constrainedTransfersForwardSearch.sortOnSpecificityRanking();
+        constrainedTransfersReverseSearch.sortOnSpecificityRanking();
     }
 }
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/ConstrainedBoardingSearch.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/ConstrainedBoardingSearch.java
new file mode 100644
index 00000000000..810ea2099f4
--- /dev/null
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/ConstrainedBoardingSearch.java
@@ -0,0 +1,154 @@
+package org.opentripplanner.routing.algorithm.raptor.transit.constrainedtransfer;
+
+import java.util.List;
+import java.util.stream.Collectors;
+import org.opentripplanner.common.model.T2;
+import org.opentripplanner.model.Trip;
+import org.opentripplanner.model.transfer.TransferConstraint;
+import org.opentripplanner.routing.algorithm.raptor.transit.TripSchedule;
+import org.opentripplanner.transit.raptor.api.transit.RaptorConstrainedTripScheduleBoardingSearch;
+import org.opentripplanner.transit.raptor.api.transit.RaptorTimeTable;
+import org.opentripplanner.transit.raptor.api.transit.RaptorTransferConstraint;
+import org.opentripplanner.transit.raptor.api.transit.RaptorTripScheduleBoardOrAlightEvent;
+
+
+/**
+ * The responsibility of this class is to provide transfer constraints to the Raptor search
+ * for a given pattern. The instance is stateful and not thread-safe. The current stop
+ * position is checked for transfers, then the provider is asked to list all transfers
+ * between the current pattern and the source trip stop arrival. The source is the "from"
+ * point in a transfer for a forward search, and the "to" point in the reverse search.
+ */
+public final class ConstrainedBoardingSearch
+        implements RaptorConstrainedTripScheduleBoardingSearch<TripSchedule> {
+
+    /**
+     * Abort the search after looking at 5 valid boardings. In the case where this happens, one of
+     * these trips are probably a better match. We abort to avoid stepping through all trips,
+     * possibly a large number (several days).
+     */
+    private static final int ABORT_SEARCH_AFTER_N_VAILD_NORMAL_TRIPS = 5;
+
+    private static final ConstrainedBoardingSearchStrategy FORWARD_STRATEGY = new ConstrainedBoardingSearchForward();
+    private static final ConstrainedBoardingSearchStrategy REVERSE_STRATEGY = new ConstrainedBoardingSearchReverse();
+
+    /** Handle forward and reverse specific tasks */
+    private final ConstrainedBoardingSearchStrategy translator;
+
+    /**
+     * List of transfers for each stop position in pattern
+     */
+    private final TransferForPatternByStopPos transfers;
+
+    private List<TransferForPattern> currentTransfers;
+    private int currentTargetStopPos;
+
+    public ConstrainedBoardingSearch(
+            boolean forwardSearch,
+            TransferForPatternByStopPos transfers
+    ) {
+        this.transfers = transfers;
+        this.translator = forwardSearch ? FORWARD_STRATEGY : REVERSE_STRATEGY;
+    }
+
+    @Override
+    public boolean transferExist(int targetStopPos) {
+        if(transfers == null) { return false; }
+
+        // Get all guaranteed transfers for the target pattern at the target stop position
+        this.currentTransfers = transfers.get(targetStopPos);
+        this.currentTargetStopPos = targetStopPos;
+        return currentTransfers != null;
+    }
+
+    @Override
+    public RaptorTripScheduleBoardOrAlightEvent<TripSchedule> find(
+            RaptorTimeTable<TripSchedule> timetable,
+            TripSchedule sourceTripSchedule,
+            int sourceStopIndex,
+            int sourceArrivalTime
+    ) {
+        var transfers = findMatchingTransfers(sourceTripSchedule, sourceStopIndex);
+
+        if(transfers.isEmpty()) { return null; }
+
+        T2<Integer, RaptorTransferConstraint> tripInfo = findTimetableTripInfo(
+                timetable,
+                transfers,
+                currentTargetStopPos,
+                sourceArrivalTime
+        );
+
+        if(tripInfo == null) { return null; }
+
+        final int tripIndex = tripInfo.first;
+        final var transferConstraint = tripInfo.second;
+
+        var trip = timetable.getTripSchedule(tripIndex);
+        int departureTime = translator.time(trip, currentTargetStopPos);
+
+        return new ConstrainedTransferBoarding<>(
+                transferConstraint, tripIndex, trip, currentTargetStopPos, departureTime
+        );
+    }
+
+    private List<TransferForPattern> findMatchingTransfers(
+            TripSchedule tripSchedule,
+            int stopIndex
+    ) {
+        final Trip trip = tripSchedule.getOriginalTripTimes().getTrip();
+        return currentTransfers.stream()
+                .filter(t -> t.matchesSourcePoint(stopIndex, trip))
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * Find the trip to board (trip index) and the transfer constraint
+     */
+    public T2<Integer, RaptorTransferConstraint> findTimetableTripInfo(
+            RaptorTimeTable<TripSchedule> timetable,
+            List<TransferForPattern> transfers,
+            int stopPos,
+            int sourceTime
+    ) {
+        int nAllowedBoardings = 0;
+        boolean useNextNormalTrip = false;
+
+        var index = translator.scheduleIndexIterator(timetable);
+        outer:
+        while (index.hasNext()) {
+            final int i = index.next();
+            var it = timetable.getTripSchedule(i);
+
+            // Forward: boardTime, Reverse: alightTime
+            int time = translator.time(it, stopPos);
+
+            if (translator.timeIsBefore(time, sourceTime)) { continue; }
+            ++nAllowedBoardings;
+
+            var targetTrip = it.getOriginalTripTimes().getTrip();
+
+            for (TransferForPattern tx : transfers) {
+                if (tx.applyToAllTargetTrips()) {
+                    return new T2<>(i, tx.getTransferConstraint());
+                }
+                else if (tx.applyToTargetTrip(targetTrip)) {
+                    if (tx.getTransferConstraint().isNotAllowed()) {
+                        useNextNormalTrip = true;
+                        continue outer;
+                    }
+                    else {
+                        return new T2<>(i, tx.getTransferConstraint());
+                    }
+                }
+            }
+            if (useNextNormalTrip) {
+                return new T2<>(i, TransferConstraint.REGULAR_TRANSFER);
+            }
+            if(nAllowedBoardings == ABORT_SEARCH_AFTER_N_VAILD_NORMAL_TRIPS) {
+                return null;
+            }
+        }
+        return null;
+    }
+}
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/ConstrainedBoardingSearchForward.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/ConstrainedBoardingSearchForward.java
new file mode 100644
index 00000000000..bd25193ae03
--- /dev/null
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/ConstrainedBoardingSearchForward.java
@@ -0,0 +1,26 @@
+package org.opentripplanner.routing.algorithm.raptor.transit.constrainedtransfer;
+
+import org.opentripplanner.routing.algorithm.raptor.transit.TripSchedule;
+import org.opentripplanner.transit.raptor.api.transit.IntIterator;
+import org.opentripplanner.transit.raptor.api.transit.RaptorTimeTable;
+import org.opentripplanner.transit.raptor.api.transit.RaptorTripSchedule;
+import org.opentripplanner.transit.raptor.util.IntIterators;
+
+class ConstrainedBoardingSearchForward
+        implements ConstrainedBoardingSearchStrategy {
+
+    @Override
+    public int time(RaptorTripSchedule schedule, int stopPos) {
+        return schedule.departure(stopPos);
+    }
+
+    @Override
+    public boolean timeIsBefore(int time0, int time1) {
+        return time0 < time1;
+    }
+
+    @Override
+    public IntIterator scheduleIndexIterator(RaptorTimeTable<TripSchedule> timetable) {
+        return IntIterators.intIncIterator(0, timetable.numberOfTripSchedules());
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/ConstrainedBoardingSearchReverse.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/ConstrainedBoardingSearchReverse.java
new file mode 100644
index 00000000000..887b6707e4e
--- /dev/null
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/ConstrainedBoardingSearchReverse.java
@@ -0,0 +1,25 @@
+package org.opentripplanner.routing.algorithm.raptor.transit.constrainedtransfer;
+
+import org.opentripplanner.routing.algorithm.raptor.transit.TripSchedule;
+import org.opentripplanner.transit.raptor.api.transit.IntIterator;
+import org.opentripplanner.transit.raptor.api.transit.RaptorTimeTable;
+import org.opentripplanner.transit.raptor.api.transit.RaptorTripSchedule;
+import org.opentripplanner.transit.raptor.util.IntIterators;
+
+class ConstrainedBoardingSearchReverse implements ConstrainedBoardingSearchStrategy {
+
+    @Override
+    public int time(RaptorTripSchedule schedule, int stopPos) {
+        return schedule.arrival(stopPos);
+    }
+
+    @Override
+    public boolean timeIsBefore(int time0, int time1) {
+        return time0 > time1;
+    }
+
+    @Override
+    public IntIterator scheduleIndexIterator(RaptorTimeTable<TripSchedule> timetable) {
+        return IntIterators.intDecIterator(timetable.numberOfTripSchedules(), 0);
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/ConstrainedBoardingSearchStrategy.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/ConstrainedBoardingSearchStrategy.java
new file mode 100644
index 00000000000..b31dbf46a0b
--- /dev/null
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/ConstrainedBoardingSearchStrategy.java
@@ -0,0 +1,38 @@
+package org.opentripplanner.routing.algorithm.raptor.transit.constrainedtransfer;
+
+import org.opentripplanner.routing.algorithm.raptor.transit.TripSchedule;
+import org.opentripplanner.transit.raptor.api.transit.IntIterator;
+import org.opentripplanner.transit.raptor.api.transit.RaptorTimeTable;
+import org.opentripplanner.transit.raptor.api.transit.RaptorTripSchedule;
+
+
+/**
+ * Used to search forward and in reverse.
+ */
+interface ConstrainedBoardingSearchStrategy {
+
+    /**
+     * <ol>
+     * <li>In a forward search return the DEPARTURE time.
+     * <li>In a reverse search return the ARRIVAL time.
+     * </ol>
+     */
+    int time(RaptorTripSchedule schedule, int stopPos);
+
+    /**
+     * <ol>
+     * <li>In a forward search the time is before another time if it is in the PAST.
+     * <li>In a reverse search the time is before another time if it is in the FUTURE.
+     * </ol>
+     */
+    boolean timeIsBefore(int time0, int time1);
+
+    /**
+     * <ol>
+     * <li>In a forward search iterate in departure order.
+     * <li>In a reverse search iterate in reverse departure order,
+     * starting with the last trip in the schedule.
+     * </ol>
+     */
+    IntIterator scheduleIndexIterator(RaptorTimeTable<TripSchedule> timetable);
+}
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/request/ConstrainedTransferBoarding.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/ConstrainedTransferBoarding.java
similarity index 66%
rename from src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/request/ConstrainedTransferBoarding.java
rename to src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/ConstrainedTransferBoarding.java
index c73a19313e3..6022b37cf65 100644
--- a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/request/ConstrainedTransferBoarding.java
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/ConstrainedTransferBoarding.java
@@ -1,23 +1,26 @@
-package org.opentripplanner.routing.algorithm.raptor.transit.request;
+package org.opentripplanner.routing.algorithm.raptor.transit.constrainedtransfer;
 
-import org.opentripplanner.model.transfer.TransferConstraint;
+import javax.validation.constraints.NotNull;
+import org.opentripplanner.transit.raptor.api.transit.RaptorTransferConstraint;
 import org.opentripplanner.transit.raptor.api.transit.RaptorTripSchedule;
 import org.opentripplanner.transit.raptor.api.transit.RaptorTripScheduleBoardOrAlightEvent;
 
-
+/**
+ * A boarding event passed to Raptor to perform a boarding.
+ */
 public class ConstrainedTransferBoarding<T extends RaptorTripSchedule>
         implements RaptorTripScheduleBoardOrAlightEvent<T> {
 
-    private final TransferConstraint constraint;
+    private final RaptorTransferConstraint constraint;
     private final int tripIndex;
     private final T trip;
     private final int stopPositionInPattern;
     private final int time;
 
     ConstrainedTransferBoarding(
-            TransferConstraint constraint,
+            @NotNull RaptorTransferConstraint constraint,
             int tripIndex,
-            T trip,
+            @NotNull T trip,
             int stopPositionInPattern,
             int time
     ) {
@@ -32,6 +35,7 @@ public class ConstrainedTransferBoarding<T extends RaptorTripSchedule>
     public int getTripIndex() { return tripIndex; }
 
     @Override
+    @NotNull
     public T getTrip() { return trip; }
 
     @Override
@@ -41,7 +45,6 @@ public class ConstrainedTransferBoarding<T extends RaptorTripSchedule>
     public int getTime() { return time; }
 
     @Override
-    public TransferConstraint getTransferConstraint() {
-        return constraint;
-    }
+    @NotNull
+    public RaptorTransferConstraint getTransferConstraint() { return constraint; }
 }
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/TransferForPattern.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/TransferForPattern.java
new file mode 100644
index 00000000000..f86d44303e1
--- /dev/null
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/TransferForPattern.java
@@ -0,0 +1,71 @@
+package org.opentripplanner.routing.algorithm.raptor.transit.constrainedtransfer;
+
+import javax.annotation.Nullable;
+import org.opentripplanner.model.Trip;
+import org.opentripplanner.transit.raptor.api.transit.RaptorTransferConstraint;
+
+
+/**
+ * Encapsulate the information needed to identify a transfer during a Raptor search for a given
+ * pattern.
+ */
+public class TransferForPattern implements Comparable<TransferForPattern> {
+
+    /**
+     * Used to filter transfers based on the source-stop-arrival.
+     */
+    private final TransferPointMatcher sourcePoint;
+
+    /**
+     * If {@code null} the constraint apply to all trips
+     */
+    @Nullable
+    private final Trip targetTrip;
+    private final RaptorTransferConstraint transferConstraint;
+    private final int specificityRanking;
+
+    TransferForPattern(
+            TransferPointMatcher sourcePoint,
+            @Nullable Trip targetTrip,
+            int specificityRanking,
+            RaptorTransferConstraint transferConstraint
+    ) {
+        this.sourcePoint = sourcePoint;
+        this.targetTrip = targetTrip;
+        this.specificityRanking = specificityRanking;
+        this.transferConstraint = transferConstraint;
+    }
+
+    public RaptorTransferConstraint getTransferConstraint() {
+        return transferConstraint;
+    }
+
+    public boolean matchesSourcePoint(int stopIndex, Trip trip) {
+        return sourcePoint.match(stopIndex, trip);
+    }
+
+    /**
+     * A transfer either apply to all target-trips (station-, stop- and route-transfer-points) or
+     * to a specific trip (trip-transfer-point).
+     */
+    public boolean applyToAllTargetTrips() {
+        return targetTrip == null;
+    }
+
+    /**
+     * return {@code true} if this transfer apply to the specified trip, and only that trip.
+     * @see #applyToAllTargetTrips()
+     */
+    public boolean applyToTargetTrip(Trip targetTrip) {
+        return this.targetTrip == targetTrip;
+    }
+
+    /**
+     * Transfers should be sorted after the specificityRanking, this make sure the
+     * transfer with the highest ranking is used by raptor.
+     */
+    @Override
+    public int compareTo(TransferForPattern o) {
+        return o.specificityRanking - specificityRanking;
+    }
+}
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/TransferForPatternByStopPos.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/TransferForPatternByStopPos.java
new file mode 100644
index 00000000000..99fb866c156
--- /dev/null
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/TransferForPatternByStopPos.java
@@ -0,0 +1,37 @@
+package org.opentripplanner.routing.algorithm.raptor.transit.constrainedtransfer;
+
+
+import gnu.trove.map.TIntObjectMap;
+import gnu.trove.map.hash.TIntObjectHashMap;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Index to a list of transfers by the stop position in pattern
+ */
+public class TransferForPatternByStopPos {
+
+    private final TIntObjectMap<List<TransferForPattern>> transfers = new TIntObjectHashMap<>();
+
+
+    /**
+     * Sort in decreasing specificityRanking order
+     */
+    public void sortOnSpecificityRanking() {
+        transfers.forEachValue(it -> { Collections.sort(it); return true; });
+    }
+
+    public void add(int targetStopPos, TransferForPattern transfer) {
+        var c = transfers.get(targetStopPos);
+        if(c == null) {
+            c = new ArrayList<>();
+            transfers.put(targetStopPos, c);
+        }
+        c.add(transfer);
+    }
+
+    public List<TransferForPattern> get(int targetStopPos) {
+        return transfers.get(targetStopPos);
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/TransferIndexGenerator.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/TransferIndexGenerator.java
new file mode 100644
index 00000000000..2da68731dfa
--- /dev/null
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/TransferIndexGenerator.java
@@ -0,0 +1,268 @@
+package org.opentripplanner.routing.algorithm.raptor.transit.constrainedtransfer;
+
+import static org.opentripplanner.routing.algorithm.raptor.transit.constrainedtransfer.TransferPointForPatternFactory.createTransferPointForPattern;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.ToIntFunction;
+import java.util.stream.Collectors;
+import org.opentripplanner.model.Route;
+import org.opentripplanner.model.Station;
+import org.opentripplanner.model.StopLocation;
+import org.opentripplanner.model.Trip;
+import org.opentripplanner.model.TripPattern;
+import org.opentripplanner.model.transfer.ConstrainedTransfer;
+import org.opentripplanner.model.transfer.RouteStationTransferPoint;
+import org.opentripplanner.model.transfer.RouteStopTransferPoint;
+import org.opentripplanner.model.transfer.StationTransferPoint;
+import org.opentripplanner.model.transfer.StopTransferPoint;
+import org.opentripplanner.model.transfer.TransferPoint;
+import org.opentripplanner.model.transfer.TripTransferPoint;
+import org.opentripplanner.routing.algorithm.raptor.transit.StopIndexForRaptor;
+import org.opentripplanner.routing.algorithm.raptor.transit.TripPatternWithRaptorStopIndexes;
+
+public class TransferIndexGenerator {
+    private static final boolean BOARD = true;
+    private static final boolean ALIGHT = false;
+
+    private final Collection<ConstrainedTransfer> constrainedTransfers;
+    private final Map<Station, List<TripPatternWithRaptorStopIndexes>> patternsByStation = new HashMap<>();
+    private final Map<StopLocation, List<TripPatternWithRaptorStopIndexes>> patternsByStop = new HashMap<>();
+    private final Map<Route, List<TripPatternWithRaptorStopIndexes>> patternsByRoute = new HashMap<>();
+    private final Map<Trip, List<TripPatternWithRaptorStopIndexes>> patternsByTrip = new HashMap<>();
+    private final StopIndexForRaptor stopIndex;
+
+    public TransferIndexGenerator(
+            Collection<ConstrainedTransfer> constrainedTransfers,
+            Collection<TripPatternWithRaptorStopIndexes> tripPatterns,
+            StopIndexForRaptor stopIndex
+    ) {
+        this.constrainedTransfers = constrainedTransfers;
+        this.stopIndex = stopIndex;
+        setupPatternByTripIndex(tripPatterns);
+    }
+
+    public void generateTransfers() {
+        for (ConstrainedTransfer tx : constrainedTransfers) {
+            var c = tx.getTransferConstraint();
+            // Only add transfers witch have an effect on the Raptor routing here.
+            // Some transfers only have the priority set, and that is used in optimized-
+            // transfers, but not in Raptor.
+            if (!c.useInRaptorRouting()) { continue; }
+
+            findTPoints(tx.getFrom(), ALIGHT).stream()
+                    .filter(TPoint::canAlight)
+                    .forEachOrdered(fromPoint -> {
+                        for (var toPoint : findTPoints(tx.getTo(), BOARD)) {
+                            if (toPoint.canBoard() && !fromPoint.equals(toPoint)) {
+                                fromPoint.addTransferConstraints(tx, toPoint);
+                            }
+                        }
+                    });
+        }
+        sortAllTransfersByRanking();
+    }
+
+    private void sortAllTransfersByRanking() {
+        for (var patterns : patternsByRoute.values()) {
+            for (var pattern : patterns) {
+                pattern.sortConstrainedTransfers();
+            }
+        }
+    }
+
+    private void setupPatternByTripIndex(Collection<TripPatternWithRaptorStopIndexes> tripPatterns) {
+        for (TripPatternWithRaptorStopIndexes pattern : tripPatterns) {
+            TripPattern tripPattern = pattern.getPattern();
+
+            patternsByRoute
+                    .computeIfAbsent(tripPattern.getRoute(), t -> new ArrayList<>())
+                    .add(pattern);
+
+            for (Trip trip : tripPattern.getTrips()) {
+                patternsByTrip.computeIfAbsent(trip, t -> new ArrayList<>()).add(pattern);
+            }
+
+            for (StopLocation stop : tripPattern.getStops()) {
+                patternsByStop.computeIfAbsent(stop, t -> new ArrayList<>()).add(pattern);
+                Station station = stop.getParentStation();
+                if (station != null) {
+                    patternsByStation.computeIfAbsent(station, t -> new ArrayList<>()).add(pattern);
+                }
+            }
+        }
+    }
+
+    private Collection<TPoint> findTPoints(TransferPoint txPoint, boolean boarding) {
+        if (txPoint.isStationTransferPoint()) {
+            return findTPoints(txPoint.asStationTransferPoint());
+        }
+        else if (txPoint.isStopTransferPoint()) {
+            return findTPoints(txPoint.asStopTransferPoint());
+        }
+        else if (txPoint.isRouteStationTransferPoint()) {
+            return findTPoint(txPoint.asRouteStationTransferPoint(), boarding);
+        }
+        else if (txPoint.isRouteStopTransferPoint()) {
+            return findTPoint(txPoint.asRouteStopTransferPoint(), boarding);
+        }
+        else {
+            return findTPoints(txPoint.asTripTransferPoint());
+        }
+    }
+
+    private List<TPoint> findTPoints(StationTransferPoint point) {
+        var station = point.getStation();
+        var patterns = patternsByStation.get(station);
+
+        if(patterns == null) { return List.of(); }
+
+        var sourcePoint = createTransferPointForPattern(station, stopIndex);
+        var result = new ArrayList<TPoint>();
+
+        for (TripPatternWithRaptorStopIndexes pattern : patterns) {
+            var tripPattern = pattern.getPattern();
+            for (int pos = 0; pos < tripPattern.numberOfStops(); ++pos) {
+                if (point.getStation() == tripPattern.getStop(pos).getParentStation()) {
+                    result.add(new TPoint(pattern, sourcePoint, null, pos));
+                }
+            }
+        }
+        return result;
+    }
+
+    private List<TPoint> findTPoints(StopTransferPoint point) {
+        var stop = point.asStopTransferPoint().getStop();
+        var patterns = patternsByStop.get(stop);
+
+        if(patterns == null) { return List.of(); }
+
+        var sourcePoint = createTransferPointForPattern(stopIndex.indexOf(stop));
+        var result = new ArrayList<TPoint>();
+
+        for (TripPatternWithRaptorStopIndexes pattern : patterns) {
+            var p = pattern.getPattern();
+            for (int pos = 0; pos < p.numberOfStops(); ++pos) {
+                if (point.getStop() == p.getStop(pos)) {
+                    result.add(new TPoint(pattern, sourcePoint, null, pos));
+                }
+            }
+        }
+        return result;
+    }
+
+    private List<TPoint> findTPoint(RouteStationTransferPoint point, boolean boarding) {
+        return findTPointForRoute(
+                point.getRoute(),
+                boarding ? p -> p.findBoardingStopPositionInPattern(point.getStation())
+                         : p -> p.findAlightStopPositionInPattern(point.getStation())
+        );
+    }
+
+    private List<TPoint> findTPoint(RouteStopTransferPoint point, boolean boarding) {
+        return findTPointForRoute(
+                point.getRoute(),
+                boarding ? p -> p.findBoardingStopPositionInPattern(point.getStop())
+                         : p -> p.findAlightStopPositionInPattern(point.getStop())
+        );
+    }
+
+    private List<TPoint> findTPointForRoute(
+            Route route,
+            ToIntFunction<TripPattern> resolveStopPosInPattern
+    ) {
+        var patterns = patternsByRoute.get(route);
+
+        // A route should have a pattern(trip), but it does not hurt to check here
+        if(patterns == null) { return List.of(); }
+
+        var points = new ArrayList<TPoint>();
+
+        for (var pattern : patterns) {
+            int stopPosInPattern = resolveStopPosInPattern.applyAsInt(pattern.getPattern());
+            int stopIndex = pattern.stopIndex(stopPosInPattern);
+            var sourcePoint = createTransferPointForPattern(route, stopIndex);
+            points.add(new TPoint(pattern, sourcePoint, null, stopPosInPattern));
+        }
+        return points;
+    }
+
+    private List<TPoint> findTPoints(TripTransferPoint point) {
+        var trip = point.getTrip();
+        // All trips have at least one pattern, no need to chech for null here
+        var patterns = patternsByTrip.get(trip);
+        int stopPosInPattern = point.getStopPositionInPattern();
+        int stopIndex = patterns.get(0).stopIndex(stopPosInPattern);
+        var sourcePoint = createTransferPointForPattern(trip, stopIndex);
+        return patterns.stream()
+                .map(p -> new TPoint(p, sourcePoint, trip, stopPosInPattern))
+                .collect(Collectors.toList());
+    }
+
+    private static class TPoint {
+        TripPatternWithRaptorStopIndexes pattern;
+        TransferPointMatcher sourcePoint;
+        Trip trip;
+        int stopPosition;
+
+        private TPoint(
+                TripPatternWithRaptorStopIndexes pattern,
+                TransferPointMatcher sourcePoint,
+                Trip trip,
+                int stopPosition
+        ) {
+            this.pattern = pattern;
+            this.sourcePoint = sourcePoint;
+            this.trip = trip;
+            this.stopPosition = stopPosition;
+        }
+
+        boolean canBoard() {
+            // We prevent boarding at the last stop, this might be enforced by the
+            // canBoard method, but we do not trust it here.
+            int lastStopPosition = pattern.getPattern().numberOfStops() - 1;
+            return stopPosition != lastStopPosition && pattern.getPattern().canBoard(stopPosition);
+        }
+
+        boolean canAlight() {
+            // We prevent alighting at the first stop, this might be enforced by the
+            // canAlight method, but we do not trust it here.
+            return stopPosition != 0 && pattern.getPattern().canAlight(stopPosition);
+        }
+
+        void addTransferConstraints(ConstrainedTransfer tx, TPoint to) {
+            int rank = tx.getSpecificityRanking();
+            var c = tx.getTransferConstraint();
+
+            // Forward search
+            to.pattern.addTransferConstraintsForwardSearch(
+                    to.stopPosition,
+                    new TransferForPattern(sourcePoint, to.trip, rank, c)
+            );
+            // Reverse search
+            pattern.addTransferConstraintsReverseSearch(
+                    stopPosition,
+                    new TransferForPattern(to.sourcePoint, trip, rank, c)
+            );
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) {return true;}
+            if (!(o instanceof TPoint)) {return false;}
+            final TPoint tPoint = (TPoint) o;
+            return stopPosition == tPoint.stopPosition
+                    && Objects.equals(pattern, tPoint.pattern)
+                    && Objects.equals(trip, tPoint.trip);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(pattern, trip, stopPosition);
+        }
+    }
+}
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/TransferPointForPatternFactory.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/TransferPointForPatternFactory.java
new file mode 100644
index 00000000000..ebe5a75e68f
--- /dev/null
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/TransferPointForPatternFactory.java
@@ -0,0 +1,109 @@
+package org.opentripplanner.routing.algorithm.raptor.transit.constrainedtransfer;
+
+import java.util.function.IntFunction;
+import org.opentripplanner.model.Route;
+import org.opentripplanner.model.Station;
+import org.opentripplanner.model.StopLocation;
+import org.opentripplanner.model.Trip;
+import org.opentripplanner.routing.algorithm.raptor.transit.StopIndexForRaptor;
+
+
+/**
+ * This class generate TransferPoints adapted to Raptor. The internal model
+ * {@link org.opentripplanner.model.transfer.TransferPoint} can not be used by Raptor as is, so
+ * we transform them into {@link TransferPointMatcher}. For example to speed ut the search in
+ * Raptor we avoid fetching Stops from memory and instead uses a {@code stopIndex}. This index is
+ * not necessarily fixed, but generated for the
+ * {@link org.opentripplanner.routing.algorithm.raptor.transit.TransitLayer}, so we need to
+ * generate
+ */
+
+
+final class TransferPointForPatternFactory {
+
+    /** private constructor to prevent utility class from instantiation */
+    private TransferPointForPatternFactory() { /* empty */ }
+
+    static TransferPointMatcher createTransferPointForPattern(
+            Station station,
+            StopIndexForRaptor stopIndex
+    ) {
+        return new StationSP(stopIndex::stopByIndex, station);
+    }
+
+    static TransferPointMatcher createTransferPointForPattern(int stopIndex) {
+        return new StopSP(stopIndex);
+    }
+
+    static TransferPointMatcher createTransferPointForPattern(Route route, int sourceStopIndex) {
+        return new RouteSP(route, sourceStopIndex);
+    }
+
+    static TransferPointMatcher createTransferPointForPattern(Trip trip, int sourceStopIndex) {
+        return new TripSP(trip, sourceStopIndex);
+    }
+
+    private static class StationSP implements TransferPointMatcher {
+
+        // This is potentially slow, can be replaced with a set of stopIndexes for the
+        // station to improve performance - not tested
+        private final IntFunction<StopLocation> toStop;
+        private final Station station;
+
+        private StationSP(IntFunction<StopLocation> toStop, Station station) {
+            this.toStop = toStop;
+            this.station = station;
+        }
+
+        @Override
+        public boolean match(int stopIndex, Trip trip) {
+            return station == toStop.apply(stopIndex).getParentStation();
+        }
+    }
+
+    private static class StopSP implements TransferPointMatcher {
+
+        private final int stopIndex;
+
+        private StopSP(int stopIndex) {
+            this.stopIndex = stopIndex;
+        }
+
+        @Override
+        public boolean match(int stopIndex, Trip trip) {
+            return this.stopIndex == stopIndex;
+        }
+    }
+
+    private static class RouteSP implements TransferPointMatcher {
+
+        private final Route route;
+        private final int stopIndex;
+
+        private RouteSP(Route route, int stopIndex) {
+            this.route = route;
+            this.stopIndex = stopIndex;
+        }
+
+        @Override
+        public boolean match(int stopIndex, Trip trip) {
+            return this.stopIndex == stopIndex && this.route == trip.getRoute();
+        }
+    }
+
+    private static class TripSP implements TransferPointMatcher {
+
+        private final Trip trip;
+        private final int stopIndex;
+
+        private TripSP(Trip trip, int stopIndex) {
+            this.trip = trip;
+            this.stopIndex = stopIndex;
+        }
+
+        @Override
+        public boolean match(int stopIndex, Trip trip) {
+            return this.stopIndex == stopIndex && this.trip == trip;
+        }
+    }
+}
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/TransferPointMatcher.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/TransferPointMatcher.java
new file mode 100644
index 00000000000..9e81daa7d25
--- /dev/null
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/TransferPointMatcher.java
@@ -0,0 +1,10 @@
+package org.opentripplanner.routing.algorithm.raptor.transit.constrainedtransfer;
+
+import org.opentripplanner.model.Trip;
+
+/**
+ * This class is used to match a given trip and stop index.
+ */
+interface TransferPointMatcher {
+    boolean match(int stopIndex, Trip trip);
+}
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/cost/DefaultCostCalculator.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/cost/DefaultCostCalculator.java
index bda8c7263a7..8b9a1990c0d 100644
--- a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/cost/DefaultCostCalculator.java
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/cost/DefaultCostCalculator.java
@@ -68,7 +68,7 @@ public int boardingCost(
             RaptorTripSchedule trip,
             RaptorTransferConstraint transferConstraints
     ) {
-        if(transferConstraints == null) {
+        if(transferConstraints.isRegularTransfer()) {
             return boardingCostRegularTransfer(
                     firstBoarding,
                     prevArrivalTime,
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/AccessEgressMapper.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/AccessEgressMapper.java
index 882ced15f3a..897fa9c3ba0 100644
--- a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/AccessEgressMapper.java
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/AccessEgressMapper.java
@@ -1,5 +1,9 @@
 package org.opentripplanner.routing.algorithm.raptor.transit.mappers;
 
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
 import org.opentripplanner.ext.flex.FlexAccessEgress;
 import org.opentripplanner.model.Stop;
 import org.opentripplanner.routing.algorithm.raptor.transit.AccessEgress;
@@ -7,11 +11,6 @@
 import org.opentripplanner.routing.algorithm.raptor.transit.StopIndexForRaptor;
 import org.opentripplanner.routing.graphfinder.NearbyStop;
 
-import java.util.Collection;
-import java.util.List;
-import java.util.Objects;
-import java.util.stream.Collectors;
-
 public class AccessEgressMapper {
 
   private final StopIndexForRaptor stopIndex;
@@ -22,8 +21,9 @@ public AccessEgressMapper(StopIndexForRaptor stopIndex) {
 
   public AccessEgress mapNearbyStop(NearbyStop nearbyStop, boolean isEgress) {
     if (!(nearbyStop.stop instanceof Stop)) { return null; }
+
     return new AccessEgress(
-        stopIndex.indexByStop.get(nearbyStop.stop),
+        stopIndex.indexOf(nearbyStop.stop),
         isEgress ? nearbyStop.state.reverse() : nearbyStop.state
     );
   }
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/TransferIndexGenerator.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/TransferIndexGenerator.java
deleted file mode 100644
index f024ea9c8a2..00000000000
--- a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/TransferIndexGenerator.java
+++ /dev/null
@@ -1,62 +0,0 @@
-package org.opentripplanner.routing.algorithm.raptor.transit.mappers;
-
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.Map;
-import org.opentripplanner.model.Trip;
-import org.opentripplanner.model.transfer.ConstrainedTransfer;
-import org.opentripplanner.model.transfer.TransferService;
-import org.opentripplanner.routing.algorithm.raptor.transit.TripPatternWithRaptorStopIndexes;
-
-public class TransferIndexGenerator {
-    private final TransferService transferService;
-    private final Map<Trip, TripPatternWithRaptorStopIndexes> patternByTrip = new HashMap<>();
-
-    private TransferIndexGenerator(TransferService transferService) {
-        this.transferService = transferService;
-    }
-
-    public static void generateTransfers(
-            TransferService transferService,
-            Collection<TripPatternWithRaptorStopIndexes> tripPatterns
-    ) {
-        var generator = new TransferIndexGenerator(transferService);
-
-        generator.setupPatternByTripIndex(tripPatterns);
-
-        for (TripPatternWithRaptorStopIndexes pattern : tripPatterns) {
-            generator.generateTransfers(pattern);
-        }
-    }
-
-    private void setupPatternByTripIndex(Collection<TripPatternWithRaptorStopIndexes> tripPatterns) {
-        for (TripPatternWithRaptorStopIndexes pattern : tripPatterns) {
-            for (Trip trip : pattern.getPattern().getTrips()) {
-                patternByTrip.put(trip, pattern);
-            }
-        }
-    }
-
-    private void generateTransfers(TripPatternWithRaptorStopIndexes pattern) {
-        for (Trip trip : pattern.getPattern().getTrips()) {
-            int nStops = pattern.getPattern().getStops().size();
-            for (int stopPos=0; stopPos < nStops; ++stopPos) {
-                var transfers= transferService.listConstrainedTransfersTo(trip, stopPos);
-                for (ConstrainedTransfer tx : transfers) {
-                    var c = tx.getTransferConstraint();
-                    if(c.isFacilitated()) {
-                        var fromTrip = tx.getFrom().getTrip();
-                        var toTrip = tx.getTo().getTrip();
-                        if (fromTrip != null && toTrip != null) {
-                            var fromPattern = patternByTrip.get(fromTrip);
-                            if (fromPattern != null) {
-                                pattern.addTransferConstraintsForwardSearch(tx);
-                                fromPattern.addTransferConstraintsReverseSearch(tx);
-                            }
-                        }
-                    }
-                }
-            }
-        }
-    }
-}
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/TransfersMapper.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/TransfersMapper.java
index 0be15e583fc..ef5b8d1dced 100644
--- a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/TransfersMapper.java
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/TransfersMapper.java
@@ -21,14 +21,14 @@ static List<List<Transfer>> mapTransfers(
 
         List<List<Transfer>> transferByStopIndex = new ArrayList<>();
 
-        for (int i = 0; i < stopIndex.stopsByIndex.size(); ++i) {
-            var stop = stopIndex.stopsByIndex.get(i);
+        for (int i = 0; i < stopIndex.size(); ++i) {
+            var stop = stopIndex.stopByIndex(i);
             ArrayList<Transfer> list = new ArrayList<>();
             transferByStopIndex.add(list);
 
             for (PathTransfer pathTransfer : transfersByStop.get(stop)) {
                 if (pathTransfer.to instanceof Stop) {
-                    int toStopIndex = stopIndex.indexByStop.get(pathTransfer.to);
+                    int toStopIndex = stopIndex.indexOf((Stop)pathTransfer.to);
                     Transfer newTransfer;
                     if (pathTransfer.getEdges() != null) {
                         newTransfer = new Transfer(
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/TransitLayerMapper.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/TransitLayerMapper.java
index 504074741cc..c5048fa3e3f 100644
--- a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/TransitLayerMapper.java
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/TransitLayerMapper.java
@@ -23,6 +23,7 @@
 import org.opentripplanner.routing.algorithm.raptor.transit.TransitTuningParameters;
 import org.opentripplanner.routing.algorithm.raptor.transit.TripPatternForDate;
 import org.opentripplanner.routing.algorithm.raptor.transit.TripPatternWithRaptorStopIndexes;
+import org.opentripplanner.routing.algorithm.raptor.transit.constrainedtransfer.TransferIndexGenerator;
 import org.opentripplanner.routing.algorithm.raptor.transit.request.RaptorRequestTransferCache;
 import org.opentripplanner.routing.graph.Graph;
 import org.opentripplanner.routing.trippattern.TripTimes;
@@ -76,10 +77,11 @@ private TransitLayer map(TransitTuningParameters tuningParameters) {
         transferByStopIndex = mapTransfers(stopIndex, graph.transfersByStop);
 
         if(OTPFeature.TransferConstraints.isOn()) {
-            TransferIndexGenerator.generateTransfers(
-                    graph.getTransferService(),
-                    newTripPatternForOld.values()
-            );
+            new TransferIndexGenerator(
+                    graph.getTransferService().listAll(),
+                    newTripPatternForOld.values(),
+                    stopIndex
+            ).generateTransfers();
         }
 
         var transferCache = new RaptorRequestTransferCache(tuningParameters.transferCacheMaxSize());
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/TripPatternMapper.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/TripPatternMapper.java
index 6aaf89bef0a..9250f85d790 100644
--- a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/TripPatternMapper.java
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/TripPatternMapper.java
@@ -1,12 +1,11 @@
 package org.opentripplanner.routing.algorithm.raptor.transit.mappers;
 
-import org.opentripplanner.model.TripPattern;
-import org.opentripplanner.routing.algorithm.raptor.transit.StopIndexForRaptor;
-import org.opentripplanner.routing.algorithm.raptor.transit.TripPatternWithRaptorStopIndexes;
-
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
+import org.opentripplanner.model.TripPattern;
+import org.opentripplanner.routing.algorithm.raptor.transit.StopIndexForRaptor;
+import org.opentripplanner.routing.algorithm.raptor.transit.TripPatternWithRaptorStopIndexes;
 
 public class TripPatternMapper {
 
@@ -23,8 +22,8 @@ static Map<TripPattern, TripPatternWithRaptorStopIndexes> mapOldTripPatternToRap
 
         for (TripPattern oldTripPattern : oldTripPatterns) {
             TripPatternWithRaptorStopIndexes newTripPattern = new TripPatternWithRaptorStopIndexes(
-                    stopIndex.listStopIndexesForStops(oldTripPattern.getStopPattern().getStops()),
-                    oldTripPattern
+                    oldTripPattern,
+                    stopIndex.listStopIndexesForStops(oldTripPattern.getStops())
             );
             newTripPatternForOld.put(oldTripPattern, newTripPattern);
         }
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/request/ConstrainedBoardingSearch.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/request/ConstrainedBoardingSearch.java
deleted file mode 100644
index 12b562d666a..00000000000
--- a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/request/ConstrainedBoardingSearch.java
+++ /dev/null
@@ -1,199 +0,0 @@
-package org.opentripplanner.routing.algorithm.raptor.transit.request;
-
-import gnu.trove.map.TIntObjectMap;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import org.opentripplanner.common.model.T2;
-import org.opentripplanner.model.Trip;
-import org.opentripplanner.model.transfer.ConstrainedTransfer;
-import org.opentripplanner.model.transfer.TransferConstraint;
-import org.opentripplanner.model.transfer.TransferPoint;
-import org.opentripplanner.routing.algorithm.raptor.transit.TripSchedule;
-import org.opentripplanner.transit.raptor.api.transit.RaptorConstrainedTripScheduleBoardingSearch;
-import org.opentripplanner.transit.raptor.api.transit.RaptorTimeTable;
-import org.opentripplanner.transit.raptor.api.transit.RaptorTripSchedule;
-import org.opentripplanner.transit.raptor.api.transit.RaptorTripScheduleBoardOrAlightEvent;
-
-
-/**
- * The responsibility of this class is to provide transfer constraints to the Raptor search
- * for a given pattern. The instance is stateful and not thread-safe. The current stop
- * position is checked for transfers, then the provider is asked to list all transfers
- * between the current pattern and the source trip stop arrival. The source is the "from"
- * point in a transfer for a forward search, and the "to" point in the reverse search.
- */
-public final class ConstrainedBoardingSearch
-        implements RaptorConstrainedTripScheduleBoardingSearch<TripSchedule> {
-
-    private static final DirectionHelper FORWARD_HELPER = new ForwardDirectionHelper();
-    private static final DirectionHelper REVERSE_HELPER = new ReverseDirectionHelper();
-
-    private final DirectionHelper translator;
-
-    /**
-     * List of transfers for each stop position in pattern
-     */
-    private final TIntObjectMap<List<ConstrainedTransfer>> transfers;
-
-    private List<ConstrainedTransfer> currentTransfers;
-    private int currentTargetStopPos;
-
-    public ConstrainedBoardingSearch(
-            boolean forwardSearch,
-            TIntObjectMap<List<ConstrainedTransfer>> transfers
-    ) {
-        this.translator = forwardSearch ? FORWARD_HELPER : REVERSE_HELPER;
-        this.transfers = transfers;
-    }
-
-    @Override
-    public boolean transferExist(int targetStopPos) {
-        if(transfers == null) { return false; }
-
-        // Get all guaranteed transfers for the target pattern at the target stop position
-        this.currentTransfers = transfers.get(targetStopPos);
-        this.currentTargetStopPos = targetStopPos;
-        return currentTransfers != null;
-    }
-
-    @Override
-    public RaptorTripScheduleBoardOrAlightEvent<TripSchedule> find(
-            RaptorTimeTable<TripSchedule> timetable,
-            TripSchedule sourceTripSchedule,
-            int sourceStopIndex,
-            int sourceArrivalTime
-    ) {
-        final Trip sourceTrip = sourceTripSchedule.getOriginalTripTimes().getTrip();
-        final int sourceStopPos = translator.findSourceStopPosition(
-                sourceTripSchedule, sourceArrivalTime, sourceStopIndex
-        );
-
-        var list = findMatchingTransfers(sourceTrip, sourceStopPos);
-
-        if(list.isEmpty()) { return null; }
-
-        var tripInfo = translator.findTimetableTripInfo(
-                timetable,
-                list,
-                currentTargetStopPos,
-                sourceArrivalTime
-        );
-
-        if(tripInfo == null) { return null; }
-
-        final int tripIndex = tripInfo.first;
-        final TransferConstraint transferConstraint = tripInfo.second;
-
-        var trip = timetable.getTripSchedule(tripIndex);
-        int departureTime = translator.time(trip, currentTargetStopPos);
-
-        return new ConstrainedTransferBoarding<>(
-                transferConstraint, tripIndex, trip, currentTargetStopPos, departureTime
-        );
-    }
-
-    private Collection<ConstrainedTransfer> findMatchingTransfers(
-            Trip sourceTrip,
-            int sourceStopPos
-    ) {
-        var result = new ArrayList<ConstrainedTransfer>();
-        for (ConstrainedTransfer tx : currentTransfers) {
-            var sourcePoint = translator.source(tx);
-            if(sourcePoint.matches(sourceTrip, sourceStopPos)) {
-                result.add(tx);
-            }
-        }
-        return result;
-    }
-
-    private interface DirectionHelper {
-        TransferPoint source(ConstrainedTransfer tx);
-        TransferPoint target(ConstrainedTransfer tx);
-        int time(RaptorTripSchedule schedule, int stopPos);
-        int findSourceStopPosition(RaptorTripSchedule schedule, int timeLimit, int stop);
-        /** Find the trip to board (trip index) and the transfer constraint */
-        T2<Integer, TransferConstraint> findTimetableTripInfo(
-                RaptorTimeTable<TripSchedule> timetable,
-                Collection<ConstrainedTransfer> transfers,
-                int stopPos,
-                int sourceTime
-        );
-    }
-
-    private static class ForwardDirectionHelper implements DirectionHelper {
-        @Override public TransferPoint source(ConstrainedTransfer tx) { return tx.getFrom();  }
-        @Override public TransferPoint target(ConstrainedTransfer tx) { return tx.getTo(); }
-        @Override public int time(RaptorTripSchedule schedule, int stopPos) {
-            return schedule.departure(stopPos);
-        }
-        @Override
-        public int findSourceStopPosition(RaptorTripSchedule schedule, int timeLimit, int stop) {
-            return schedule.findArrivalStopPosition(timeLimit, stop);
-        }
-        @Override
-        public T2<Integer, TransferConstraint> findTimetableTripInfo(
-                RaptorTimeTable<TripSchedule> timetable,
-                Collection<ConstrainedTransfer> transfers,
-                int stopPos,
-                int sourceArrivalTime
-        ) {
-            // Abort after 6 hours
-            int maxLimit = sourceArrivalTime + 3600 * 6;
-
-            for (int i = 0; i < timetable.numberOfTripSchedules(); i++) {
-                var it = timetable.getTripSchedule(i);
-                int departureTime = it.departure(stopPos);
-                if(departureTime < sourceArrivalTime) { continue; }
-                if(departureTime > maxLimit) { return null; }
-
-                var targetTrip = it.getOriginalTripTimes().getTrip();
-
-                for (ConstrainedTransfer tx : transfers) {
-                    if(targetTrip == tx.getTo().getTrip()) {
-                        return new T2<>(i, tx.getTransferConstraint());
-                    }
-                }
-            }
-            return null;
-        }
-    }
-
-    private static class ReverseDirectionHelper implements DirectionHelper {
-        @Override public TransferPoint source(ConstrainedTransfer tx) { return tx.getTo();  }
-        @Override public TransferPoint target(ConstrainedTransfer tx) { return tx.getFrom(); }
-        @Override public int time(RaptorTripSchedule schedule, int stopPos) {
-            return schedule.arrival(stopPos);
-        }
-        @Override
-        public int findSourceStopPosition(RaptorTripSchedule schedule, int timeLimit, int stop) {
-            return schedule.findDepartureStopPosition(timeLimit, stop);
-        }
-        @Override
-        public T2<Integer, TransferConstraint> findTimetableTripInfo(
-                RaptorTimeTable<TripSchedule> timetable,
-                Collection<ConstrainedTransfer> transfers,
-                int stopPos,
-                int sourceDepartureTime
-        ) {
-            // Abort after 6 hours
-            int minLimit = sourceDepartureTime - 3600 * 6;
-
-            for (int i = 0; i < timetable.numberOfTripSchedules(); i++) {
-                var it = timetable.getTripSchedule(i);
-                int arrivalTime = it.arrival(stopPos);
-                if(arrivalTime < minLimit) { continue; }
-                if(arrivalTime > sourceDepartureTime) { return null; }
-
-                var targetTrip = it.getOriginalTripTimes().getTrip();
-
-                for (ConstrainedTransfer tx : transfers) {
-                    if(targetTrip == tx.getFrom().getTrip()) {
-                        return new T2<>(i ,tx.getTransferConstraint());
-                    }
-                }
-           }
-            return null;
-        }
-    }
-}
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/request/RaptorRoutingRequestTransitData.java b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/request/RaptorRoutingRequestTransitData.java
index 6fcda026fdb..82bc51111dd 100644
--- a/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/request/RaptorRoutingRequestTransitData.java
+++ b/src/main/java/org/opentripplanner/routing/algorithm/raptor/transit/request/RaptorRoutingRequestTransitData.java
@@ -135,12 +135,12 @@ public RaptorConstrainedTransfer findConstrainedTransfer(
               TripSchedule fromTrip, int fromStopPosition, TripSchedule toTrip, int toStopPosition
       ) {
         return transferService.findTransfer(
-                transitLayer.getStopByIndex(fromTrip.pattern().stopIndex(fromStopPosition)),
-                transitLayer.getStopByIndex(toTrip.pattern().stopIndex(toStopPosition)),
                 fromTrip.getOriginalTripTimes().getTrip(),
-                toTrip.getOriginalTripTimes().getTrip(),
                 fromStopPosition,
-                toStopPosition
+                transitLayer.getStopByIndex(fromTrip.pattern().stopIndex(fromStopPosition)),
+                toTrip.getOriginalTripTimes().getTrip(),
+                toStopPosition,
+                transitLayer.getStopByIndex(toTrip.pattern().stopIndex(toStopPosition))
         );
       }
     };
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/transferoptimization/services/TransferGenerator.java b/src/main/java/org/opentripplanner/routing/algorithm/transferoptimization/services/TransferGenerator.java
index 8cb93f2927b..7f0f3ec9121 100644
--- a/src/main/java/org/opentripplanner/routing/algorithm/transferoptimization/services/TransferGenerator.java
+++ b/src/main/java/org/opentripplanner/routing/algorithm/transferoptimization/services/TransferGenerator.java
@@ -123,6 +123,10 @@ private Collection<TripToTripTransfer<T>> transferFromSameStop(TripStopTime<T> f
     final int stop = from.stop();
     var tx = transferServiceAdaptor.findTransfer(from, toTrip, stop);
 
+    if(tx != null && tx.getTransferConstraint().isNotAllowed()) {
+      return List.of();
+    }
+
     final int earliestDepartureTime = earliestDepartureTime(
             from.time(), SAME_STOP_TRANSFER_TIME, tx
     );
@@ -154,6 +158,9 @@ private Collection<? extends TripToTripTransfer<T>> findStandardTransfers(TripSt
       int toStop = it.stop();
 
       ConstrainedTransfer tx = transferServiceAdaptor.findTransfer(from, toTrip, toStop);
+      if(tx != null && tx.getTransferConstraint().isNotAllowed()) {
+        continue;
+      }
 
       int earliestDepartureTime = earliestDepartureTime(from.time(), it.durationInSeconds(), tx);
       int toTripStopPos = toTrip.findDepartureStopPosition(earliestDepartureTime, toStop);
diff --git a/src/main/java/org/opentripplanner/routing/algorithm/transferoptimization/services/TransferServiceAdaptor.java b/src/main/java/org/opentripplanner/routing/algorithm/transferoptimization/services/TransferServiceAdaptor.java
index d597dee0d1e..8988f9a3cf6 100644
--- a/src/main/java/org/opentripplanner/routing/algorithm/transferoptimization/services/TransferServiceAdaptor.java
+++ b/src/main/java/org/opentripplanner/routing/algorithm/transferoptimization/services/TransferServiceAdaptor.java
@@ -1,7 +1,7 @@
 package org.opentripplanner.routing.algorithm.transferoptimization.services;
 
 import java.util.function.IntFunction;
-import org.opentripplanner.model.Stop;
+import javax.annotation.Nullable;
 import org.opentripplanner.model.StopLocation;
 import org.opentripplanner.model.Trip;
 import org.opentripplanner.model.transfer.ConstrainedTransfer;
@@ -51,14 +51,15 @@ public static <T extends RaptorTripSchedule> TransferServiceAdaptor<T> noop() {
     /**
      * Find transfer in the same stop for the given from location and to trip/stop.
      */
+    @Nullable
     protected ConstrainedTransfer findTransfer(TripStopTime<T> from, T toTrip, int toStop) {
         return transferService.findTransfer(
-                stop(from.stop()),
-                stop(toStop),
                 trip(from.trip()),
-                trip(toTrip),
                 from.stopPosition(),
-                toTrip.findDepartureStopPosition(from.time(), toStop)
+                stop(from.stop()),
+                trip(toTrip),
+                toTrip.findDepartureStopPosition(from.time(), toStop),
+                stop(toStop)
         );
     }
 
diff --git a/src/main/java/org/opentripplanner/routing/graphfinder/PlaceFinderTraverseVisitor.java b/src/main/java/org/opentripplanner/routing/graphfinder/PlaceFinderTraverseVisitor.java
index 8b49106a4fa..45e52221c6f 100644
--- a/src/main/java/org/opentripplanner/routing/graphfinder/PlaceFinderTraverseVisitor.java
+++ b/src/main/java/org/opentripplanner/routing/graphfinder/PlaceFinderTraverseVisitor.java
@@ -1,5 +1,11 @@
 package org.opentripplanner.routing.graphfinder;
 
+import static java.util.stream.Collectors.toList;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
 import org.opentripplanner.model.FeedScopedId;
 import org.opentripplanner.model.Stop;
 import org.opentripplanner.model.TransitMode;
@@ -7,19 +13,12 @@
 import org.opentripplanner.routing.RoutingService;
 import org.opentripplanner.routing.algorithm.astar.TraverseVisitor;
 import org.opentripplanner.routing.algorithm.astar.strategies.SkipEdgeStrategy;
-import org.opentripplanner.routing.vehicle_rental.VehicleRentalPlace;
 import org.opentripplanner.routing.core.State;
 import org.opentripplanner.routing.graph.Edge;
 import org.opentripplanner.routing.graph.Vertex;
-import org.opentripplanner.routing.vertextype.VehicleRentalStationVertex;
+import org.opentripplanner.routing.vehicle_rental.VehicleRentalPlace;
 import org.opentripplanner.routing.vertextype.TransitStopVertex;
-
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-import static java.util.stream.Collectors.toList;
+import org.opentripplanner.routing.vertextype.VehicleRentalStationVertex;
 
 /**
  * A TraverseVisitor used in finding various types of places while walking the street graph.
@@ -125,7 +124,7 @@ private void handlePatternsAtStop(Stop stop, double distance) {
           .filter(pattern -> filterByModes == null || filterByModes.contains(pattern.getMode()))
           .filter(pattern -> filterByRoutes == null
               || filterByRoutes.contains(pattern.getRoute().getId()))
-          .filter(pattern -> pattern.canBoard(pattern.getStopIndex(stop)))
+          .filter(pattern -> pattern.canBoard(stop))
           .collect(toList());
 
       for (TripPattern pattern : patterns) {
diff --git a/src/main/java/org/opentripplanner/routing/stoptimes/StopTimesHelper.java b/src/main/java/org/opentripplanner/routing/stoptimes/StopTimesHelper.java
index ff073bb5d6e..ae881af4ab0 100644
--- a/src/main/java/org/opentripplanner/routing/stoptimes/StopTimesHelper.java
+++ b/src/main/java/org/opentripplanner/routing/stoptimes/StopTimesHelper.java
@@ -1,6 +1,19 @@
 package org.opentripplanner.routing.stoptimes;
 
+import static org.opentripplanner.routing.stoptimes.ArrivalDeparture.ARRIVALS;
+import static org.opentripplanner.routing.stoptimes.ArrivalDeparture.DEPARTURES;
+import static org.opentripplanner.util.time.DateConstants.ONE_DAY_SECONDS;
+
 import com.google.common.collect.MinMaxPriorityQueue;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.List;
+import java.util.Queue;
 import org.opentripplanner.model.PickDrop;
 import org.opentripplanner.model.StopLocation;
 import org.opentripplanner.model.StopTimesInPattern;
@@ -14,20 +27,6 @@
 import org.opentripplanner.routing.core.ServiceDay;
 import org.opentripplanner.routing.trippattern.TripTimes;
 
-import java.time.Instant;
-import java.time.LocalDate;
-import java.time.ZoneId;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Comparator;
-import java.util.Date;
-import java.util.List;
-import java.util.Queue;
-
-import static org.opentripplanner.routing.stoptimes.ArrivalDeparture.ARRIVALS;
-import static org.opentripplanner.routing.stoptimes.ArrivalDeparture.DEPARTURES;
-import static org.opentripplanner.util.time.DateConstants.ONE_DAY_SECONDS;
-
 public class StopTimesHelper {
   /**
    * Fetch upcoming vehicle departures from a stop.
@@ -72,7 +71,7 @@ public static List<StopTimesInPattern> stopTimesForStop(
       dates.add(new ServiceDate(date).shift(i));
     }
 
-    ServiceDate[] serviceDates = dates.toArray(new ServiceDate[dates.size()]);
+    ServiceDate[] serviceDates = dates.toArray(new ServiceDate[0]);
 
     // Fetch all patterns, including those from realtime sources
     Collection<TripPattern> patterns = routingService.getPatternsForStop(stop, timetableSnapshot);
@@ -141,7 +140,7 @@ public static List<StopTimesInPattern> stopTimesForStop(
           .getRoute()
           .getAgency().getId());
       int sidx = 0;
-      for (var currStop : pattern.getStopPattern().getStops()) {
+      for (var currStop : pattern.getStops()) {
         if (currStop == stop) {
           if(skipByPickUpDropOff(pattern, arrivalDeparture, sidx)) continue;
           for (TripTimes t : tt.getTripTimes()) {
@@ -246,7 +245,7 @@ private static Queue<TripTimeOnDate> listTripTimeShortsForPatternAtStop(
 
       int secondsSinceMidnight = sd.secondsSinceMidnight(startTime);
       int stopIndex = 0;
-      for (var currStop : pattern.getStopPattern().getStops()) {
+      for (var currStop : pattern.getStops()) {
         if (currStop == stop) {
 
           if (skipByPickUpDropOff(pattern, arrivalDeparture, stopIndex)) { continue; }
@@ -256,7 +255,7 @@ private static Queue<TripTimeOnDate> listTripTimeShortsForPatternAtStop(
             if (!sd.serviceRunning(tripTimes.getServiceCode())) { continue; }
             if (skipByTripCancellation(tripTimes, includeCancellations)) { continue; }
             if (
-                    !includeReplaced && 
+                    !includeReplaced &&
                     isReplacedByAnotherPattern(
                             tripTimes.getTrip(), serviceDate, pattern, timetableSnapshot
                     )
@@ -309,23 +308,20 @@ private static boolean skipByTripCancellation(TripTimes tripTimes, boolean inclu
   private static boolean skipByPickUpDropOff(
       TripPattern pattern, ArrivalDeparture arrivalDeparture, int stopIndex
   ) {
-    boolean pickup = pattern.getStopPattern().getPickup(stopIndex) != PickDrop.NONE;
-    boolean dropoff = pattern.getStopPattern().getDropoff(stopIndex) != PickDrop.NONE;
-
-    if (!pickup && !dropoff)
-      return true;
-    if (!pickup && arrivalDeparture == DEPARTURES)
-      return true;
-    if (!dropoff && arrivalDeparture == ARRIVALS)
-      return true;
+    boolean noPickup = pattern.getBoardType(stopIndex).is(PickDrop.NONE);
+    boolean noDropoff = pattern.getAlightType(stopIndex).is(PickDrop.NONE);
+
+    if (noPickup && noDropoff) { return true; }
+    if (noPickup && arrivalDeparture == DEPARTURES) { return true; }
+    if (noDropoff && arrivalDeparture == ARRIVALS) { return true; }
     return false;
   }
 
   private static boolean skipByStopCancellation(
       TripPattern pattern, boolean includeCancelledTrips, int stopIndex
   ) {
-    boolean pickupCancelled = pattern.getStopPattern().getPickup(stopIndex) == PickDrop.CANCELLED;
-    boolean dropOffCancelled = pattern.getStopPattern().getDropoff(stopIndex) == PickDrop.CANCELLED;
+    boolean pickupCancelled = pattern.getBoardType(stopIndex).is(PickDrop.CANCELLED);
+    boolean dropOffCancelled = pattern.getAlightType(stopIndex).is(PickDrop.CANCELLED);
 
     return (pickupCancelled || dropOffCancelled) && !includeCancelledTrips;
   }
diff --git a/src/main/java/org/opentripplanner/transit/raptor/api/path/PathBuilderLeg.java b/src/main/java/org/opentripplanner/transit/raptor/api/path/PathBuilderLeg.java
index 5a01e7d5d01..846c9c3cf2d 100644
--- a/src/main/java/org/opentripplanner/transit/raptor/api/path/PathBuilderLeg.java
+++ b/src/main/java/org/opentripplanner/transit/raptor/api/path/PathBuilderLeg.java
@@ -8,6 +8,7 @@
 import org.opentripplanner.transit.raptor.api.transit.RaptorConstrainedTransfer;
 import org.opentripplanner.transit.raptor.api.transit.RaptorSlackProvider;
 import org.opentripplanner.transit.raptor.api.transit.RaptorTransfer;
+import org.opentripplanner.transit.raptor.api.transit.RaptorTransferConstraint;
 import org.opentripplanner.transit.raptor.api.transit.RaptorTripSchedule;
 import org.opentripplanner.transit.raptor.api.view.BoardAndAlightTime;
 import org.opentripplanner.transit.raptor.util.PathStringBuilder;
@@ -426,7 +427,9 @@ private int transitCost(CostCalculator costCalculator, RaptorSlackProvider slack
         var txBeforeLeg = prevTransit == null
                 ? null
                 : prevTransit.constrainedTransferAfterLeg();
-        var transferConstraint = txBeforeLeg == null ? null : txBeforeLeg.getTransferConstraint();
+        var transferConstraint = txBeforeLeg == null
+                ? RaptorTransferConstraint.REGULAR_TRANSFER
+                : txBeforeLeg.getTransferConstraint();
         boolean firstBoarding = prev != null && prev.isAccessWithoutRides();
 
         int boardCost = costCalculator.boardingCost(
diff --git a/src/main/java/org/opentripplanner/transit/raptor/api/transit/RaptorTransferConstraint.java b/src/main/java/org/opentripplanner/transit/raptor/api/transit/RaptorTransferConstraint.java
index 84469bde812..7bf76bd5b31 100644
--- a/src/main/java/org/opentripplanner/transit/raptor/api/transit/RaptorTransferConstraint.java
+++ b/src/main/java/org/opentripplanner/transit/raptor/api/transit/RaptorTransferConstraint.java
@@ -6,5 +6,26 @@
  * instance in a callback to the cost calculator.
  */
 public interface RaptorTransferConstraint {
-    /* This is intentionally empty */
+
+    /**
+     * A regular transfer is a transfer with no constraints.
+     */
+    RaptorTransferConstraint REGULAR_TRANSFER = new RaptorTransferConstraint() {
+        @Override public boolean isNotAllowed() { return false; }
+        @Override public boolean isRegularTransfer() { return true; }
+    };
+
+
+    /**
+     * Return {@code true} if the constrained transfer is not allowed between the two routes.
+     * Note! If a constraint only apply to specific trips, then the
+     * {@link RaptorConstrainedTripScheduleBoardingSearch} is reponsible for NOT returning the
+     * NOT-ALLOWED transfer, and finding the next ALLOWED trip.
+     */
+    boolean isNotAllowed();
+
+    /**
+     * Returns {@code true} if this is a regular transfer without any constrains.
+     */
+    boolean isRegularTransfer();
 }
\ No newline at end of file
diff --git a/src/main/java/org/opentripplanner/transit/raptor/api/transit/RaptorTripScheduleBoardOrAlightEvent.java b/src/main/java/org/opentripplanner/transit/raptor/api/transit/RaptorTripScheduleBoardOrAlightEvent.java
index eb029364fcc..ca719e731b8 100644
--- a/src/main/java/org/opentripplanner/transit/raptor/api/transit/RaptorTripScheduleBoardOrAlightEvent.java
+++ b/src/main/java/org/opentripplanner/transit/raptor/api/transit/RaptorTripScheduleBoardOrAlightEvent.java
@@ -1,7 +1,7 @@
 package org.opentripplanner.transit.raptor.api.transit;
 
 
-import javax.annotation.Nullable;
+import javax.validation.constraints.NotNull;
 
 /**
  * The purpose of the TripScheduleBoardAlight is to represent the board/alight for a
@@ -50,8 +50,10 @@ default int getBoardStopIndex() {
     int getTime();
 
     /**
-     * Return the transfer constrains for the transfer before this boarding, if it exists.
+     * Return the transfer constrains for the transfer before this boarding.
+     * If there are no transfer constraints assisiated with the boarding the
+     * {@link RaptorTransferConstraint#isRegularTransfer()} is {@code true}.
      */
-    @Nullable
+    @NotNull
     RaptorTransferConstraint getTransferConstraint();
 }
diff --git a/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/RangeRaptorWorker.java b/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/RangeRaptorWorker.java
index 6daa2e912ed..e753b2eec56 100644
--- a/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/RangeRaptorWorker.java
+++ b/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/RangeRaptorWorker.java
@@ -45,7 +45,7 @@
  *     <li>Multi-criteria pareto optimal Range Raptor (McRR)
  *     <li>Reverse search in combination with R and RR
  * </ul>
- * This version do NOT support the following features:
+ * This version does NOT support the following features:
  * <ul>
  *     <li>Frequency routes, supported by the original code using Monte Carlo methods
  *     (generating randomized schedules)
@@ -214,7 +214,7 @@ private void findAllTransitForRound() {
                 var route = routeIterator.next();
                 var pattern = route.pattern();
                 var tripSearch = createTripSearch(route.timetable());
-                var txService = enableTransferConstraints
+                var txSearch = enableTransferConstraints
                         ? calculator.transferConstraintsSearch(route) : null;
 
                 int alightSlack = slackProvider.alightSlack(pattern);
@@ -238,12 +238,12 @@ private void findAllTransitForRound() {
                         // MC Raptor have many, while RR have one boarding
                         transitWorker.forEachBoarding(stopIndex, (int prevArrivalTime) -> {
 
-                            boolean ok = boardWithConstrainedTransfer(
-                                    txService, route.timetable(), stopIndex, stopPos
+                            boolean handled = boardWithConstrainedTransfer(
+                                    txSearch, route.timetable(), stopIndex, stopPos
                             );
 
                             // Find the best trip and board [no guaranteed transfer exist]
-                            if(!ok) {
+                            if(!handled) {
                                 boardWithRegularTransfer(
                                         tripSearch, stopPos, stopIndex, prevArrivalTime, boardSlack
                                 );
@@ -280,15 +280,19 @@ private void boardWithRegularTransfer(
         }
     }
 
+    /**
+     * @return {@code true} if a constrained transfer exist to prevent the normal
+     * trip search from execution.
+     */
     private boolean boardWithConstrainedTransfer(
-            RaptorConstrainedTripScheduleBoardingSearch<T> txService,
+            RaptorConstrainedTripScheduleBoardingSearch<T> txSearch,
             RaptorTimeTable<T> targetTimetable,
             int targetStopIndex,
             int targetStopPos
     ) {
         if(!enableTransferConstraints) { return false; }
 
-        if(!txService.transferExist(targetStopPos)) { return false; }
+        if(!txSearch.transferExist(targetStopPos)) { return false; }
 
         // Get the previous transit stop arrival (transfer source)
         TransitArrival<T> sourceStopArrival = transitWorker.previousTransit(targetStopIndex);
@@ -301,15 +305,19 @@ private boolean boardWithConstrainedTransfer(
                 slackProvider.alightSlack(sourceStopArrival.trip().pattern())
         );
 
-        var result = txService.find(
+        var result = txSearch.find(
                 targetTimetable,
                 sourceStopArrival.trip(),
                 sourceStopArrival.stop(),
                 earliestBoardTime
         );
 
-        if (result == null) {
-            return false;
+        if (result == null) { return false; }
+
+        if (result.getTransferConstraint().isNotAllowed()) {
+            // We are blocking a normal trip search here by returning
+            // true without boarding the trip
+            return true;
         }
 
         this.earliestBoardTime = earliestBoardTime;
diff --git a/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/standard/StopArrivalsState.java b/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/standard/StopArrivalsState.java
index c27d5f5294e..e84bc71c41a 100644
--- a/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/standard/StopArrivalsState.java
+++ b/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/standard/StopArrivalsState.java
@@ -41,13 +41,7 @@ default void rejectNewBestTransitTime(int alightStop, int alightTime, T trip, in
     default void rejectNewBestTransferTime(int fromStop, int arrivalTime, RaptorTransfer transfer) {}
 
     @Nullable
-    default TransitArrival<T> previousTransit(int boardStopIndex) {
-        throw new IllegalStateException(
-                "The implementation of this interface is not compatible with the request" +
-                "configuration. For example the BestTimesOnlyStopArrivalsState can not be used " +
-                "with constrained transfers."
-        );
-    }
+    TransitArrival<T> previousTransit(int boardStopIndex);
 
     default Collection<Path<T>> extractPaths() { return List.of(); }
 }
\ No newline at end of file
diff --git a/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/standard/besttimes/BestTimesOnlyStopArrivalsState.java b/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/standard/besttimes/BestTimesOnlyStopArrivalsState.java
index b004a06b3ab..0cbda5d8872 100644
--- a/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/standard/besttimes/BestTimesOnlyStopArrivalsState.java
+++ b/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/standard/besttimes/BestTimesOnlyStopArrivalsState.java
@@ -3,6 +3,7 @@
 
 import org.opentripplanner.transit.raptor.api.transit.RaptorTransfer;
 import org.opentripplanner.transit.raptor.api.transit.RaptorTripSchedule;
+import org.opentripplanner.transit.raptor.api.transit.TransitArrival;
 import org.opentripplanner.transit.raptor.rangeraptor.standard.StopArrivalsState;
 
 /**
@@ -60,4 +61,13 @@ public void setNewBestTransitTime(int stop, int alightTime, T trip, int boardSto
     public void setNewBestTransferTime(int fromStop, int arrivalTime, RaptorTransfer transfer) {
         bestNumberOfTransfers.arriveAtStop(transfer.stop());
     }
+
+    @Override
+    public TransitArrival<T> previousTransit(int boardStopIndex) {
+        throw new IllegalStateException(
+                "The implementation of this interface is not compatible with the request" +
+                        "configuration. For example the BestTimesOnlyStopArrivalsState can not be used " +
+                        "with constrained transfers."
+        );
+    }
 }
diff --git a/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/transit/TripScheduleAlightSearch.java b/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/transit/TripScheduleAlightSearch.java
index 7513799fae9..88f888ae1e7 100644
--- a/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/transit/TripScheduleAlightSearch.java
+++ b/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/transit/TripScheduleAlightSearch.java
@@ -65,9 +65,10 @@ public int getStopPositionInPattern() {
         return stopPositionInPattern;
     }
 
-    @Nullable
     @Override
-    public RaptorTransferConstraint getTransferConstraint() { return null; }
+    public RaptorTransferConstraint getTransferConstraint() {
+        return RaptorTransferConstraint.REGULAR_TRANSFER;
+    }
 
 
     /* TripScheduleSearch implementation */
diff --git a/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/transit/TripScheduleBoardSearch.java b/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/transit/TripScheduleBoardSearch.java
index 9b80e89da20..578ec14de04 100644
--- a/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/transit/TripScheduleBoardSearch.java
+++ b/src/main/java/org/opentripplanner/transit/raptor/rangeraptor/transit/TripScheduleBoardSearch.java
@@ -67,9 +67,10 @@ public int getStopPositionInPattern() {
         return stopPositionInPattern;
     }
 
-    @Nullable
     @Override
-    public RaptorTransferConstraint getTransferConstraint() { return null; }
+    public RaptorTransferConstraint getTransferConstraint() {
+        return RaptorTransferConstraint.REGULAR_TRANSFER;
+    }
 
 
     /* TripScheduleSearch implementation */
diff --git a/src/test/java/org/opentripplanner/common/model/T2Test.java b/src/test/java/org/opentripplanner/common/model/T2Test.java
new file mode 100644
index 00000000000..7f5c8d0db83
--- /dev/null
+++ b/src/test/java/org/opentripplanner/common/model/T2Test.java
@@ -0,0 +1,32 @@
+package org.opentripplanner.common.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+
+import org.junit.jupiter.api.Test;
+
+class T2Test {
+
+    @Test
+    void testEquals() {
+        var subject = new T2<>("Alf", 1);
+
+        assertEquals(new T2<>("Alf", 1), subject);
+        assertEquals(new T2<>("Alf", 1).hashCode(), subject.hashCode());
+
+        // first is different
+        assertNotEquals(new T2<>("Alfi", 1), subject);
+
+        // second is different
+        assertNotEquals(new T2<>("Alf", 2), subject);
+
+        // Different types, should not fail with exception
+        assertNotEquals(new T2<>(1, "Alf"), subject);
+    }
+
+    @Test
+    void testToString() {
+        var subject = new T2<>("Alf", 1);
+        assertEquals("T2(Alf, 1)", subject.toString());
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/opentripplanner/graph_builder/linking/LinkStopToPlatformTest.java b/src/test/java/org/opentripplanner/graph_builder/linking/LinkStopToPlatformTest.java
index 323288ec050..4033d50c289 100644
--- a/src/test/java/org/opentripplanner/graph_builder/linking/LinkStopToPlatformTest.java
+++ b/src/test/java/org/opentripplanner/graph_builder/linking/LinkStopToPlatformTest.java
@@ -1,6 +1,10 @@
 package org.opentripplanner.graph_builder.linking;
 
 
+import static org.junit.Assert.assertEquals;
+
+import java.util.ArrayList;
+import java.util.List;
 import org.junit.Before;
 import org.junit.Ignore;
 import org.junit.Test;
@@ -23,14 +27,9 @@
 import org.opentripplanner.util.I18NString;
 import org.opentripplanner.util.LocalizedString;
 
-import java.util.ArrayList;
-import java.util.List;
-
-import static org.junit.Assert.assertEquals;
-
 public class LinkStopToPlatformTest {
 
-    private static GeometryFactory geometryFactory = GeometryUtils.getGeometryFactory();
+    private static final GeometryFactory geometryFactory = GeometryUtils.getGeometryFactory();
 
     private Graph graph;
 
diff --git a/src/test/java/org/opentripplanner/model/TimetableTest.java b/src/test/java/org/opentripplanner/model/TimetableTest.java
index 2c62c1323b3..d9a1c47ea2b 100644
--- a/src/test/java/org/opentripplanner/model/TimetableTest.java
+++ b/src/test/java/org/opentripplanner/model/TimetableTest.java
@@ -195,8 +195,8 @@ public void testUpdate() {
         // TODO This will not work since individual stops cannot be cancelled using GTFS updates
         //      yet
         for (int i = 0; i < tripTimes.getNumStops(); i++) {
-            assertEquals(PickDrop.CANCELLED, pattern.getStopPattern().getPickup(i) );
-            assertEquals(PickDrop.CANCELLED, pattern.getStopPattern().getDropoff(i) );
+            assertEquals(PickDrop.CANCELLED, pattern.getBoardType(i));
+            assertEquals(PickDrop.CANCELLED, pattern.getAlightType(i));
         }
 
         //---
diff --git a/src/test/java/org/opentripplanner/model/impl/OtpTransitServiceImplTest.java b/src/test/java/org/opentripplanner/model/impl/OtpTransitServiceImplTest.java
index 7d6255fc072..2da6ee9cbe5 100644
--- a/src/test/java/org/opentripplanner/model/impl/OtpTransitServiceImplTest.java
+++ b/src/test/java/org/opentripplanner/model/impl/OtpTransitServiceImplTest.java
@@ -3,15 +3,15 @@
 import static java.util.Comparator.comparing;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
-import static org.junit.Assert.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.opentripplanner.gtfs.GtfsContextBuilder.contextBuilder;
 
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
-import org.junit.BeforeClass;
-import org.junit.Test;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
 import org.opentripplanner.ConstantsForTests;
 import org.opentripplanner.gtfs.GtfsContextBuilder;
 import org.opentripplanner.model.Agency;
@@ -37,7 +37,7 @@ public class OtpTransitServiceImplTest {
     private static OtpTransitService subject;
 
 
-    @BeforeClass
+    @BeforeAll
     public static void setup() throws IOException {
         GtfsContextBuilder contextBuilder = contextBuilder(FEED_ID, ConstantsForTests.FAKE_GTFS);
         OtpTransitServiceBuilder builder = contextBuilder.getTransitBuilder();
@@ -106,16 +106,13 @@ public void testGetAllTransfers() {
                         .collect(joining("\n"))
         );
 
-        // There is 9 transfers, but because of the route to trip we get more
-        // TODO TGR - Support Route to trip expansion
         assertEquals(
-                //"Transfer{from: (route: 2, trip: 2.1, stopPos: 2), to: (route: 5, trip: 5.1, stopPos: 0), constraint: {guaranteed}}\n"
-                //+ "Transfer{from: (route: 2, trip: 2.2, stopPos: 2), to: (route: 5, trip: 5.1, stopPos: 0), constraint: {guaranteed}}\n"
-                "ConstrainedTransfer{from: (stop: K), to: (stop: L), constraint: {priority: RECOMMENDED}}\n"
-                + "ConstrainedTransfer{from: (stop: K), to: (stop: M), constraint: {priority: NOT_ALLOWED}}\n"
-                + "ConstrainedTransfer{from: (stop: L), to: (stop: K), constraint: {priority: RECOMMENDED}}\n"
-                + "ConstrainedTransfer{from: (stop: M), to: (stop: K), constraint: {priority: NOT_ALLOWED}}\n"
-                + "ConstrainedTransfer{from: (trip: 1.1, stopPos: 1), to: (trip: 2.2, stopPos: 0), constraint: {guaranteed}}",
+                "ConstrainedTransfer{from: <Route 2, stop D>, to: <Route 5, stop I>, constraint: {guaranteed}}\n"
+                + "ConstrainedTransfer{from: <Stop K>, to: <Stop L>, constraint: {priority: RECOMMENDED}}\n"
+                + "ConstrainedTransfer{from: <Stop K>, to: <Stop M>, constraint: {priority: NOT_ALLOWED}}\n"
+                + "ConstrainedTransfer{from: <Stop L>, to: <Stop K>, constraint: {priority: RECOMMENDED}}\n"
+                + "ConstrainedTransfer{from: <Stop M>, to: <Stop K>, constraint: {priority: NOT_ALLOWED}}\n"
+                + "ConstrainedTransfer{from: <Trip 1.1, stopPos 1>, to: <Trip 2.2, stopPos 0>, constraint: {guaranteed}}",
                 result
         );
     }
diff --git a/src/test/java/org/opentripplanner/model/transfer/ConstrainedTransferTest.java b/src/test/java/org/opentripplanner/model/transfer/ConstrainedTransferTest.java
index aaba82eeb26..a78d723a294 100644
--- a/src/test/java/org/opentripplanner/model/transfer/ConstrainedTransferTest.java
+++ b/src/test/java/org/opentripplanner/model/transfer/ConstrainedTransferTest.java
@@ -3,52 +3,112 @@
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_1;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_2;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_POINT_1A;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_POINT_1S;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_POINT_2B;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_POINT_2S;
+import static org.opentripplanner.model.transfer.TransferTestData.STATION_POINT;
+import static org.opentripplanner.model.transfer.TransferTestData.STOP_POINT_A;
+import static org.opentripplanner.model.transfer.TransferTestData.STOP_POINT_B;
+import static org.opentripplanner.model.transfer.TransferTestData.TRIP_11;
+import static org.opentripplanner.model.transfer.TransferTestData.TRIP_21;
+import static org.opentripplanner.model.transfer.TransferTestData.TRIP_POINT_11_1;
+import static org.opentripplanner.model.transfer.TransferTestData.TRIP_POINT_21_3;
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 
-public class ConstrainedTransferTest implements TransferTestData {
+public class ConstrainedTransferTest {
 
   private static final TransferConstraint NO_CONSTRAINS = TransferConstraint.create().build();
   private static final TransferConstraint GUARANTIED = TransferConstraint.create().guaranteed().build();
 
-  private final ConstrainedTransfer TX_A_TO_B = new ConstrainedTransfer(null, STOP_POINT_A, STOP_POINT_B, NO_CONSTRAINS);
-  private final ConstrainedTransfer TX_A_TO_R22 = new ConstrainedTransfer(null, STOP_POINT_A, ROUTE_POINT_22, NO_CONSTRAINS);
-  private final ConstrainedTransfer TX_A_TO_T23 = new ConstrainedTransfer(null, STOP_POINT_A, TRIP_POINT_23, NO_CONSTRAINS);
-  private final ConstrainedTransfer TX_R11_TO_B = new ConstrainedTransfer(null, ROUTE_POINT_11, STOP_POINT_B, NO_CONSTRAINS);
-  private final ConstrainedTransfer TX_R11_TO_R22 = new ConstrainedTransfer(null, ROUTE_POINT_11, ROUTE_POINT_22, NO_CONSTRAINS);
-  private final ConstrainedTransfer TX_T11_TO_R22 = new ConstrainedTransfer(null, TRIP_POINT_11, ROUTE_POINT_22, NO_CONSTRAINS);
-  private final ConstrainedTransfer TX_T11_TO_T22 = new ConstrainedTransfer(null, TRIP_POINT_11, TRIP_POINT_23, NO_CONSTRAINS);
+  private final ConstrainedTransfer TX_STATION_TO_STATION = noConstTx(STATION_POINT, STATION_POINT);
+  private final ConstrainedTransfer TX_STATION_TO_B = noConstTx(STATION_POINT, STOP_POINT_B);
+  private final ConstrainedTransfer TX_STATION_TO_R2B = noConstTx(STATION_POINT, ROUTE_POINT_2B);
+  private final ConstrainedTransfer TX_STATION_TO_R2S = noConstTx(STATION_POINT, ROUTE_POINT_2S);
+  private final ConstrainedTransfer TX_STATION_TO_T23 = noConstTx(STATION_POINT, TRIP_POINT_21_3);
 
-  private final ConstrainedTransfer TX_NO_CONSTRAINS = new ConstrainedTransfer(null, STOP_POINT_A, STOP_POINT_B, NO_CONSTRAINS);
-  private final ConstrainedTransfer TX_GUARANTIED = new ConstrainedTransfer(null, TRIP_POINT_11, TRIP_POINT_23, GUARANTIED);
+  private final ConstrainedTransfer TX_A_TO_STATION = noConstTx(STOP_POINT_A, STATION_POINT);
+  private final ConstrainedTransfer TX_A_TO_B = noConstTx(STOP_POINT_A, STOP_POINT_B);
+  private final ConstrainedTransfer TX_A_TO_R2B = noConstTx(STOP_POINT_A, ROUTE_POINT_2B);
+  private final ConstrainedTransfer TX_A_TO_R2S = noConstTx(STOP_POINT_A, ROUTE_POINT_2S);
+  private final ConstrainedTransfer TX_A_TO_T23 = noConstTx(STOP_POINT_A, TRIP_POINT_21_3);
+
+  private final ConstrainedTransfer TX_R1S_TO_STATION = noConstTx(ROUTE_POINT_1S, STATION_POINT);
+  private final ConstrainedTransfer TX_R1S_TO_B = noConstTx(ROUTE_POINT_1S, STOP_POINT_B);
+  private final ConstrainedTransfer TX_R1S_TO_R2B = noConstTx(ROUTE_POINT_1S, ROUTE_POINT_2B);
+  private final ConstrainedTransfer TX_R1S_TO_R2S = noConstTx(ROUTE_POINT_1S, ROUTE_POINT_2S);
+  private final ConstrainedTransfer TX_R1S_TO_T23 = noConstTx(ROUTE_POINT_1S, TRIP_POINT_21_3);
+
+  private final ConstrainedTransfer TX_R1A_TO_STATION = noConstTx(ROUTE_POINT_1A, STATION_POINT);
+  private final ConstrainedTransfer TX_R1A_TO_B = noConstTx(ROUTE_POINT_1A, STOP_POINT_B);
+  private final ConstrainedTransfer TX_R1A_TO_R2B = noConstTx(ROUTE_POINT_1A, ROUTE_POINT_2B);
+  private final ConstrainedTransfer TX_R1A_TO_R2S = noConstTx(ROUTE_POINT_1A, ROUTE_POINT_2S);
+  private final ConstrainedTransfer TX_R1A_TO_T23 = noConstTx(ROUTE_POINT_1A, TRIP_POINT_21_3);
+
+  private final ConstrainedTransfer TX_T11_TO_STATION = noConstTx(TRIP_POINT_11_1, STATION_POINT);
+  private final ConstrainedTransfer TX_T11_TO_B = noConstTx(TRIP_POINT_11_1, STOP_POINT_B);
+  private final ConstrainedTransfer TX_T11_TO_R2B = noConstTx(TRIP_POINT_11_1, ROUTE_POINT_2B);
+  private final ConstrainedTransfer TX_T11_TO_R2S = noConstTx(TRIP_POINT_11_1, ROUTE_POINT_2S);
+  private final ConstrainedTransfer TX_T11_TO_T23 = noConstTx(TRIP_POINT_11_1, TRIP_POINT_21_3);
+
+  private final ConstrainedTransfer TX_NO_CONSTRAINS = noConstTx(STOP_POINT_A, STOP_POINT_B);
+
+  private final ConstrainedTransfer TX_GUARANTIED =  new ConstrainedTransfer(
+          null, TRIP_POINT_11_1, TRIP_POINT_21_3, GUARANTIED
+  );
 
   @BeforeEach
   public void setup() {
     ROUTE_1.setShortName("L1");
     ROUTE_2.setShortName("L2");
-    TRIP_1.setRoute(ROUTE_1);
-    TRIP_2.setRoute(ROUTE_2);
-    TRIP_1.setRoute(ROUTE_1);
-    TRIP_2.setRoute(ROUTE_2);
+    TRIP_11.setRoute(ROUTE_1);
+    TRIP_21.setRoute(ROUTE_2);
+    TRIP_11.setRoute(ROUTE_1);
+    TRIP_21.setRoute(ROUTE_2);
   }
 
   @Test
   public void getSpecificityRanking() {
-    assertEquals(0, TX_A_TO_B.getSpecificityRanking());
-    assertEquals(1, TX_R11_TO_B.getSpecificityRanking());
-    assertEquals(1, TX_A_TO_R22.getSpecificityRanking());
-    assertEquals(2, TX_R11_TO_R22.getSpecificityRanking());
-    assertEquals(2, TX_A_TO_T23.getSpecificityRanking());
-    assertEquals(3, TX_T11_TO_R22.getSpecificityRanking());
-    assertEquals(4, TX_T11_TO_T22.getSpecificityRanking());
+    assertEquals(0, TX_STATION_TO_STATION.getSpecificityRanking());
+    assertEquals(10, TX_STATION_TO_B.getSpecificityRanking());
+    assertEquals(20, TX_STATION_TO_R2S.getSpecificityRanking());
+    assertEquals(30, TX_STATION_TO_R2B.getSpecificityRanking());
+    assertEquals(40, TX_STATION_TO_T23.getSpecificityRanking());
+
+    assertEquals(11, TX_A_TO_STATION.getSpecificityRanking());
+    assertEquals(21, TX_A_TO_B.getSpecificityRanking());
+    assertEquals(31, TX_A_TO_R2S.getSpecificityRanking());
+    assertEquals(41, TX_A_TO_R2B.getSpecificityRanking());
+    assertEquals(51, TX_A_TO_T23.getSpecificityRanking());
+
+    assertEquals(22, TX_R1S_TO_STATION.getSpecificityRanking());
+    assertEquals(32, TX_R1S_TO_B.getSpecificityRanking());
+    assertEquals(42, TX_R1S_TO_R2S.getSpecificityRanking());
+    assertEquals(52, TX_R1S_TO_R2B.getSpecificityRanking());
+    assertEquals(62, TX_R1S_TO_T23.getSpecificityRanking());
+
+    assertEquals(33, TX_R1A_TO_STATION.getSpecificityRanking());
+    assertEquals(43, TX_R1A_TO_B.getSpecificityRanking());
+    assertEquals(53, TX_R1A_TO_R2S.getSpecificityRanking());
+    assertEquals(63, TX_R1A_TO_R2B.getSpecificityRanking());
+    assertEquals(73, TX_R1A_TO_T23.getSpecificityRanking());
+
+    assertEquals(44, TX_T11_TO_STATION.getSpecificityRanking());
+    assertEquals(54, TX_T11_TO_B.getSpecificityRanking());
+    assertEquals(64, TX_T11_TO_R2S.getSpecificityRanking());
+    assertEquals(74, TX_T11_TO_R2B.getSpecificityRanking());
+    assertEquals(84, TX_T11_TO_T23.getSpecificityRanking());
   }
 
   @Test
   public void testOtherAccessors() {
-    assertEquals(STOP_POINT_A, TX_A_TO_R22.getFrom());
-    assertEquals(ROUTE_POINT_22, TX_A_TO_R22.getTo());
+    assertEquals(STOP_POINT_A, TX_A_TO_R2B.getFrom());
+    assertEquals(ROUTE_POINT_2B, TX_A_TO_R2B.getTo());
   }
 
   @Test
@@ -60,8 +120,12 @@ public void noConstraints() {
   @Test
   public void testToString() {
     assertEquals(
-            "ConstrainedTransfer{from: (stop: F:A), to: (stop: F:B), constraint: {no constraints}}",
+            "ConstrainedTransfer{from: <Stop F:A>, to: <Stop F:B>, constraint: {no constraints}}",
             TX_A_TO_B.toString()
     );
   }
+
+  private static ConstrainedTransfer noConstTx(TransferPoint s, TransferPoint t) {
+    return new ConstrainedTransfer(null, s, t, NO_CONSTRAINS);
+  }
 }
\ No newline at end of file
diff --git a/src/test/java/org/opentripplanner/model/transfer/TransferConstraintTest.java b/src/test/java/org/opentripplanner/model/transfer/TransferConstraintTest.java
index fd005c544f5..b2dbedfc8a0 100644
--- a/src/test/java/org/opentripplanner/model/transfer/TransferConstraintTest.java
+++ b/src/test/java/org/opentripplanner/model/transfer/TransferConstraintTest.java
@@ -8,7 +8,7 @@
 import org.junit.jupiter.api.Test;
 import org.opentripplanner.util.time.DurationUtils;
 
-public class TransferConstraintTest implements TransferTestData {
+public class TransferConstraintTest {
 
   public static final int MAX_WAIT_TIME_ONE_HOUR = DurationUtils.duration("1h");
 
@@ -16,6 +16,7 @@ public class TransferConstraintTest implements TransferTestData {
   private final TransferConstraint RECOMMENDED = TransferConstraint.create().recommended().build();
   private final TransferConstraint STAY_SEATED = TransferConstraint.create().staySeated().build();
   private final TransferConstraint GUARANTIED = TransferConstraint.create().guaranteed().build();
+  private final TransferConstraint NOT_ALLOWED = TransferConstraint.create().notAllowed().build();
   private final TransferConstraint MAX_WAIT_TIME = TransferConstraint.create()
           .guaranteed().maxWaitTime(MAX_WAIT_TIME_ONE_HOUR).build();
   private final TransferConstraint EVERYTHING = TransferConstraint.create()
@@ -44,6 +45,22 @@ public void isFacilitated() {
     assertTrue(GUARANTIED.isFacilitated());
     assertTrue(STAY_SEATED.isFacilitated());
     assertFalse(NO_CONSTRAINS.isFacilitated());
+    assertFalse(NOT_ALLOWED.isFacilitated());
+  }
+
+  @Test
+  public void useInRaptorRouting() {
+    assertTrue(GUARANTIED.useInRaptorRouting());
+    assertTrue(STAY_SEATED.useInRaptorRouting());
+    assertFalse(NO_CONSTRAINS.useInRaptorRouting());
+    assertTrue(NOT_ALLOWED.useInRaptorRouting());
+  }
+
+  @Test
+  public void isNotAllowed() {
+    assertTrue(NOT_ALLOWED.isNotAllowed());
+    assertFalse(GUARANTIED.isNotAllowed());
+    assertFalse(NO_CONSTRAINS.isNotAllowed());
   }
 
   @Test
@@ -62,12 +79,12 @@ public void cost() {
 
   @Test
   public void noConstraints() {
-    assertTrue(NO_CONSTRAINS.noConstraints());
-    assertFalse(STAY_SEATED.noConstraints());
-    assertFalse(GUARANTIED.noConstraints());
-    assertFalse(RECOMMENDED.noConstraints());
-    assertFalse(MAX_WAIT_TIME.noConstraints());
-    assertFalse(EVERYTHING.noConstraints());
+    assertTrue(NO_CONSTRAINS.isRegularTransfer());
+    assertFalse(STAY_SEATED.isRegularTransfer());
+    assertFalse(GUARANTIED.isRegularTransfer());
+    assertFalse(RECOMMENDED.isRegularTransfer());
+    assertFalse(MAX_WAIT_TIME.isRegularTransfer());
+    assertFalse(EVERYTHING.isRegularTransfer());
   }
 
   @Test
diff --git a/src/test/java/org/opentripplanner/model/transfer/TransferPointMapTest.java b/src/test/java/org/opentripplanner/model/transfer/TransferPointMapTest.java
new file mode 100644
index 00000000000..49cfc826b24
--- /dev/null
+++ b/src/test/java/org/opentripplanner/model/transfer/TransferPointMapTest.java
@@ -0,0 +1,71 @@
+package org.opentripplanner.model.transfer;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.opentripplanner.model.transfer.TransferTestData.ANY_STOP;
+import static org.opentripplanner.model.transfer.TransferTestData.POS_1;
+import static org.opentripplanner.model.transfer.TransferTestData.POS_2;
+import static org.opentripplanner.model.transfer.TransferTestData.POS_3;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_POINT_1A;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_POINT_1S;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_POINT_2B;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_POINT_2S;
+import static org.opentripplanner.model.transfer.TransferTestData.STATION;
+import static org.opentripplanner.model.transfer.TransferTestData.STATION_POINT;
+import static org.opentripplanner.model.transfer.TransferTestData.STOP_A;
+import static org.opentripplanner.model.transfer.TransferTestData.STOP_B;
+import static org.opentripplanner.model.transfer.TransferTestData.STOP_POINT_A;
+import static org.opentripplanner.model.transfer.TransferTestData.STOP_POINT_B;
+import static org.opentripplanner.model.transfer.TransferTestData.TRIP_11;
+import static org.opentripplanner.model.transfer.TransferTestData.TRIP_21;
+import static org.opentripplanner.model.transfer.TransferTestData.TRIP_POINT_11_1;
+import static org.opentripplanner.model.transfer.TransferTestData.TRIP_POINT_21_3;
+
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class TransferPointMapTest {
+    private static final int ANY_STOP_POS = 999;
+
+    final TransferPointMap<String> subject = new TransferPointMap<>();
+
+    @BeforeEach
+    void setup() {
+        STOP_A.setParentStation(STATION);
+    }
+
+    @Test
+    void addAndGetEmptyMap() {
+        assertEquals(List.of(), subject.get(TRIP_11, STOP_A, POS_1));
+    }
+
+    @Test
+    void addAndGet() {
+        subject.put(TRIP_POINT_11_1, "A");
+        subject.put(TRIP_POINT_21_3, "B");
+        subject.put(ROUTE_POINT_1A, "C");
+        subject.put(ROUTE_POINT_2B, "D");
+        subject.put(ROUTE_POINT_1S, "E");
+        subject.put(ROUTE_POINT_2S, "F");
+        subject.put(STOP_POINT_A, "G");
+        subject.put(STOP_POINT_B, "H");
+        subject.put(STATION_POINT, "I");
+
+        assertEquals(List.of("A", "C", "E", "G", "I"), subject.get(TRIP_11, STOP_A, POS_1));
+        assertEquals(List.of("F", "G", "I"), subject.get(TRIP_21, STOP_A, ANY_STOP_POS));
+        assertEquals(List.of("D", "H"), subject.get(TRIP_21, STOP_B, POS_2));
+        assertEquals(List.of("B"), subject.get(TRIP_21, ANY_STOP, POS_3));
+    }
+
+    @Test
+    void computeIfAbsent() {
+        assertEquals("A", subject.computeIfAbsent(TRIP_POINT_11_1, () -> "A"));
+        assertEquals("B", subject.computeIfAbsent(ROUTE_POINT_1A, () -> "B"));
+        assertEquals("C", subject.computeIfAbsent(STOP_POINT_B, () -> "C"));
+        assertEquals("D", subject.computeIfAbsent(STATION_POINT, () -> "D"));
+        assertEquals("E", subject.computeIfAbsent(ROUTE_POINT_1S, () -> "E"));
+
+        assertEquals(List.of("A", "B", "E", "D"), subject.get(TRIP_11, STOP_A, POS_1));
+        assertEquals(List.of("C"), subject.get(TRIP_21, STOP_B, POS_2));
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/opentripplanner/model/transfer/TransferPointTest.java b/src/test/java/org/opentripplanner/model/transfer/TransferPointTest.java
index 5f617f517cc..e5f5e654e8c 100644
--- a/src/test/java/org/opentripplanner/model/transfer/TransferPointTest.java
+++ b/src/test/java/org/opentripplanner/model/transfer/TransferPointTest.java
@@ -1,77 +1,97 @@
 package org.opentripplanner.model.transfer;
 
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotEquals;
-import static org.junit.Assert.assertNull;
-
-import org.junit.Test;
-
-public class TransferPointTest implements TransferTestData {
-
-    public static final int STOP_POSITION_1 = 1;
-    public static final int STOP_POSITION_2 = 2;
-
-    private final TransferPoint otherPoint = new RouteTransferPoint(ROUTE_2, TRIP_2, STOP_POSITION_2);
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.opentripplanner.model.transfer.TransferTestData.POS_1;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_1;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_2;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_POINT_1A;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_POINT_1S;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_POINT_2B;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_POINT_2S;
+import static org.opentripplanner.model.transfer.TransferTestData.STATION;
+import static org.opentripplanner.model.transfer.TransferTestData.STATION_POINT;
+import static org.opentripplanner.model.transfer.TransferTestData.STOP_A;
+import static org.opentripplanner.model.transfer.TransferTestData.STOP_B;
+import static org.opentripplanner.model.transfer.TransferTestData.STOP_POINT_A;
+import static org.opentripplanner.model.transfer.TransferTestData.STOP_POINT_B;
+import static org.opentripplanner.model.transfer.TransferTestData.TRIP_11;
+import static org.opentripplanner.model.transfer.TransferTestData.TRIP_POINT_11_1;
+import static org.opentripplanner.model.transfer.TransferTestData.TRIP_POINT_21_3;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+public class TransferPointTest {
 
+    @Test
+    public void getStation() {
+        assertEquals(STATION, STATION_POINT.asStationTransferPoint().getStation());
+        assertEquals(STATION, ROUTE_POINT_1S.asRouteStationTransferPoint().getStation());
+    }
 
     @Test
     public void getStop() {
-        assertEquals(STOP_A, STOP_POINT_A.getStop());
-        assertNull(TRIP_POINT_11.getStop());
-        assertNull(ROUTE_POINT_11.getStop());
+        assertEquals(STOP_A, STOP_POINT_A.asStopTransferPoint().getStop());
+        assertEquals(STOP_A, ROUTE_POINT_1A.asRouteStopTransferPoint().getStop());
+        assertEquals(STOP_B, STOP_POINT_B.asStopTransferPoint().getStop());
+        assertEquals(STOP_B, ROUTE_POINT_2B.asRouteStopTransferPoint().getStop());
+    }
+
+    @Test
+    public void getRoute() {
+        assertEquals(ROUTE_1, ROUTE_POINT_1S.asRouteStationTransferPoint().getRoute());
+        assertEquals(ROUTE_1, ROUTE_POINT_1A.asRouteStopTransferPoint().getRoute());
+        assertEquals(ROUTE_2, ROUTE_POINT_2S.asRouteStationTransferPoint().getRoute());
+        assertEquals(ROUTE_2, ROUTE_POINT_2B.asRouteStopTransferPoint().getRoute());
     }
 
     @Test
     public void getTrip() {
-        assertNull(STOP_POINT_A.getTrip());
-        assertEquals(TRIP_1, TRIP_POINT_11.getTrip());
-        assertEquals(TRIP_1, ROUTE_POINT_11.getTrip());
+        assertEquals(TRIP_11, TRIP_POINT_11_1.asTripTransferPoint().getTrip());
     }
 
     @Test
     public void getStopPosition() {
-        assertEquals(TransferPoint.NOT_AVAILABLE, STOP_POINT_A.getStopPosition());
-        assertEquals(STOP_POSITION_1, TRIP_POINT_11.getStopPosition());
-        assertEquals(STOP_POSITION_1, ROUTE_POINT_11.getStopPosition());
+        assertEquals(POS_1, TRIP_POINT_11_1.asTripTransferPoint().getStopPositionInPattern());
     }
 
     @Test
     public void getSpecificityRanking() {
-        assertEquals(0, STOP_POINT_A.getSpecificityRanking());
-        assertEquals(1, ROUTE_POINT_11.getSpecificityRanking());
-        assertEquals(2, TRIP_POINT_11.getSpecificityRanking());
+        assertEquals(0, STATION_POINT.getSpecificityRanking());
+        assertEquals(1, STOP_POINT_A.getSpecificityRanking());
+        assertEquals(2, ROUTE_POINT_1S.getSpecificityRanking());
+        assertEquals(3, ROUTE_POINT_1A.getSpecificityRanking());
+        assertEquals(4, TRIP_POINT_11_1.getSpecificityRanking());
     }
 
     @Test
-    public void equalsAndHashCode() {
-        // A STOP_POINT_A should never match a route or trip point
-        assertNotEquals(STOP_POINT_A, ROUTE_POINT_11);
-        assertNotEquals(STOP_POINT_A, TRIP_POINT_11);
-        assertNotEquals(ROUTE_POINT_11, STOP_POINT_A);
-        assertNotEquals(TRIP_POINT_11, STOP_POINT_A);
-
-        assertNotEquals(STOP_POINT_A.hashCode(), ROUTE_POINT_11.hashCode());
-        assertNotEquals(STOP_POINT_A.hashCode(), TRIP_POINT_11.hashCode());
-        assertNotEquals(ROUTE_POINT_11.hashCode(), STOP_POINT_A.hashCode());
-        assertNotEquals(TRIP_POINT_11.hashCode(), STOP_POINT_A.hashCode());
-
-        // If the trip and stopPosition is the same then trip and route point should match
-        assertEquals(TRIP_POINT_11, ROUTE_POINT_11);
-        assertEquals(ROUTE_POINT_11, TRIP_POINT_11);
-
-        assertEquals(TRIP_POINT_11.hashCode(), ROUTE_POINT_11.hashCode());
-        assertEquals(ROUTE_POINT_11.hashCode(), TRIP_POINT_11.hashCode());
+    public void isNnnTransferPoint() {
+        List.of(STATION_POINT, STOP_POINT_A, ROUTE_POINT_1A, ROUTE_POINT_1S, TRIP_POINT_11_1).forEach( p -> {
+            assertEquals(p == STATION_POINT, p.isStationTransferPoint());
+            assertEquals(p == STOP_POINT_A, p.isStopTransferPoint());
+            assertEquals(p == ROUTE_POINT_1A, p.isRouteStopTransferPoint());
+            assertEquals(p == ROUTE_POINT_1S, p.isRouteStationTransferPoint());
+            assertEquals(p == TRIP_POINT_11_1, p.isTripTransferPoint());
+        });
+    }
 
-        assertNotEquals(TRIP_POINT_11, otherPoint);
-        assertNotEquals(ROUTE_POINT_11, otherPoint);
-        assertNotEquals(TRIP_POINT_11.hashCode(), otherPoint.hashCode());
-        assertNotEquals(ROUTE_POINT_11.hashCode(), otherPoint.hashCode());
+    @Test
+    public void applyToAllTrips() {
+        assertTrue(STATION_POINT.appliesToAllTrips());
+        assertTrue(STOP_POINT_A.appliesToAllTrips());
+        assertTrue(ROUTE_POINT_1A.appliesToAllTrips());
+        assertTrue(ROUTE_POINT_1S.appliesToAllTrips());
+        assertFalse(TRIP_POINT_11_1.appliesToAllTrips());
     }
 
     @Test
     public void testToString() {
-        assertEquals("(stop: F:A)", STOP_POINT_A.toString());
-        assertEquals("(route: R:1, trip: T:1, stopPos: 1)", ROUTE_POINT_11.toString());
-        assertEquals("(trip: T:1, stopPos: 1)", TRIP_POINT_11.toString());
+        assertEquals("<Station F:Central Station>", STATION_POINT.toString());
+        assertEquals("<Stop F:A>", STOP_POINT_A.toString());
+        assertEquals("<Route F:1, stop F:A>", ROUTE_POINT_1A.toString());
+        assertEquals("<Route F:1, station F:Central Station>", ROUTE_POINT_1S.toString());
+        assertEquals("<Trip F:21, stopPos 3>", TRIP_POINT_21_3.toString());
     }
 }
\ No newline at end of file
diff --git a/src/test/java/org/opentripplanner/model/transfer/TransferServiceTest.java b/src/test/java/org/opentripplanner/model/transfer/TransferServiceTest.java
index 9fd1ba7ffc8..e44b5f3008f 100644
--- a/src/test/java/org/opentripplanner/model/transfer/TransferServiceTest.java
+++ b/src/test/java/org/opentripplanner/model/transfer/TransferServiceTest.java
@@ -1,43 +1,85 @@
 package org.opentripplanner.model.transfer;
 
-import static org.junit.Assert.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.opentripplanner.model.transfer.TransferTestData.ANY_POS;
+import static org.opentripplanner.model.transfer.TransferTestData.ANY_TRIP;
+import static org.opentripplanner.model.transfer.TransferTestData.POS_1;
+import static org.opentripplanner.model.transfer.TransferTestData.POS_3;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_POINT_1A;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_POINT_1S;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_POINT_2B;
+import static org.opentripplanner.model.transfer.TransferTestData.ROUTE_POINT_2S;
+import static org.opentripplanner.model.transfer.TransferTestData.STATION;
+import static org.opentripplanner.model.transfer.TransferTestData.STATION_POINT;
+import static org.opentripplanner.model.transfer.TransferTestData.STOP_A;
+import static org.opentripplanner.model.transfer.TransferTestData.STOP_B;
+import static org.opentripplanner.model.transfer.TransferTestData.STOP_POINT_A;
+import static org.opentripplanner.model.transfer.TransferTestData.STOP_POINT_B;
+import static org.opentripplanner.model.transfer.TransferTestData.STOP_S;
+import static org.opentripplanner.model.transfer.TransferTestData.TRIP_11;
+import static org.opentripplanner.model.transfer.TransferTestData.TRIP_12;
+import static org.opentripplanner.model.transfer.TransferTestData.TRIP_21;
+import static org.opentripplanner.model.transfer.TransferTestData.TRIP_POINT_11_1;
+import static org.opentripplanner.model.transfer.TransferTestData.TRIP_POINT_21_3;
 
 import java.util.List;
-import org.junit.Test;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
 
-public class TransferServiceTest implements TransferTestData {
+public class TransferServiceTest {
 
     private final TransferService subject = new TransferService();
 
+    @BeforeEach
+    public void setup() {
+        STOP_A.setParentStation(STATION);
+    }
 
     @Test
-    public void addOneTransferForEachCombinationOfFromToTypesAndRetriveEachOfThem() {
-        // Given:
-        var A = transfer(STOP_POINT_A, STOP_POINT_B);
-        var B = transfer(STOP_POINT_A, TRIP_POINT_23);
-        var C = transfer(ROUTE_POINT_11, STOP_POINT_B);
-        var D = transfer(TRIP_POINT_11, ROUTE_POINT_22);
-        var E = transfer(TRIP_POINT_11, TRIP_POINT_23);
+    public void findTransfer() {
+        // Given:                                                               // Ranking
+        var A = transfer(TRIP_POINT_11_1, TRIP_POINT_21_3);  // 84
+        var B = transfer(TRIP_POINT_11_1, ROUTE_POINT_2B);   // 74
+        var C = transfer(TRIP_POINT_11_1, ROUTE_POINT_2S);   // 64
+        var D = transfer(STOP_POINT_A, TRIP_POINT_21_3);     // 51
+        var E = transfer(ROUTE_POINT_1A, STOP_POINT_B);      // 43
+        var F = transfer(ROUTE_POINT_1S, STOP_POINT_B);      // 32
+        var G = transfer(STATION_POINT, ROUTE_POINT_2B);     // 30
+        var H = transfer(STATION_POINT, ROUTE_POINT_2S);     // 20
+        var I = transfer(STATION_POINT, STATION_POINT);      // 11
 
         // When: all transfers is added to service
-        subject.addAll(List.of(A, B, C, D, E));
+        subject.addAll(List.of(A, B, C, D, E, F, G, H, I));
+
+        // Then:
+
+        // Find the most specific transfer TRIP to TRIP
+        // Trip and stop position must match, stops are ignored
+        assertEquals(A, subject.findTransfer(TRIP_11, POS_1, STOP_A, TRIP_21, POS_3, STOP_B));
+
+        // Find the TRIP to ROUTE+STOP transfer when TO stop position does not match
+        assertEquals(B, subject.findTransfer(TRIP_11, POS_1, STOP_A, TRIP_21, ANY_POS, STOP_B));
+
+        // Find the TRIP to ROUTE+STATION transfer when TO stop does not match
+        assertEquals(C, subject.findTransfer(TRIP_11, POS_1, STOP_A, TRIP_21, ANY_POS, STOP_S));
 
-        /* THEN */
+        // Find transfer: STOP to TRIP when FROM trip does not match
+        assertEquals(D, subject.findTransfer(TRIP_12, POS_1, STOP_A, TRIP_21, POS_3, STOP_B));
 
-        // Find the most specific transfer, Trip and stop position match - stops is ignored
-        assertEquals(D, subject.findTransfer(STOP_A, STOP_B, TRIP_1, TRIP_2, 1, 2));
+        // Find the ROUTE+STOP to STOP transfer when FROM trip and TO stopPos do not match
+        assertEquals(E, subject.findTransfer(TRIP_12, POS_1, STOP_A, TRIP_21, ANY_POS, STOP_B));
 
-        // Find the another specific transfer with the stop position changed
-        assertEquals(E, subject.findTransfer(STOP_A, STOP_B, TRIP_1, TRIP_2, 1, 3));
+        // Find the ROUTE+STATION to STOP transfer when FROM stop position does not match
+        assertEquals(F, subject.findTransfer(TRIP_11, ANY_POS, STOP_S, TRIP_21, POS_3, STOP_B));
 
-        // Find the specific transfer: TRIP -> STOP when stop position do not match TO point
-        assertEquals(C, subject.findTransfer(STOP_A, STOP_B, TRIP_1, TRIP_2, 1, 7));
+        // Find STOP to STOP transfer, when FROM trip and TO stop position do not match
+        assertEquals(G, subject.findTransfer(ANY_TRIP, POS_1, STOP_A, TRIP_21, ANY_POS, STOP_B));
 
-        // Find the specific transfer: STOP -> TRIP when stop position do not match FROM point
-        assertEquals(B, subject.findTransfer(STOP_A, STOP_B, TRIP_1, TRIP_2, 7, 3));
+        // Find STATION to ROUTE+STATION when FROM trip and route and TO Stop do not match
+        assertEquals(H, subject.findTransfer(ANY_TRIP, ANY_POS, STOP_S, TRIP_21, POS_3, STOP_S));
 
-        // Stop position fall back to STOP -> STOP when stop position do not match
-        assertEquals(A, subject.findTransfer(STOP_A, STOP_B, TRIP_1, TRIP_2, 7, 7));
+        // Find STATION to STATION when there are no match for FROM/TO trips and patterns
+        assertEquals(I, subject.findTransfer(ANY_TRIP, POS_1, STOP_S, ANY_TRIP, ANY_POS, STOP_S));
     }
 
     @Test
@@ -45,12 +87,26 @@ public void addSameTransferTwiceRetrieveFirstAdded() {
         var A = transfer(STOP_POINT_A, STOP_POINT_B);
         var A_EQ = transfer(STOP_POINT_A, STOP_POINT_B);
 
-        // Adding two transfers between the same stops, will result in only the first being kept
+        // Adding two transfers between the same stops
+        // should result in only the first being added
         subject.addAll(List.of(A, A_EQ));
 
-        assertEquals(A, subject.findTransfer(STOP_A, STOP_B, TRIP_1, TRIP_2, 1, 2));
+        assertEquals(List.of(A), subject.listAll());
     }
 
+    @Test
+    public void listAll() {
+        // Given:
+        var A = transfer(STATION_POINT, ROUTE_POINT_1A);
+        var B = transfer(STOP_POINT_A, STOP_POINT_B);
+        var C = transfer(STOP_POINT_A, TRIP_POINT_21_3);
+
+        // When: all transfers is added to service
+        subject.addAll(List.of(A, B, C));
+
+        // Then
+        assertEquals(List.of(A, B, C), subject.listAll());
+    }
 
     ConstrainedTransfer transfer(TransferPoint from, TransferPoint to) {
         var c = TransferConstraint.create().build();
diff --git a/src/test/java/org/opentripplanner/model/transfer/TransferTestData.java b/src/test/java/org/opentripplanner/model/transfer/TransferTestData.java
index 532a889ccf0..f8e198a0060 100644
--- a/src/test/java/org/opentripplanner/model/transfer/TransferTestData.java
+++ b/src/test/java/org/opentripplanner/model/transfer/TransferTestData.java
@@ -2,32 +2,69 @@
 
 import org.opentripplanner.model.FeedScopedId;
 import org.opentripplanner.model.Route;
+import org.opentripplanner.model.Station;
 import org.opentripplanner.model.Stop;
 import org.opentripplanner.model.Trip;
 
-public interface TransferTestData {
-    Stop STOP_A = Stop.stopForTest("A", 60.0, 11.0);
-    Stop STOP_B = Stop.stopForTest("B", 60.0, 11.0);
+public class TransferTestData {
+    static final String FEED_ID = "F";
 
-    Route ROUTE_1 = new Route(new FeedScopedId("R", "1"));
-    Route ROUTE_2 = new Route(new FeedScopedId("R", "2"));
+    static final Station STATION = Station.stationForTest("Central Station", 60.0, 11.0);
 
-    Trip TRIP_1 = createTrip("1", ROUTE_1);
-    Trip TRIP_2 = createTrip("2", ROUTE_2);
+    static final int POS_1 = 1;
+    static final int POS_2 = 2;
+    static final int POS_3 = 3;
+    static final int ANY_POS = 999;
 
-    TransferPoint STOP_POINT_A = new StopTransferPoint(STOP_A);
-    TransferPoint STOP_POINT_B = new StopTransferPoint(STOP_B);
+    static final Stop STOP_A = Stop.stopForTest("A", 60.0, 11.0);
+    static final Stop STOP_B = Stop.stopForTest("B", 60.0, 11.0);
+    static final Stop STOP_S = Stop.stopForTest("S", 60.0, 11.0);
+    static final Stop ANY_STOP = Stop.stopForTest("any", 60.0, 11.0);
 
-    TransferPoint ROUTE_POINT_11 = new RouteTransferPoint(ROUTE_1, TRIP_1, 1);
-    TransferPoint ROUTE_POINT_22 = new RouteTransferPoint(ROUTE_2, TRIP_2, 2);
+    static final Route ROUTE_1 = createRoute(1, "L1");
+    static final Route ROUTE_2 = createRoute(2, "L2");
+    static final Route ANY_ROUTE = createRoute(999, "any");
 
-    TransferPoint TRIP_POINT_11 = new TripTransferPoint(TRIP_1, 1);
-    TransferPoint TRIP_POINT_23 = new TripTransferPoint(TRIP_2, 3);
+    static final Trip TRIP_11 = createTrip(11, ROUTE_1);
+    static final Trip TRIP_12 = createTrip(12, ROUTE_1);
+    static final Trip TRIP_21 = createTrip(21, ROUTE_2);
+    static final Trip TRIP_22 = createTrip(22, ROUTE_2);
+    static final Trip ANY_TRIP = createTrip(999, ANY_ROUTE);
 
+    static final TransferPoint STATION_POINT = new StationTransferPoint(STATION);
 
-    private static Trip createTrip(String id, Route route) {
-        Trip t = new Trip(new FeedScopedId("T", id));
+    static final TransferPoint STOP_POINT_A = new StopTransferPoint(STOP_A);
+    static final TransferPoint STOP_POINT_B = new StopTransferPoint(STOP_B);
+
+    static final TransferPoint ROUTE_POINT_1S = new RouteStationTransferPoint(ROUTE_1, STATION);
+    static final TransferPoint ROUTE_POINT_2S = new RouteStationTransferPoint(ROUTE_2, STATION);
+
+    static final TransferPoint ROUTE_POINT_1A = new RouteStopTransferPoint(ROUTE_1, STOP_A);
+    static final TransferPoint ROUTE_POINT_2B = new RouteStopTransferPoint(ROUTE_2, STOP_B);
+
+    static final TransferPoint TRIP_POINT_11_1 = new TripTransferPoint(TRIP_11, POS_1);
+    static final TransferPoint TRIP_POINT_21_3 = new TripTransferPoint(TRIP_21, POS_3);
+
+    static {
+        STATION.addChildStop(STOP_A);
+        STOP_A.setParentStation(STATION);
+        STATION.addChildStop(STOP_S);
+        STOP_S.setParentStation(STATION);
+    }
+
+    private static Trip createTrip(int id, Route route) {
+        Trip t = new Trip(createId(id));
         t.setRoute(route);
         return t;
     }
+
+    private static Route createRoute(int id, String name) {
+        Route r = new Route(createId(id));
+        r.setShortName(name);
+        return r;
+    }
+
+    private static FeedScopedId createId(int id) {
+        return new FeedScopedId(FEED_ID, String.valueOf(id));
+    }
 }
diff --git a/src/test/java/org/opentripplanner/netex/mapping/StopTransferPriorityMapperTest.java b/src/test/java/org/opentripplanner/netex/mapping/StopTransferPriorityMapperTest.java
index 0f91b91ea9b..d2345598b91 100644
--- a/src/test/java/org/opentripplanner/netex/mapping/StopTransferPriorityMapperTest.java
+++ b/src/test/java/org/opentripplanner/netex/mapping/StopTransferPriorityMapperTest.java
@@ -13,23 +13,23 @@ public class StopTransferPriorityMapperTest {
   @Test
   public void mapToDomain() {
 
-    assertNull(TransferPriorityMapper.mapToDomain(null));
+    assertNull(StopTransferPriorityMapper.mapToDomain(null));
 
     assertEquals(
         StopTransferPriority.DISCOURAGED,
-        TransferPriorityMapper.mapToDomain(InterchangeWeightingEnumeration.NO_INTERCHANGE)
+        StopTransferPriorityMapper.mapToDomain(InterchangeWeightingEnumeration.NO_INTERCHANGE)
     );
     assertEquals(
         StopTransferPriority.ALLOWED,
-        TransferPriorityMapper.mapToDomain(InterchangeWeightingEnumeration.INTERCHANGE_ALLOWED)
+        StopTransferPriorityMapper.mapToDomain(InterchangeWeightingEnumeration.INTERCHANGE_ALLOWED)
     );
     assertEquals(
         StopTransferPriority.PREFERRED,
-        TransferPriorityMapper.mapToDomain(InterchangeWeightingEnumeration.PREFERRED_INTERCHANGE)
+        StopTransferPriorityMapper.mapToDomain(InterchangeWeightingEnumeration.PREFERRED_INTERCHANGE)
     );
     assertEquals(
         StopTransferPriority.RECOMMENDED,
-        TransferPriorityMapper.mapToDomain(InterchangeWeightingEnumeration.RECOMMENDED_INTERCHANGE)
+        StopTransferPriorityMapper.mapToDomain(InterchangeWeightingEnumeration.RECOMMENDED_INTERCHANGE)
     );
   }
 }
\ No newline at end of file
diff --git a/src/test/java/org/opentripplanner/netex/mapping/TripPatternMapperTest.java b/src/test/java/org/opentripplanner/netex/mapping/TripPatternMapperTest.java
index fca09a102b4..eb5e7486e9d 100644
--- a/src/test/java/org/opentripplanner/netex/mapping/TripPatternMapperTest.java
+++ b/src/test/java/org/opentripplanner/netex/mapping/TripPatternMapperTest.java
@@ -3,12 +3,10 @@
 import static org.junit.Assert.assertEquals;
 
 import java.util.Collections;
-import java.util.List;
 import java.util.Map;
 import org.junit.Test;
 import org.opentripplanner.graph_builder.DataImportIssueStore;
 import org.opentripplanner.model.FeedScopedId;
-import org.opentripplanner.model.Stop;
 import org.opentripplanner.model.Trip;
 import org.opentripplanner.model.TripPattern;
 import org.opentripplanner.model.impl.EntityById;
@@ -52,19 +50,18 @@ public void testMapTripPattern() {
 
         assertEquals(1, r.tripPatterns.size());
 
-        TripPattern tripPattern = r.tripPatterns.get(0);
+        TripPattern tripPattern = r.tripPatterns.values().stream().findFirst().orElseThrow();
 
-        assertEquals(4, tripPattern.getStops().size());
+        assertEquals(4, tripPattern.numberOfStops());
         assertEquals(1, tripPattern.getTrips().size());
 
-        var stops = tripPattern.getStops();
         Trip trip = tripPattern.getTrips().get(0);
 
         assertEquals("RUT:ServiceJourney:1", trip.getId().getId());
-        assertEquals("NSR:Quay:1", stops.get(0).getId().getId());
-        assertEquals("NSR:Quay:2", stops.get(1).getId().getId());
-        assertEquals("NSR:Quay:3", stops.get(2).getId().getId());
-        assertEquals("NSR:Quay:4", stops.get(3).getId().getId());
+        assertEquals("NSR:Quay:1", tripPattern.getStop(0).getId().getId());
+        assertEquals("NSR:Quay:2", tripPattern.getStop(1).getId().getId());
+        assertEquals("NSR:Quay:3", tripPattern.getStop(2).getId().getId());
+        assertEquals("NSR:Quay:4", tripPattern.getStop(3).getId().getId());
 
         assertEquals(1, tripPattern.getScheduledTimetable().getTripTimes().size());
 
diff --git a/src/test/java/org/opentripplanner/routing/algorithm/mapping/__snapshots__/TransitSnapshotTest.snap b/src/test/java/org/opentripplanner/routing/algorithm/mapping/__snapshots__/TransitSnapshotTest.snap
index 6e07f7dd0db..4ff04b5c220 100644
--- a/src/test/java/org/opentripplanner/routing/algorithm/mapping/__snapshots__/TransitSnapshotTest.snap
+++ b/src/test/java/org/opentripplanner/routing/algorithm/mapping/__snapshots__/TransitSnapshotTest.snap
@@ -2357,8 +2357,8 @@ org.opentripplanner.routing.algorithm.mapping.TransitSnapshotTest.test_trip_plan
           "agencyUrl": "http://trimet.org",
           "arrivalDelay": 0,
           "departureDelay": 0,
-          "distance": 2347.5602438577907,
-          "endTime": "2009-11-17T18:52:12.000+00:00",
+          "distance": 2035.619484953424,
+          "endTime": "2009-11-17T18:51:01.000+00:00",
           "from": {
             "arrival": "2009-11-17T18:42:54.000+00:00",
             "departure": "2009-11-17T18:42:54.000+00:00",
@@ -2372,7 +2372,7 @@ org.opentripplanner.routing.algorithm.mapping.TransitSnapshotTest.test_trip_plan
             "vertexType": "TRANSIT",
             "zoneId": "1"
           },
-          "generalizedCost": 1158,
+          "generalizedCost": 1087,
           "headsign": "Rose Qtr TC",
           "interlineWithPreviousLeg": false,
           "intermediateStops": [
@@ -2466,37 +2466,11 @@ org.opentripplanner.routing.algorithm.mapping.TransitSnapshotTest.test_trip_plan
               "stopSequence": 39,
               "vertexType": "TRANSIT",
               "zoneId": "0"
-            },
-            {
-              "arrival": "2009-11-17T18:51:01.000+00:00",
-              "departure": "2009-11-17T18:51:01.000+00:00",
-              "lat": 45.531569,
-              "lon": -122.659045,
-              "name": "NE Multnomah & 7th",
-              "stopCode": "4054",
-              "stopId": "prt:4054",
-              "stopIndex": 39,
-              "stopSequence": 40,
-              "vertexType": "TRANSIT",
-              "zoneId": "0"
-            },
-            {
-              "arrival": "2009-11-17T18:51:27.000+00:00",
-              "departure": "2009-11-17T18:51:27.000+00:00",
-              "lat": 45.531586,
-              "lon": -122.660482,
-              "name": "NE Multnomah & Grand",
-              "stopCode": "4043",
-              "stopId": "prt:4043",
-              "stopIndex": 40,
-              "stopSequence": 41,
-              "vertexType": "TRANSIT",
-              "zoneId": "0"
             }
           ],
           "legGeometry": {
-            "length": 62,
-            "points": "s`ytGhxrkV[?mCAmC?wBA??W?mC?{BA??Q?oC?mC?kBAa@?w@???wA?mCAmC?oCA}C?sDC??aBAm@@k@AY?uABU@I@IBQFb@fC}@d@OFO@q@???Q?]?gGA??[??nJ???b@?vK?rA???tBCfD???^?nE?V@Z?PH\\Nb@`@~@Rf@"
+            "length": 49,
+            "points": "s`ytGhxrkV[?mCAmC?wBA??W?mC?{BA??Q?oC?mC?kBAa@?w@???wA?mCAmC?oCA}C?sDC??aBAm@@k@AY?uABU@I@IBQFb@fC}@d@OFO@q@???Q?]?gGA??[??nJ???b@?vK?rA"
           },
           "mode": "BUS",
           "pathway": false,
@@ -2509,15 +2483,15 @@ org.opentripplanner.routing.algorithm.mapping.TransitSnapshotTest.test_trip_plan
           "startTime": "2009-11-17T18:42:54.000+00:00",
           "steps": [ ],
           "to": {
-            "arrival": "2009-11-17T18:52:12.000+00:00",
-            "departure": "2009-11-17T18:56:09.000+00:00",
-            "lat": 45.531159,
-            "lon": -122.66293,
-            "name": "NE Multnomah & 3rd",
-            "stopCode": "11492",
-            "stopId": "prt:11492",
-            "stopIndex": 41,
-            "stopSequence": 42,
+            "arrival": "2009-11-17T18:51:01.000+00:00",
+            "departure": "2009-11-17T18:54:29.000+00:00",
+            "lat": 45.531569,
+            "lon": -122.659045,
+            "name": "NE Multnomah & 7th",
+            "stopCode": "4054",
+            "stopId": "prt:4054",
+            "stopIndex": 39,
+            "stopSequence": 40,
             "vertexType": "TRANSIT",
             "zoneId": "0"
           },
@@ -2532,25 +2506,51 @@ org.opentripplanner.routing.algorithm.mapping.TransitSnapshotTest.test_trip_plan
           "agencyUrl": "http://trimet.org",
           "arrivalDelay": 0,
           "departureDelay": 0,
-          "distance": 3248.2996826174576,
+          "distance": 3560.2404415218243,
           "endTime": "2009-11-17T19:07:55.000+00:00",
           "from": {
-            "arrival": "2009-11-17T18:52:12.000+00:00",
-            "departure": "2009-11-17T18:56:09.000+00:00",
-            "lat": 45.531159,
-            "lon": -122.66293,
-            "name": "NE Multnomah & 3rd",
-            "stopCode": "11492",
-            "stopId": "prt:11492",
-            "stopIndex": 83,
-            "stopSequence": 84,
+            "arrival": "2009-11-17T18:51:01.000+00:00",
+            "departure": "2009-11-17T18:54:29.000+00:00",
+            "lat": 45.531569,
+            "lon": -122.659045,
+            "name": "NE Multnomah & 7th",
+            "stopCode": "4054",
+            "stopId": "prt:4054",
+            "stopIndex": 81,
+            "stopSequence": 82,
             "vertexType": "TRANSIT",
             "zoneId": "0"
           },
-          "generalizedCost": 1543,
+          "generalizedCost": 1614,
           "headsign": "Montgomery Park",
           "interlineWithPreviousLeg": false,
           "intermediateStops": [
+            {
+              "arrival": "2009-11-17T18:55:05.000+00:00",
+              "departure": "2009-11-17T18:55:05.000+00:00",
+              "lat": 45.531586,
+              "lon": -122.660482,
+              "name": "NE Multnomah & Grand",
+              "stopCode": "4043",
+              "stopId": "prt:4043",
+              "stopIndex": 82,
+              "stopSequence": 83,
+              "vertexType": "TRANSIT",
+              "zoneId": "0"
+            },
+            {
+              "arrival": "2009-11-17T18:56:09.000+00:00",
+              "departure": "2009-11-17T18:56:09.000+00:00",
+              "lat": 45.531159,
+              "lon": -122.66293,
+              "name": "NE Multnomah & 3rd",
+              "stopCode": "11492",
+              "stopId": "prt:11492",
+              "stopIndex": 83,
+              "stopSequence": 84,
+              "vertexType": "TRANSIT",
+              "zoneId": "0"
+            },
             {
               "arrival": "2009-11-17T18:58:00.000+00:00",
               "departure": "2009-11-17T18:58:00.000+00:00",
@@ -2657,8 +2657,8 @@ org.opentripplanner.routing.algorithm.mapping.TransitSnapshotTest.test_trip_plan
             }
           ],
           "legGeometry": {
-            "length": 91,
-            "points": "kx{tG~qtkV`@bANb@FV@R?P?pE?jA@h@AnAbBl@LFJN\\f@LT??NXJPPVJFf@Vf@Pp@Nd@NRLB@RNXZR\\vAhC@BhAhD`AhClAbDBrDCnG@n@@^@d@HdAP`CBjEDvD???LqCFmCDYBGDEBGJkAzAQR??KNa@b@MJuBBY?OHW@u@~@aD`EcBhBBrD@xC??@l@BlE@lD???XBjEBpD???VBlE?dA@t@?b@?h@BfEBrD???VBhEFtKDvJ??@\\DnJ"
+            "length": 104,
+            "points": "yz{tG`zskV?tBCfD???^?nE?V@Z?PH\\Nb@`@~@Rf@??`@bANb@FV@R?P?pE?jA@h@AnAbBl@LFJN\\f@LT??NXJPPVJFf@Vf@Pp@Nd@NRLB@RNXZR\\vAhC@BhAhD`AhClAbDBrDCnG@n@@^@d@HdAP`CBjEDvD???LqCFmCDYBGDEBGJkAzAQR??KNa@b@MJuBBY?OHW@u@~@aD`EcBhBBrD@xC??@l@BlE@lD???XBjEBpD???VBlE?dA@t@?b@?h@BfEBrD???VBhEFtKDvJ??@\\DnJ"
           },
           "mode": "BUS",
           "pathway": false,
@@ -2668,7 +2668,7 @@ org.opentripplanner.routing.algorithm.mapping.TransitSnapshotTest.test_trip_plan
           "routeLongName": "Broadway/Halsey",
           "routeShortName": "77",
           "serviceDate": "2009-11-17",
-          "startTime": "2009-11-17T18:56:09.000+00:00",
+          "startTime": "2009-11-17T18:54:29.000+00:00",
           "steps": [ ],
           "to": {
             "arrival": "2009-11-17T19:07:55.000+00:00",
@@ -2746,8 +2746,8 @@ org.opentripplanner.routing.algorithm.mapping.TransitSnapshotTest.test_trip_plan
       "startTime": "2009-11-17T18:37:30.000+00:00",
       "tooSloped": false,
       "transfers": 1,
-      "transitTime": 1264,
-      "waitingTime": 237,
+      "transitTime": 1293,
+      "waitingTime": 208,
       "walkDistance": 438.42099999999994,
       "walkLimitExceeded": false,
       "walkTime": 348
@@ -4438,8 +4438,8 @@ org.opentripplanner.routing.algorithm.mapping.TransitSnapshotTest.test_trip_plan
           "agencyUrl": "http://trimet.org",
           "arrivalDelay": 0,
           "departureDelay": 0,
-          "distance": 1667.1475209896525,
-          "endTime": "2009-11-17T18:52:12.000+00:00",
+          "distance": 1355.2067620852858,
+          "endTime": "2009-11-17T18:51:01.000+00:00",
           "from": {
             "arrival": "2009-11-17T18:46:00.000+00:00",
             "departure": "2009-11-17T18:46:00.000+00:00",
@@ -4453,7 +4453,7 @@ org.opentripplanner.routing.algorithm.mapping.TransitSnapshotTest.test_trip_plan
             "vertexType": "TRANSIT",
             "zoneId": "1"
           },
-          "generalizedCost": 972,
+          "generalizedCost": 901,
           "headsign": "Rose Qtr TC",
           "interlineWithPreviousLeg": false,
           "intermediateStops": [
@@ -4508,37 +4508,11 @@ org.opentripplanner.routing.algorithm.mapping.TransitSnapshotTest.test_trip_plan
               "stopSequence": 39,
               "vertexType": "TRANSIT",
               "zoneId": "0"
-            },
-            {
-              "arrival": "2009-11-17T18:51:01.000+00:00",
-              "departure": "2009-11-17T18:51:01.000+00:00",
-              "lat": 45.531569,
-              "lon": -122.659045,
-              "name": "NE Multnomah & 7th",
-              "stopCode": "4054",
-              "stopId": "prt:4054",
-              "stopIndex": 39,
-              "stopSequence": 40,
-              "vertexType": "TRANSIT",
-              "zoneId": "0"
-            },
-            {
-              "arrival": "2009-11-17T18:51:27.000+00:00",
-              "departure": "2009-11-17T18:51:27.000+00:00",
-              "lat": 45.531586,
-              "lon": -122.660482,
-              "name": "NE Multnomah & Grand",
-              "stopCode": "4043",
-              "stopId": "prt:4043",
-              "stopIndex": 40,
-              "stopSequence": 41,
-              "vertexType": "TRANSIT",
-              "zoneId": "0"
             }
           ],
           "legGeometry": {
-            "length": 46,
-            "points": "{fztG`xrkVwA?mCAmC?oCA}C?sDC??aBAm@@k@AY?uABU@I@IBQFb@fC}@d@OFO@q@???Q?]?gGA??[??nJ???b@?vK?rA???tBCfD???^?nE?V@Z?PH\\Nb@`@~@Rf@"
+            "length": 33,
+            "points": "{fztG`xrkVwA?mCAmC?oCA}C?sDC??aBAm@@k@AY?uABU@I@IBQFb@fC}@d@OFO@q@???Q?]?gGA??[??nJ???b@?vK?rA"
           },
           "mode": "BUS",
           "pathway": false,
@@ -4551,15 +4525,15 @@ org.opentripplanner.routing.algorithm.mapping.TransitSnapshotTest.test_trip_plan
           "startTime": "2009-11-17T18:46:00.000+00:00",
           "steps": [ ],
           "to": {
-            "arrival": "2009-11-17T18:52:12.000+00:00",
-            "departure": "2009-11-17T18:56:09.000+00:00",
-            "lat": 45.531159,
-            "lon": -122.66293,
-            "name": "NE Multnomah & 3rd",
-            "stopCode": "11492",
-            "stopId": "prt:11492",
-            "stopIndex": 41,
-            "stopSequence": 42,
+            "arrival": "2009-11-17T18:51:01.000+00:00",
+            "departure": "2009-11-17T18:54:29.000+00:00",
+            "lat": 45.531569,
+            "lon": -122.659045,
+            "name": "NE Multnomah & 7th",
+            "stopCode": "4054",
+            "stopId": "prt:4054",
+            "stopIndex": 39,
+            "stopSequence": 40,
             "vertexType": "TRANSIT",
             "zoneId": "0"
           },
@@ -4574,25 +4548,51 @@ org.opentripplanner.routing.algorithm.mapping.TransitSnapshotTest.test_trip_plan
           "agencyUrl": "http://trimet.org",
           "arrivalDelay": 0,
           "departureDelay": 0,
-          "distance": 3526.7741793792093,
+          "distance": 3838.714938283576,
           "endTime": "2009-11-17T19:08:50.000+00:00",
           "from": {
-            "arrival": "2009-11-17T18:52:12.000+00:00",
-            "departure": "2009-11-17T18:56:09.000+00:00",
-            "lat": 45.531159,
-            "lon": -122.66293,
-            "name": "NE Multnomah & 3rd",
-            "stopCode": "11492",
-            "stopId": "prt:11492",
-            "stopIndex": 83,
-            "stopSequence": 84,
+            "arrival": "2009-11-17T18:51:01.000+00:00",
+            "departure": "2009-11-17T18:54:29.000+00:00",
+            "lat": 45.531569,
+            "lon": -122.659045,
+            "name": "NE Multnomah & 7th",
+            "stopCode": "4054",
+            "stopId": "prt:4054",
+            "stopIndex": 81,
+            "stopSequence": 82,
             "vertexType": "TRANSIT",
             "zoneId": "0"
           },
-          "generalizedCost": 1598,
+          "generalizedCost": 1669,
           "headsign": "Montgomery Park",
           "interlineWithPreviousLeg": false,
           "intermediateStops": [
+            {
+              "arrival": "2009-11-17T18:55:05.000+00:00",
+              "departure": "2009-11-17T18:55:05.000+00:00",
+              "lat": 45.531586,
+              "lon": -122.660482,
+              "name": "NE Multnomah & Grand",
+              "stopCode": "4043",
+              "stopId": "prt:4043",
+              "stopIndex": 82,
+              "stopSequence": 83,
+              "vertexType": "TRANSIT",
+              "zoneId": "0"
+            },
+            {
+              "arrival": "2009-11-17T18:56:09.000+00:00",
+              "departure": "2009-11-17T18:56:09.000+00:00",
+              "lat": 45.531159,
+              "lon": -122.66293,
+              "name": "NE Multnomah & 3rd",
+              "stopCode": "11492",
+              "stopId": "prt:11492",
+              "stopIndex": 83,
+              "stopSequence": 84,
+              "vertexType": "TRANSIT",
+              "zoneId": "0"
+            },
             {
               "arrival": "2009-11-17T18:58:00.000+00:00",
               "departure": "2009-11-17T18:58:00.000+00:00",
@@ -4712,8 +4712,8 @@ org.opentripplanner.routing.algorithm.mapping.TransitSnapshotTest.test_trip_plan
             }
           ],
           "legGeometry": {
-            "length": 96,
-            "points": "kx{tG~qtkV`@bANb@FV@R?P?pE?jA@h@AnAbBl@LFJN\\f@LT??NXJPPVJFf@Vf@Pp@Nd@NRLB@RNXZR\\vAhC@BhAhD`AhClAbDBrDCnG@n@@^@d@HdAP`CBjEDvD???LqCFmCDYBGDEBGJkAzAQR??KNa@b@MJuBBY?OHW@u@~@aD`EcBhBBrD@xC??@l@BlE@lD???XBjEBpD???VBlE?dA@t@?b@?h@BfEBrD???VBhEFtKDvJ??@\\DnJ???d@FtKmCBo@@"
+            "length": 109,
+            "points": "yz{tG`zskV?tBCfD???^?nE?V@Z?PH\\Nb@`@~@Rf@??`@bANb@FV@R?P?pE?jA@h@AnAbBl@LFJN\\f@LT??NXJPPVJFf@Vf@Pp@Nd@NRLB@RNXZR\\vAhC@BhAhD`AhClAbDBrDCnG@n@@^@d@HdAP`CBjEDvD???LqCFmCDYBGDEBGJkAzAQR??KNa@b@MJuBBY?OHW@u@~@aD`EcBhBBrD@xC??@l@BlE@lD???XBjEBpD???VBlE?dA@t@?b@?h@BfEBrD???VBhEFtKDvJ??@\\DnJ???d@FtKmCBo@@"
           },
           "mode": "BUS",
           "pathway": false,
@@ -4723,7 +4723,7 @@ org.opentripplanner.routing.algorithm.mapping.TransitSnapshotTest.test_trip_plan
           "routeLongName": "Broadway/Halsey",
           "routeShortName": "77",
           "serviceDate": "2009-11-17",
-          "startTime": "2009-11-17T18:56:09.000+00:00",
+          "startTime": "2009-11-17T18:54:29.000+00:00",
           "steps": [ ],
           "to": {
             "arrival": "2009-11-17T19:08:50.000+00:00",
@@ -4813,8 +4813,8 @@ org.opentripplanner.routing.algorithm.mapping.TransitSnapshotTest.test_trip_plan
       "startTime": "2009-11-17T18:45:44.000+00:00",
       "tooSloped": false,
       "transfers": 1,
-      "transitTime": 1133,
-      "waitingTime": 237,
+      "transitTime": 1162,
+      "waitingTime": 208,
       "walkDistance": 252.199,
       "walkLimitExceeded": false,
       "walkTime": 197
diff --git a/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/ConstrainedBoardingSearchTest.java b/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/ConstrainedBoardingSearchTest.java
new file mode 100644
index 00000000000..9c661c81a93
--- /dev/null
+++ b/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/constrainedtransfer/ConstrainedBoardingSearchTest.java
@@ -0,0 +1,349 @@
+package org.opentripplanner.routing.algorithm.raptor.transit.constrainedtransfer;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.opentripplanner.model.transfer.TransferConstraint.REGULAR_TRANSFER;
+import static org.opentripplanner.routing.algorithm.raptor.transit.request.TestTransitCaseData.RAPTOR_STOP_INDEX;
+import static org.opentripplanner.routing.algorithm.raptor.transit.request.TestTransitCaseData.STATION_B;
+import static org.opentripplanner.routing.algorithm.raptor.transit.request.TestTransitCaseData.STOP_A;
+import static org.opentripplanner.routing.algorithm.raptor.transit.request.TestTransitCaseData.STOP_B;
+import static org.opentripplanner.routing.algorithm.raptor.transit.request.TestTransitCaseData.STOP_C;
+import static org.opentripplanner.routing.algorithm.raptor.transit.request.TestTransitCaseData.STOP_D;
+import static org.opentripplanner.routing.algorithm.raptor.transit.request.TestTransitCaseData.id;
+import static org.opentripplanner.routing.algorithm.raptor.transit.request.TestTransitCaseData.stopIndex;
+
+import java.util.Collection;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.opentripplanner.model.FeedScopedId;
+import org.opentripplanner.model.Stop;
+import org.opentripplanner.model.TransitMode;
+import org.opentripplanner.model.transfer.ConstrainedTransfer;
+import org.opentripplanner.model.transfer.RouteStationTransferPoint;
+import org.opentripplanner.model.transfer.RouteStopTransferPoint;
+import org.opentripplanner.model.transfer.StationTransferPoint;
+import org.opentripplanner.model.transfer.StopTransferPoint;
+import org.opentripplanner.model.transfer.TransferConstraint;
+import org.opentripplanner.model.transfer.TripTransferPoint;
+import org.opentripplanner.routing.algorithm.raptor.transit.StopIndexForRaptor;
+import org.opentripplanner.routing.algorithm.raptor.transit.TransitTuningParameters;
+import org.opentripplanner.routing.algorithm.raptor.transit.TripPatternWithRaptorStopIndexes;
+import org.opentripplanner.routing.algorithm.raptor.transit.TripSchedule;
+import org.opentripplanner.routing.algorithm.raptor.transit.request.TestRouteData;
+import org.opentripplanner.transit.raptor.api.transit.RaptorTripScheduleBoardOrAlightEvent;
+
+
+public class ConstrainedBoardingSearchTest {
+
+    private static final FeedScopedId ID = id("ID");
+    private static final TransferConstraint GUARANTEED_CONSTRAINT =
+            TransferConstraint.create().guaranteed().build();
+    private static final TransferConstraint NOT_ALLOWED_CONSTRAINT =
+            TransferConstraint.create().notAllowed().build();
+    private static final StopTransferPoint STOP_B_TX_POINT = new StopTransferPoint(STOP_B);
+    private static final StopTransferPoint STOP_C_TX_POINT = new StopTransferPoint(STOP_C);
+
+    private static final int TRIP_1_INDEX = 0;
+    private static final int TRIP_2_INDEX = 1;
+    public static final StationTransferPoint STATION_B_TX_POINT =
+            new StationTransferPoint(STATION_B);
+
+    private TestRouteData route1;
+    private TestRouteData route2;
+    private TripPatternWithRaptorStopIndexes pattern1;
+    private TripPatternWithRaptorStopIndexes pattern2;
+    private StopIndexForRaptor stopIndex;
+
+    /**
+     * Create transit data with 2 routes with a trip each.
+     * <pre>
+     *                              STOPS
+     *                     A      B      C      D
+     * Route R1
+     *   - Trip R1-1:    10:00  10:10  10:20
+     *   - Trip R1-2:    10:05  10:15  10:25
+     * Route R2
+     *   - Trip R2-1:           10:15  10:30  10:40
+     *   - Trip R2-2:           10:20  10:35  10:45
+     *   - Trip R2-3:           10:25  10:40  10:50
+     *   - Trip R2-4:           10:30  10:45  10:55
+     *   - Trip R2-5:           10:35  10:50  11:00
+     *   - Trip R2-6:           10:40  10:55  11:05
+     * </pre>
+     * <ul>
+     *     <li>
+     *         The transfer at stop B is tight between trip R1-2 and R2-1. There is no time between
+     *         the arrival and departure, and it is only possible to transfer if the transfer is
+     *         stay-seated or guaranteed. For other types of constrained transfers we should board
+     *         the next trip 'R2-2'.
+     *     </li>
+     *     <li>
+     *         The transfer at stop C allow regular transfers between trip R1-2 and R2-1.
+     *     </li>
+     *     <li>
+     *         R1-1 is the fallback in the reverse search in the same way as R2-2 is the fallback
+     *         int the forward search.
+     *     </li>
+     * </ul>
+     * The
+     *
+     */
+    @BeforeEach
+    void setup() {
+        route1 = new TestRouteData(
+                "R1", TransitMode.RAIL,
+                List.of(STOP_A, STOP_B, STOP_C),
+                "10:00 10:10 10:20",
+                "10:05 10:15 10:25"
+        );
+
+        route2 = new TestRouteData(
+                "R2", TransitMode.BUS,
+                List.of(STOP_B, STOP_C, STOP_D),
+                "10:15 10:30 10:40",
+                "10:20 10:35 10:45",
+                "10:25 10:40 10:50",
+                "10:30 10:45 10:55",
+                "10:35 10:50 11:00",
+                "10:40 10:55 11:05"
+        );
+
+        this.pattern1 = route1.getRaptorTripPattern();
+        this.pattern2 = route2.getRaptorTripPattern();
+        this.stopIndex = new StopIndexForRaptor(
+                List.of(RAPTOR_STOP_INDEX),
+                TransitTuningParameters.FOR_TEST
+        );
+    }
+
+    @Test
+    void transferExist() {
+        int fromStopPos = route1.stopPosition(STOP_C);
+        int toStopPos = route2.stopPosition(STOP_C);
+
+        var txAllowed = new ConstrainedTransfer(
+                ID, STOP_C_TX_POINT, STOP_C_TX_POINT, GUARANTEED_CONSTRAINT
+        );
+        generateTransfersForPatterns(List.of(txAllowed));
+
+        // Forward
+        var subject = route2.getRaptorTripPattern().constrainedTransferForwardSearch();
+        assertTrue(subject.transferExist(toStopPos));
+
+        // Reverse
+        subject = route1.getRaptorTripPattern().constrainedTransferReverseSearch();
+        assertTrue(subject.transferExist(fromStopPos));
+    }
+
+    @Test
+    void findGuaranteedTransferWithZeroConnectionTimeWithStation() {
+        var txGuaranteedTrip2Trip = new ConstrainedTransfer(
+                ID, STATION_B_TX_POINT, STATION_B_TX_POINT, GUARANTEED_CONSTRAINT
+        );
+        findGuaranteedTransferWithZeroConnectionTime(List.of(txGuaranteedTrip2Trip));
+    }
+
+    @Test
+    void findGuaranteedTransferWithZeroConnectionTimeWithStop() {
+        var txGuaranteedTrip2Trip = new ConstrainedTransfer(
+                ID, STOP_B_TX_POINT, STOP_B_TX_POINT, GUARANTEED_CONSTRAINT
+        );
+        findGuaranteedTransferWithZeroConnectionTime(List.of(txGuaranteedTrip2Trip));
+    }
+
+    @Test
+    void findGuaranteedTransferWithZeroConnectionTimeWithRouteAndStopTransfers() {
+        var route1TxPoint = new RouteStopTransferPoint(route1.getRoute(), STOP_B);
+        var route2TxPoint = new RouteStopTransferPoint(route2.getRoute(), STOP_B);
+
+        var txGuaranteedTrip2Trip = new ConstrainedTransfer(
+                ID, route1TxPoint, route2TxPoint, GUARANTEED_CONSTRAINT
+        );
+        findGuaranteedTransferWithZeroConnectionTime(List.of(txGuaranteedTrip2Trip));
+    }
+
+    @Test
+    void findGuaranteedTransferWithZeroConnectionTimeWithRouteAndStationTransfers() {
+        var route1TxPoint = new RouteStationTransferPoint(route1.getRoute(), STATION_B);
+        var route2TxPoint = new RouteStationTransferPoint(route2.getRoute(), STATION_B);
+
+        var txGuaranteedTrip2Trip = new ConstrainedTransfer(
+                ID, route1TxPoint, route2TxPoint, GUARANTEED_CONSTRAINT
+        );
+        findGuaranteedTransferWithZeroConnectionTime(List.of(txGuaranteedTrip2Trip));
+    }
+
+    @Test
+    void findGuaranteedTransferWithZeroConnectionTimeWithTripTransfers() {
+        int sourceStopPos = route1.stopPosition(STOP_B);
+        int targetStopPos = route2.stopPosition(STOP_B);
+        var trip1TxPoint = new TripTransferPoint(route1.lastTrip().trip(), sourceStopPos);
+        var trip2TxPoint = new TripTransferPoint(route2.firstTrip().trip(), targetStopPos);
+
+        var txGuaranteedTrip2Trip = new ConstrainedTransfer(
+                ID, trip1TxPoint, trip2TxPoint, GUARANTEED_CONSTRAINT
+        );
+        findGuaranteedTransferWithZeroConnectionTime(List.of(txGuaranteedTrip2Trip));
+    }
+
+    @Test
+    void findGuaranteedTransferWithMostSpecificTransfers() {
+        int sourceStopPos = route1.stopPosition(STOP_B);
+        int targetStopPos = route2.stopPosition(STOP_B);
+        var trip1TxPoint = new TripTransferPoint(route1.lastTrip().trip(), sourceStopPos);
+        var route1TxPoint = new RouteStopTransferPoint(route1.getRoute(), STOP_B);
+        var trip2TxPoint = new TripTransferPoint(route2.firstTrip().trip(), targetStopPos);
+
+
+        var transfers =  List.of(
+                new ConstrainedTransfer(ID, STOP_B_TX_POINT, trip2TxPoint, NOT_ALLOWED_CONSTRAINT),
+                new ConstrainedTransfer(ID, trip1TxPoint, STOP_B_TX_POINT, GUARANTEED_CONSTRAINT),
+                new ConstrainedTransfer(ID, route1TxPoint, STOP_B_TX_POINT, NOT_ALLOWED_CONSTRAINT)
+        );
+        findGuaranteedTransferWithZeroConnectionTime(transfers);
+    }
+
+    @Test
+    void findNextTransferWhenFirstTransferIsNotAllowed() {
+        int sourceStopPos = route1.stopPosition(STOP_C);
+        int targetStopPos = route2.stopPosition(STOP_C);
+        var trip1TxPoint = new TripTransferPoint(route1.lastTrip().trip(), sourceStopPos);
+        var trip2TxPoint = new TripTransferPoint(route2.firstTrip().trip(), targetStopPos);
+
+        var txNotAllowed = new ConstrainedTransfer(
+                ID, trip1TxPoint, trip2TxPoint, NOT_ALLOWED_CONSTRAINT
+        );
+
+        testTransferSearch(
+                STOP_C, List.of(txNotAllowed), TRIP_2_INDEX, TRIP_1_INDEX, REGULAR_TRANSFER
+        );
+    }
+
+    @Test
+    void blockTransferWhenNotAllowedApplyToAllTrips() {
+        ConstrainedTransfer transfer = new ConstrainedTransfer(
+                ID, STOP_C_TX_POINT, STOP_C_TX_POINT, NOT_ALLOWED_CONSTRAINT
+        );
+        testTransferSearch(
+                STOP_C, List.of(transfer), TRIP_1_INDEX, TRIP_2_INDEX, NOT_ALLOWED_CONSTRAINT
+        );
+    }
+
+    @Test
+    void makeSureTheSearchIsAbortedAfter5NormalTripsAreFound() {
+        int sourceStopPos = route1.stopPosition(STOP_C);
+        int targetStopPos = route2.stopPosition(STOP_C);
+        var trip1TxPoint = new TripTransferPoint(route1.lastTrip().trip(), sourceStopPos);
+        var trip2TxPoint = new TripTransferPoint(route2.lastTrip().trip(), targetStopPos);
+
+        var txGuaranteed = new ConstrainedTransfer(
+                ID, trip1TxPoint, trip2TxPoint, GUARANTEED_CONSTRAINT
+        );
+
+        testTransferSearch(
+                STOP_C, List.of(txGuaranteed), TRIP_2_INDEX, TRIP_1_INDEX, null
+        );
+    }
+
+
+    /**
+     * The most specific transfer passed in should be a guaranteed transfer
+     * at stop B
+     */
+    private void findGuaranteedTransferWithZeroConnectionTime(
+            List<ConstrainedTransfer> constrainedTransfers
+    ) {
+        testTransferSearch(
+                STOP_B, constrainedTransfers, TRIP_1_INDEX, TRIP_2_INDEX, GUARANTEED_CONSTRAINT
+        );
+    }
+
+    void testTransferSearch(
+            Stop transferStop,
+            List<ConstrainedTransfer> constraints,
+            int expTripIndexFwdSearch,
+            int expTripIndexRevSearch,
+            TransferConstraint expConstraint
+    ) {
+        testTransferSearchForward(transferStop, constraints, expTripIndexFwdSearch, expConstraint);
+        testTransferSearchReverse(transferStop, constraints, expTripIndexRevSearch, expConstraint);
+    }
+
+    void testTransferSearchForward(
+            Stop transferStop,
+            List<ConstrainedTransfer> txList,
+            int expectedTripIndex,
+            TransferConstraint expectedConstraint
+    ) {
+        generateTransfersForPatterns(txList);
+        var subject = pattern2.constrainedTransferForwardSearch();
+
+        int targetStopPos = route2.stopPosition(transferStop);
+        int stopIndex = stopIndex(transferStop);
+        int sourceArrivalTime = route1.lastTrip().getStopTime(transferStop).getArrivalTime();
+
+        // Check that transfer exist
+        assertTrue(subject.transferExist(targetStopPos));
+
+        var boarding = subject.find(
+                route2.getTimetable(),
+                route1.lastTrip().getTripSchedule(),
+                stopIndex,
+                sourceArrivalTime
+        );
+        assertBoarding(stopIndex, targetStopPos, expectedTripIndex, expectedConstraint, boarding);
+    }
+
+    void testTransferSearchReverse(
+            Stop transferStop,
+            List<ConstrainedTransfer> txList,
+            int expectedTripIndex,
+            TransferConstraint expectedConstraint
+    ) {
+        generateTransfersForPatterns(txList);
+        var subject = pattern1.constrainedTransferReverseSearch();
+        int targetStopPos = route1.stopPosition(transferStop);
+
+        int stopIndex = stopIndex(transferStop);
+        int sourceArrivalTime = route2.firstTrip().getStopTime(transferStop).getDepartureTime();
+
+        // Check that transfer exist
+        assertTrue(subject.transferExist(targetStopPos));
+
+        var boarding = subject.find(
+                route1.getTimetable(),
+                route2.firstTrip().getTripSchedule(),
+                stopIndex,
+                sourceArrivalTime
+        );
+
+        assertBoarding(stopIndex, targetStopPos, expectedTripIndex, expectedConstraint, boarding);
+    }
+
+    private void assertBoarding(
+            int stopIndex,
+            int targetStopPos,
+            int expectedTripIndex,
+            TransferConstraint expectedConstraint,
+            RaptorTripScheduleBoardOrAlightEvent<TripSchedule> boarding
+    ) {
+        if(expectedConstraint != null) {
+            assertNotNull(boarding);
+            assertEquals(expectedConstraint, boarding.getTransferConstraint());
+            assertEquals(stopIndex, boarding.getBoardStopIndex());
+            assertEquals(targetStopPos, boarding.getStopPositionInPattern());
+            assertEquals(expectedTripIndex, boarding.getTripIndex());
+        }
+        else {
+            assertNull(boarding);
+        }
+    }
+
+    private void generateTransfersForPatterns(Collection<ConstrainedTransfer> txList) {
+        new TransferIndexGenerator(txList, List.of(pattern1, pattern2), stopIndex)
+                .generateTransfers();
+    }
+}
diff --git a/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/DateMapperTest.java b/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/DateMapperTest.java
index c2bd0719639..616a5192bb9 100644
--- a/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/DateMapperTest.java
+++ b/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/DateMapperTest.java
@@ -1,7 +1,7 @@
 package org.opentripplanner.routing.algorithm.raptor.transit.mappers;
 
 
-import static org.junit.Assert.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.opentripplanner.routing.algorithm.raptor.transit.mappers.DateMapper.asStartOfService;
 
 import java.time.Instant;
@@ -9,7 +9,7 @@
 import java.time.LocalTime;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
-import org.junit.Test;
+import org.junit.jupiter.api.Test;
 
 public class DateMapperTest {
   private static final ZoneId ZONE_ID = ZoneId.of("Europe/Paris");
diff --git a/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/StopIndexForRaptorTest.java b/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/StopIndexForRaptorTest.java
index 7bd18b24bba..e3437afeab7 100644
--- a/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/StopIndexForRaptorTest.java
+++ b/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/mappers/StopIndexForRaptorTest.java
@@ -1,21 +1,28 @@
 package org.opentripplanner.routing.algorithm.raptor.transit.mappers;
 
-import static org.junit.Assert.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 
 import java.util.Arrays;
 import java.util.List;
-import org.junit.Test;
+import java.util.stream.Collectors;
+import lombok.val;
+import org.junit.jupiter.api.Test;
 import org.opentripplanner.model.FeedScopedId;
 import org.opentripplanner.model.Station;
 import org.opentripplanner.model.Stop;
 import org.opentripplanner.model.StopLocation;
+import org.opentripplanner.model.StopPattern;
+import org.opentripplanner.model.StopTime;
 import org.opentripplanner.model.StopTransferPriority;
+import org.opentripplanner.model.TripPattern;
 import org.opentripplanner.model.WgsCoordinate;
 import org.opentripplanner.routing.algorithm.raptor.transit.StopIndexForRaptor;
 import org.opentripplanner.routing.algorithm.raptor.transit.TransitTuningParameters;
 
 public class StopIndexForRaptorTest {
 
+    private final FeedScopedId ANY_ID = new FeedScopedId("F", "1");
+
     private final Stop STOP_0 = Stop.stopForTest("ID-" + 1, 0.0, 0.0);
     private final Stop STOP_1 = Stop.stopForTest("ID-" + 2, 0.0, 0.0);
     private final Stop STOP_2 = Stop.stopForTest("ID-" + 3, 0.0, 0.0);
@@ -32,23 +39,23 @@ public class StopIndexForRaptorTest {
 
     @Test public void listStopIndexesForEmptyTripPattern() {
         StopIndexForRaptor stopIndex = new StopIndexForRaptor(STOPS, TransitTuningParameters.FOR_TEST);
+        val p = new TripPattern(ANY_ID, null, new StopPattern(List.of()));
 
-        int[] result = stopIndex.listStopIndexesForStops(new Stop[0]);
+        int[] result = stopIndex.listStopIndexesForPattern(p);
 
         assertEquals(result.length, 0);
     }
 
 
     @Test public void listStopIndexesForTripPattern() {
-        StopLocation[] input = new Stop[] {
-                STOP_0,
-                STOP_2,
-                STOP_4
-        };
-
-        StopIndexForRaptor stopIndex = new StopIndexForRaptor(STOPS, TransitTuningParameters.FOR_TEST);
+        var stopIndex = new StopIndexForRaptor(STOPS, TransitTuningParameters.FOR_TEST);
+        var tripPattern = new TripPattern(
+                ANY_ID,
+                null,
+                new StopPattern(stopTimes(STOP_0, STOP_2, STOP_4))
+        );
 
-        int[] result = stopIndex.listStopIndexesForStops(input);
+        int[] result = stopIndex.listStopIndexesForPattern(tripPattern);
 
         assertEquals("[0, 2, 4]", Arrays.toString(result));
     }
@@ -68,6 +75,18 @@ public class StopIndexForRaptorTest {
     }
 
     Station createStation(String name, StopTransferPriority pri) {
-        return new Station(new FeedScopedId("F", name), name, new WgsCoordinate(0, 0), null, null, null, null, pri);
+        return new Station(ANY_ID, name, new WgsCoordinate(0, 0), null, null, null, null, pri);
+    }
+
+    private static List<StopTime> stopTimes(Stop ... stops) {
+        return Arrays.stream(stops)
+                .map(StopIndexForRaptorTest::stopTime)
+                .collect(Collectors.toList());
+    }
+
+    private static StopTime stopTime(Stop stop) {
+        val st = new StopTime();
+        st.setStop(stop);
+        return st;
     }
 }
diff --git a/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/request/RaptorRoutingRequestTransitDataCreatorTest.java b/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/request/RaptorRoutingRequestTransitDataCreatorTest.java
index 3e7aa1c26ce..ae896989dce 100644
--- a/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/request/RaptorRoutingRequestTransitDataCreatorTest.java
+++ b/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/request/RaptorRoutingRequestTransitDataCreatorTest.java
@@ -1,7 +1,15 @@
 package org.opentripplanner.routing.algorithm.raptor.transit.request;
 
-import org.junit.Before;
-import org.junit.Test;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
 import org.opentripplanner.model.FeedScopedId;
 import org.opentripplanner.model.Route;
 import org.opentripplanner.model.StopPattern;
@@ -15,15 +23,6 @@
 import org.opentripplanner.routing.trippattern.Deduplicator;
 import org.opentripplanner.routing.trippattern.TripTimes;
 
-import java.time.LocalDate;
-import java.time.ZoneId;
-import java.time.ZonedDateTime;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-
-import static org.junit.Assert.assertEquals;
-
 public class RaptorRoutingRequestTransitDataCreatorTest {
 
   public static final FeedScopedId TP_ID_1 = new FeedScopedId("F", "1");
@@ -36,7 +35,7 @@ public class RaptorRoutingRequestTransitDataCreatorTest {
           new StopPattern(List.of())
   );
 
-  @Before
+  @BeforeEach
   public void setup() {
     TP.getRoute().setMode(TransitMode.BUS);
   }
diff --git a/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/request/RoutingRequestTransitDataProviderFilterTest.java b/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/request/RoutingRequestTransitDataProviderFilterTest.java
index d8f5f7b809f..40240002006 100644
--- a/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/request/RoutingRequestTransitDataProviderFilterTest.java
+++ b/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/request/RoutingRequestTransitDataProviderFilterTest.java
@@ -1,22 +1,30 @@
 package org.opentripplanner.routing.algorithm.raptor.transit.request;
 
-import org.junit.Test;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
-import org.opentripplanner.model.*;
+import org.opentripplanner.model.BikeAccess;
+import org.opentripplanner.model.FeedScopedId;
+import org.opentripplanner.model.Route;
+import org.opentripplanner.model.Stop;
+import org.opentripplanner.model.StopPattern;
+import org.opentripplanner.model.StopTime;
+import org.opentripplanner.model.TransitMode;
+import org.opentripplanner.model.Trip;
+import org.opentripplanner.model.TripAlteration;
+import org.opentripplanner.model.TripPattern;
 import org.opentripplanner.model.modes.AllowedTransitMode;
 import org.opentripplanner.routing.algorithm.raptor.transit.TripPatternForDate;
 import org.opentripplanner.routing.algorithm.raptor.transit.TripPatternWithRaptorStopIndexes;
 import org.opentripplanner.routing.trippattern.Deduplicator;
 import org.opentripplanner.routing.trippattern.TripTimes;
 
-import java.time.LocalDate;
-import java.util.List;
-import java.util.Set;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
-
 public class RoutingRequestTransitDataProviderFilterTest {
 
   private static final FeedScopedId TEST_ROUTE_ID = new FeedScopedId("TEST", "ROUTE");
@@ -135,7 +143,7 @@ private TripPatternForDate createTestTripPatternForDate() {
     TripPattern pattern = new TripPattern(null, route, stopPattern);
 
     TripPatternWithRaptorStopIndexes tripPattern = new TripPatternWithRaptorStopIndexes(
-        new int[0], pattern
+            pattern, new int[0]
     );
 
     TripTimes tripTimes = Mockito.mock(TripTimes.class);
diff --git a/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/request/TestRouteData.java b/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/request/TestRouteData.java
new file mode 100644
index 00000000000..507bb9d99a8
--- /dev/null
+++ b/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/request/TestRouteData.java
@@ -0,0 +1,158 @@
+package org.opentripplanner.routing.algorithm.raptor.transit.request;
+
+import static org.opentripplanner.routing.algorithm.raptor.transit.request.TestTransitCaseData.DATE;
+import static org.opentripplanner.routing.algorithm.raptor.transit.request.TestTransitCaseData.OFFSET;
+import static org.opentripplanner.routing.algorithm.raptor.transit.request.TestTransitCaseData.id;
+import static org.opentripplanner.routing.algorithm.raptor.transit.request.TestTransitCaseData.stopIndex;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.opentripplanner.model.Route;
+import org.opentripplanner.model.Stop;
+import org.opentripplanner.model.StopLocation;
+import org.opentripplanner.model.StopPattern;
+import org.opentripplanner.model.StopTime;
+import org.opentripplanner.model.TransitMode;
+import org.opentripplanner.model.Trip;
+import org.opentripplanner.model.TripPattern;
+import org.opentripplanner.routing.algorithm.raptor.transit.TripPatternForDate;
+import org.opentripplanner.routing.algorithm.raptor.transit.TripPatternWithRaptorStopIndexes;
+import org.opentripplanner.routing.algorithm.raptor.transit.TripSchedule;
+import org.opentripplanner.routing.trippattern.Deduplicator;
+import org.opentripplanner.routing.trippattern.TripTimes;
+import org.opentripplanner.transit.raptor.api.transit.RaptorTimeTable;
+import org.opentripplanner.util.time.TimeUtils;
+
+public class TestRouteData {
+
+    private final Route route;
+    private final List<Trip> trips;
+    private final Map<Trip, List<StopTime>> stopTimesByTrip = new HashMap<>();
+    private final Map<Trip, TripTimes> tripTimesByTrip = new HashMap<>();
+    private final Map<Trip, TripSchedule> tripSchedulesByTrip = new HashMap<>();
+    private final RaptorTimeTable<TripSchedule> timetable;
+    private final TripPatternWithRaptorStopIndexes raptorTripPattern;
+    private Trip currentTrip;
+
+    public TestRouteData(String route, TransitMode mode, List<Stop> stops, String ... times) {
+        final Deduplicator deduplicator = new Deduplicator();
+        this.route = new Route(id(route));
+        this.route.setMode(mode);
+        this.trips = Arrays.stream(times)
+                .map(it -> parseTripInfo(route, it, stops, deduplicator))
+                .collect(Collectors.toList());
+
+        List<StopTime> stopTimesFistTrip = firstTrip().getStopTimes();
+        // Get TripTimes in same order as the trips
+        List<TripTimes> tripTimes = trips.stream()
+                .map(tripTimesByTrip::get)
+                .collect(Collectors.toList());
+
+        raptorTripPattern = new TripPatternWithRaptorStopIndexes(
+                new TripPattern(id("TP:"+route), this.route, new StopPattern(stopTimesFistTrip)),
+                stopIndexes(stopTimesFistTrip)
+        );
+        tripTimes.forEach(t -> raptorTripPattern.getPattern().add(t));
+
+        var listOfTripPatternForDates = List.of(
+                new TripPatternForDate(raptorTripPattern, tripTimes, DATE)
+        );
+
+        var patternForDates = new TripPatternForDates(
+                raptorTripPattern, listOfTripPatternForDates, List.of(OFFSET)
+        );
+        for (Trip trip : trips) {
+            var tripSchedule = new TripScheduleWithOffset(
+                    patternForDates, DATE, tripTimesByTrip.get(trip), OFFSET
+            );
+            tripSchedulesByTrip.put(trip, tripSchedule);
+        }
+
+        this.timetable = patternForDates;
+    }
+
+    private Trip parseTripInfo(String route, String tripTimes, List<Stop> stops, Deduplicator deduplicator) {
+        var trip = new Trip(id(route + "-" + stopTimesByTrip.size()+1));
+        trip.setRoute(this.route);
+        var stopTimes = stopTimes(trip, stops, tripTimes);
+        this.stopTimesByTrip.put(trip, stopTimes);
+        this.tripTimesByTrip.put(trip, new TripTimes(trip, stopTimes, deduplicator));
+        return trip;
+    }
+
+    public Route getRoute() {
+        return route;
+    }
+
+    public Trip trip() {
+        return currentTrip;
+    }
+
+    public TestRouteData firstTrip() {
+        this.currentTrip = trips.get(0);
+        return this;
+    }
+
+    public TestRouteData lastTrip() {
+        this.currentTrip = trips.get(trips.size()-1);
+        return this;
+    }
+
+    public StopTime getStopTime(Stop stop) {
+        return stopTimesByTrip.get(currentTrip).get(stopPosition(stop));
+    }
+
+    public RaptorTimeTable<TripSchedule> getTimetable() {
+        return timetable;
+    }
+
+    public TripSchedule getTripSchedule() {
+        return tripSchedulesByTrip.get(currentTrip);
+    }
+
+    public TripPatternWithRaptorStopIndexes getRaptorTripPattern() {
+        return raptorTripPattern;
+    }
+
+    private List<StopTime> getStopTimes() {
+        return stopTimesByTrip.get(currentTrip);
+    }
+
+    int[] stopIndexes(Collection<StopTime> times) {
+        return times.stream().mapToInt(it -> stopIndex(it.getStop())).toArray();
+    }
+
+    public int stopPosition(StopLocation stop) {
+        List<StopTime> times = firstTrip().getStopTimes();
+        for (int i=0; i<times.size(); ++i) {
+            if(stop == times.get(i).getStop()) { return i; }
+        }
+        throw new IllegalArgumentException();
+    }
+
+    private List<StopTime> stopTimes(Trip trip, List<Stop> stops, String timesAsString) {
+        var times = TimeUtils.times(timesAsString);
+        var stopTimes = new ArrayList<StopTime>();
+        for (int i = 0; i < stops.size(); i++) {
+            stopTimes.add(stopTime(trip, stops.get(i), times[i], i+1));
+        }
+        return stopTimes;
+    }
+
+    private StopTime stopTime(Trip trip, Stop stop, int time, int seq) {
+        var s = new StopTime();
+        s.setTrip(trip);
+        s.setStop(stop);
+        s.setArrivalTime(time);
+        s.setDepartureTime(time);
+        s.setStopSequence(seq);
+        s.setStopHeadsign("NA");
+        s.setRouteShortName("NA");
+        return s;
+    }
+}
diff --git a/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/request/TestTransitCaseData.java b/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/request/TestTransitCaseData.java
new file mode 100644
index 00000000000..69b6bc147cd
--- /dev/null
+++ b/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/request/TestTransitCaseData.java
@@ -0,0 +1,49 @@
+package org.opentripplanner.routing.algorithm.raptor.transit.request;
+
+import java.time.LocalDate;
+import org.opentripplanner.model.FeedScopedId;
+import org.opentripplanner.model.Station;
+import org.opentripplanner.model.Stop;
+import org.opentripplanner.model.StopLocation;
+
+public final class TestTransitCaseData {
+    public static final Station STATION_A = Station.stationForTest("A", 60.0, 11.1);
+    public static final Station STATION_B = Station.stationForTest("B", 61.0, 11.5);
+
+    public static final Stop STOP_A = Stop.stopForTest("A", 60.0, 11.0, STATION_A);
+    public static final Stop STOP_B = Stop.stopForTest("B", 60.0, 11.2, STATION_B);
+    public static final Stop STOP_C = Stop.stopForTest("C", 61.0, 11.4);
+    public static final Stop STOP_D = Stop.stopForTest("D", 61.0, 11.6);
+
+    // Random order stop indexes - should be different from stopPos in pattern to
+    // make sure code-under-test do not mix stopIndex and stopPosition
+    public static final Stop[] RAPTOR_STOP_INDEX = { STOP_D, STOP_A, STOP_C, STOP_B };
+
+    public static final LocalDate DATE = LocalDate.of(2021, 12, 24);
+
+    public static final int OFFSET = 0;
+
+
+    public static FeedScopedId id(String id) {
+        return new FeedScopedId("F", id);
+    }
+
+    public static int stopIndex(StopLocation stop) {
+        for (int i=0;i< RAPTOR_STOP_INDEX.length;++i) {
+            if(stop == RAPTOR_STOP_INDEX[i]) { return i; }
+        }
+        throw new IllegalArgumentException();
+    }
+
+    static {
+        setupStationStopRelationship(STATION_A, STOP_A);
+        setupStationStopRelationship(STATION_B, STOP_B);
+    }
+
+    private static void setupStationStopRelationship(Station station, Stop ... stops) {
+        for (Stop stop : stops) {
+            station.addChildStop(stop);
+            stop.setParentStation(station);
+        }
+    }
+}
diff --git a/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/request/TripPatternWithId.java b/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/request/TripPatternWithId.java
index 6cd8d0ecd85..a89401c9fd3 100644
--- a/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/request/TripPatternWithId.java
+++ b/src/test/java/org/opentripplanner/routing/algorithm/raptor/transit/request/TripPatternWithId.java
@@ -4,14 +4,14 @@
 import org.opentripplanner.routing.algorithm.raptor.transit.TripPatternWithRaptorStopIndexes;
 
 public class TripPatternWithId extends TripPatternWithRaptorStopIndexes {
-  private FeedScopedId id;
+  private final FeedScopedId id;
 
   public TripPatternWithId(
       FeedScopedId id,
       int[] stopIndexes,
       org.opentripplanner.model.TripPattern originalTripPattern
   ) {
-    super(stopIndexes, originalTripPattern);
+    super(originalTripPattern, stopIndexes);
     this.id = id;
   }
 
diff --git a/src/test/java/org/opentripplanner/routing/algorithm/transferoptimization/services/OptimizePathDomainServiceTest.java b/src/test/java/org/opentripplanner/routing/algorithm/transferoptimization/services/OptimizePathDomainServiceTest.java
index 00dffe9792f..5814df3340e 100644
--- a/src/test/java/org/opentripplanner/routing/algorithm/transferoptimization/services/OptimizePathDomainServiceTest.java
+++ b/src/test/java/org/opentripplanner/routing/algorithm/transferoptimization/services/OptimizePathDomainServiceTest.java
@@ -254,7 +254,7 @@ public void testConstrainedTransferIsPreferred() {
         );
         // Verify the attached Transfer is exist and is valid
         assertEquals(
-                "ConstrainedTransfer{from: (trip: BUS T1:10:02, stopPos: 2), to: (trip: BUS T2:10:13, stopPos: 1), constraint: {guaranteed}}",
+                "ConstrainedTransfer{from: <Trip BUS T1:10:02, stopPos 2>, to: <Trip BUS T2:10:13, stopPos 1>, constraint: {guaranteed}}",
                 it.accessLeg().nextLeg().asTransitLeg().getConstrainedTransferAfterLeg().toString()
         );
     }
diff --git a/src/test/java/org/opentripplanner/routing/algorithm/transferoptimization/services/TransferGeneratorTest.java b/src/test/java/org/opentripplanner/routing/algorithm/transferoptimization/services/TransferGeneratorTest.java
index fb69123fa4a..e2945b41376 100644
--- a/src/test/java/org/opentripplanner/routing/algorithm/transferoptimization/services/TransferGeneratorTest.java
+++ b/src/test/java/org/opentripplanner/routing/algorithm/transferoptimization/services/TransferGeneratorTest.java
@@ -10,7 +10,6 @@
 import java.util.List;
 import java.util.stream.Collectors;
 import org.junit.jupiter.api.Test;
-import org.opentripplanner.routing.algorithm.raptor.transit.SlackProvider;
 import org.opentripplanner.transit.raptor._data.RaptorTestConstants;
 import org.opentripplanner.transit.raptor._data.api.TestPathBuilder;
 import org.opentripplanner.transit.raptor._data.transit.TestRoute;
@@ -330,6 +329,48 @@ void findTransferWithBoardingForbiddenAtDifferentStop() {
         );
     }
 
+    @Test
+    void findTransferWithNotAllowedConstrainedTransfer() {
+        // given 3 possible expected transfers
+        var expBxB = "TripToTripTransfer{from: [2 10:10 BUS L1], to: [2 10:20 BUS L2]}";
+        var expCxD = "TripToTripTransfer{from: [3 10:20 BUS L1], to: [4 10:30 BUS L2], transfer: On-Street 1m ~ 4}";
+        var expExE = "TripToTripTransfer{from: [5 10:30 BUS L1], to: [5 10:40 BUS L2]}";
+
+        var l1 = route("L1", STOP_A, STOP_B, STOP_C, STOP_E)
+                .withTimetable(schedule("10:00 10:10 10:20 10:30"));
+
+        var l2 = route("L2", STOP_B, STOP_D, STOP_E, STOP_F)
+                .withTimetable(schedule("10:20 10:30 10:40 10:50"));
+
+        data.withRoutes(l1, l2).withTransfer(STOP_C, walk(STOP_D, D1m));
+
+        final Path<TestTripSchedule> path = pathBuilder
+                .access(ACCESS_START, ACCESS_DURATION, STOP_A)
+                .bus(l1.getTripSchedule(0), STOP_C)
+                .walk(D1m, STOP_D)
+                .bus(l2.getTripSchedule(0), STOP_F)
+                .egress(D1m);
+
+        var transitLegs = path.transitLegs().collect(Collectors.toList());
+        var subject = new TransferGenerator<>(TS_ADAPTOR, SLACK_PROVIDER, data);
+
+        var tripA = l1.getTripSchedule(0);
+        var tripB = l2.getTripSchedule(0);
+
+        data.withConstrainedTransfer(tripA, STOP_B, tripB, STOP_B, TestTransitData.TX_NOT_ALLOWED);
+        var result = subject.findAllPossibleTransfers(transitLegs);
+
+        // The same stop transfer is no longer an option
+        assertEquals("[[" + expCxD + ", " + expExE + "]]", result.toString());
+
+        data.clearConstrainedTransfers();
+        data.withConstrainedTransfer(tripA, STOP_C, tripB, STOP_D, TestTransitData.TX_NOT_ALLOWED);
+        result = subject.findAllPossibleTransfers(transitLegs);
+
+        // The same stop transfer is no longer an option
+        assertEquals("[[" + expBxB + ", " + expExE + "]]", result.toString());
+    }
+
     @Test
     void findDependedTransfersForThreeRoutes() {
         TestRoute l1 = route("L1", STOP_A, STOP_B, STOP_C)
diff --git a/src/test/java/org/opentripplanner/routing/graph/RoutingServiceTest.java b/src/test/java/org/opentripplanner/routing/graph/RoutingServiceTest.java
index 765e8b4aacd..0660101729a 100644
--- a/src/test/java/org/opentripplanner/routing/graph/RoutingServiceTest.java
+++ b/src/test/java/org/opentripplanner/routing/graph/RoutingServiceTest.java
@@ -70,7 +70,8 @@ public void testPatternsCoherent() {
         }
         for (var stop : graph.index.getAllStops()) {
             for (TripPattern pattern : graph.index.getPatternsForStop(stop)) {
-                assertTrue(pattern.getStopPattern().containsStop(stop.getId().toString()));
+                int stopPos = pattern.findStopPosition(stop);
+                assertTrue("Stop position exist", stopPos >= 0);
             }
         }
     }
diff --git a/src/test/java/org/opentripplanner/transit/raptor/_data/api/TestPathBuilderTest.java b/src/test/java/org/opentripplanner/transit/raptor/_data/api/TestPathBuilderTest.java
index 4b8b19f19fa..14a5986c607 100644
--- a/src/test/java/org/opentripplanner/transit/raptor/_data/api/TestPathBuilderTest.java
+++ b/src/test/java/org/opentripplanner/transit/raptor/_data/api/TestPathBuilderTest.java
@@ -1,6 +1,7 @@
 package org.opentripplanner.transit.raptor._data.api;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.opentripplanner.model.transfer.TransferConstraint.REGULAR_TRANSFER;
 import static org.opentripplanner.transit.raptor._data.stoparrival.BasicPathTestCase.ACCESS_DURATION;
 import static org.opentripplanner.transit.raptor._data.stoparrival.BasicPathTestCase.ACCESS_START;
 import static org.opentripplanner.transit.raptor._data.stoparrival.BasicPathTestCase.BASIC_PATH_AS_DETAILED_STRING;
@@ -46,7 +47,12 @@ public void testSimplePathWithOneTransit() {
 
     var transitLeg = path.accessLeg().nextLeg().asTransitLeg();
     int boardCost = COST_CALCULATOR.boardingCost(
-        true, path.accessLeg().toTime(), STOP_A, transitLeg.fromTime(), transitLeg.trip(), null
+            true,
+            path.accessLeg().toTime(),
+            STOP_A,
+            transitLeg.fromTime(),
+            transitLeg.trip(),
+            REGULAR_TRANSFER
     );
 
     int transitCost = COST_CALCULATOR.transitArrivalCost(
diff --git a/src/test/java/org/opentripplanner/transit/raptor/_data/stoparrival/BasicPathTestCase.java b/src/test/java/org/opentripplanner/transit/raptor/_data/stoparrival/BasicPathTestCase.java
index f7ef5dee1e0..cbd5e0a219e 100644
--- a/src/test/java/org/opentripplanner/transit/raptor/_data/stoparrival/BasicPathTestCase.java
+++ b/src/test/java/org/opentripplanner/transit/raptor/_data/stoparrival/BasicPathTestCase.java
@@ -1,6 +1,7 @@
 package org.opentripplanner.transit.raptor._data.stoparrival;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.opentripplanner.model.transfer.TransferConstraint.REGULAR_TRANSFER;
 import static org.opentripplanner.routing.algorithm.raptor.transit.cost.RaptorCostConverter.toRaptorCost;
 import static org.opentripplanner.transit.raptor._data.transit.TestTransfer.walk;
 import static org.opentripplanner.transit.raptor._data.transit.TestTripPattern.pattern;
@@ -329,7 +330,7 @@ private static int transitArrivalCost(
     ) {
         boolean firstTransit = TRIP_1 == trip;
         int boardCost = COST_CALCULATOR.boardingCost(
-                firstTransit, prevArrivalTime, boardStop, boardTime, trip, null
+                firstTransit, prevArrivalTime, boardStop, boardTime, trip, REGULAR_TRANSFER
         );
 
         return COST_CALCULATOR.transitArrivalCost(
diff --git a/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestConstrainedBoardingSearch.java b/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestConstrainedBoardingSearch.java
index 6ad8df996ab..75bb096ceff 100644
--- a/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestConstrainedBoardingSearch.java
+++ b/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestConstrainedBoardingSearch.java
@@ -5,6 +5,7 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.function.BiPredicate;
 import java.util.stream.Collectors;
 import javax.annotation.Nullable;
 import org.opentripplanner.model.base.ToStringBuilder;
@@ -16,14 +17,17 @@
 public class TestConstrainedBoardingSearch
         implements RaptorConstrainedTripScheduleBoardingSearch<TestTripSchedule> {
 
-    private static final TransferConstraint GUARANTEED = TransferConstraint.create().guaranteed().build();
-
-
     /** Index of guaranteed transfers by fromStopPos */
     private final TIntObjectMap<List<TestConstrainedTransferBoarding>> transfersByFromStopPos = new TIntObjectHashMap<>();
 
     private int currentTargetStopPos;
 
+    private final BiPredicate<Integer, Integer> timeAfterOrEqual;
+
+    TestConstrainedBoardingSearch(boolean forward) {
+        this.timeAfterOrEqual = forward ? (a,b) -> a >= b : (a,b) -> a <= b;
+    }
+
     @Override
     public boolean transferExist(int targetStopPos) {
         this.currentTargetStopPos = targetStopPos;
@@ -43,7 +47,8 @@ public RaptorTripScheduleBoardOrAlightEvent<TestTripSchedule> find(
             var trip = tx.getSourceTrip();
             if(trip == sourceTrip) {
                 int stopPos = trip.findDepartureStopPosition(sourceArrivalTime, sourceStopIndex);
-                if(tx.getSourceStopPos() == stopPos) {
+                boolean boardAlightPossible = timeAfterOrEqual.test(tx.getTime(), sourceArrivalTime);
+                if(tx.getSourceStopPos() == stopPos && boardAlightPossible) {
                     return tx;
                 }
             }
@@ -65,13 +70,14 @@ public List<TestConstrainedTransferBoarding> constrainedBoardings() {
      * The the {@code source/target} is the trips in order of the search direction (forward or
      * reverse). For reverse search it is the opposite from {@code from/to} in the result path.
      */
-    void addGuaranteedTransfers(
+    void addConstraintTransfers(
             TestTripSchedule sourceTrip,
             int sourceStopPos,
             TestTripSchedule targetTrip,
             int targetTripIndex,
             int targetStopPos,
-            int targetTime
+            int targetTime,
+            TransferConstraint constraint
     ) {
         List<TestConstrainedTransferBoarding> list = transfersByFromStopPos.get(targetStopPos);
         if(list == null) {
@@ -79,7 +85,7 @@ void addGuaranteedTransfers(
             transfersByFromStopPos.put(targetStopPos, list);
         }
         list.add(new TestConstrainedTransferBoarding(
-                GUARANTEED,
+                constraint,
                 sourceTrip, sourceStopPos,
                 targetTrip, targetTripIndex, targetStopPos, targetTime
         ));
@@ -93,4 +99,7 @@ public String toString() {
                 .toString();
     }
 
+    void clear() {
+        transfersByFromStopPos.clear();
+    }
 }
diff --git a/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestRoute.java b/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestRoute.java
index f2e3ca75fc9..73bce82771d 100644
--- a/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestRoute.java
+++ b/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestRoute.java
@@ -5,6 +5,7 @@
 import java.util.Collections;
 import java.util.List;
 import org.opentripplanner.model.base.ToStringBuilder;
+import org.opentripplanner.model.transfer.TransferConstraint;
 import org.opentripplanner.transit.raptor.api.transit.RaptorConstrainedTripScheduleBoardingSearch;
 import org.opentripplanner.transit.raptor.api.transit.RaptorRoute;
 import org.opentripplanner.transit.raptor.api.transit.RaptorTimeTable;
@@ -15,9 +16,9 @@ public class TestRoute implements RaptorRoute<TestTripSchedule>, RaptorTimeTable
     private final TestTripPattern pattern;
     private final List<TestTripSchedule> schedules = new ArrayList<>();
     private final TestConstrainedBoardingSearch transferConstraintsForwardSearch =
-            new TestConstrainedBoardingSearch();
+            new TestConstrainedBoardingSearch(true);
     private final TestConstrainedBoardingSearch transferConstraintsReverseSearch =
-            new TestConstrainedBoardingSearch();
+            new TestConstrainedBoardingSearch(false);
 
 
     private TestRoute(TestTripPattern pattern) {
@@ -87,35 +88,43 @@ public String toString() {
                 .toString();
     }
 
-    void addGuaranteedTxForwardSearch(
-            TestTripSchedule sourceTrip,
-            int sourceStopPos,
-            TestTripSchedule targetTrip,
-            int targetTripIndex,
-            int targetStopPos
-    ) {
-        final int targetTime = targetTrip.arrival(targetStopPos);
-
-        this.transferConstraintsForwardSearch.addGuaranteedTransfers(
-                sourceTrip, sourceStopPos, targetTrip, targetTripIndex, targetStopPos, targetTime
-        );
+    void clearTransferConstraints() {
+        transferConstraintsForwardSearch.clear();
+        transferConstraintsReverseSearch.clear();
     }
 
     /**
-     * Reverse search transfer, the {@code source/target} is the trips in order of the reverse
-     * search, which is opposite from {@code from/to} in the result path.
+     * Add a transfer constraint to the route by iterating over all trips and matching
+     * the provided {@code toTrip}(added to forward search) {@code fromTrip}(added to reverse
+     * search) with the rips in the route timetable.
      */
-    void addGuaranteedTxReverseSearch(
-            TestTripSchedule sourceTrip,
-            int sourceStopPos,
-            TestTripSchedule targetTrip,
-            int targetTripIndex,
-            int targetStopPos
+    void addTransferConstraint(
+            TestTripSchedule fromTrip,
+            int fromStopPos,
+            TestTripSchedule toTrip,
+            int toStopPos,
+            TransferConstraint constraint
     ) {
-        final int targetTime = targetTrip.departure(targetStopPos);
-        // This is used in the revers search
-        this.transferConstraintsReverseSearch.addGuaranteedTransfers(
-                sourceTrip, sourceStopPos, targetTrip, targetTripIndex, targetStopPos, targetTime
-        );
+        for (int i = 0; i < timetable().numberOfTripSchedules(); i++) {
+            var trip = timetable().getTripSchedule(i);
+            if(toTrip == trip) {
+                this.transferConstraintsForwardSearch.addConstraintTransfers(
+                        fromTrip, fromStopPos,
+                        trip, i, toStopPos,
+                        trip.arrival(toStopPos),
+                        constraint
+                );
+            }
+            // Reverse search transfer, the {@code source/target} is the trips in order of the
+            // reverse search, which is opposite from {@code from/to} in the result path.
+            if(fromTrip == trip) {
+                this.transferConstraintsReverseSearch.addConstraintTransfers(
+                        toTrip, toStopPos,
+                        trip, i, fromStopPos,
+                        trip.departure(fromStopPos),
+                        constraint
+                );
+            }
+        }
     }
 }
diff --git a/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestTransferPoint.java b/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestTransferPoint.java
index 4148251d191..a356cfe47af 100644
--- a/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestTransferPoint.java
+++ b/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestTransferPoint.java
@@ -6,13 +6,22 @@
 public class TestTransferPoint implements TransferPoint {
     private final int stop;
     private final TestTripSchedule schedule;
+    private final boolean applyToAllTrips;
 
     public TestTransferPoint(
             int stop,
-            TestTripSchedule schedule
+            TestTripSchedule schedule,
+            boolean applyToAllTrips
+
     ) {
         this.stop = stop;
         this.schedule = schedule;
+        this.applyToAllTrips = applyToAllTrips;
+    }
+
+    @Override
+    public boolean appliesToAllTrips() {
+        return applyToAllTrips;
     }
 
     public int getStopPosition() {
diff --git a/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestTransitData.java b/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestTransitData.java
index 83e32fa5944..5dbcd1ecb98 100644
--- a/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestTransitData.java
+++ b/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestTransitData.java
@@ -31,14 +31,16 @@
 @SuppressWarnings("UnusedReturnValue")
 public class TestTransitData implements RaptorTransitDataProvider<TestTripSchedule>, RaptorTestConstants {
 
-  private static final TransferConstraint GUARANTEED = TransferConstraint.create()
-          .guaranteed().build();
+  public static final TransferConstraint TX_GUARANTEED = TransferConstraint.create().guaranteed()
+          .build();
+  public static final TransferConstraint TX_NOT_ALLOWED = TransferConstraint.create().notAllowed()
+          .build();
 
   private final List<List<RaptorTransfer>> transfersFromStop = new ArrayList<>();
   private final List<List<RaptorTransfer>> transfersToStop = new ArrayList<>();
   private final List<Set<TestRoute>> routesByStop = new ArrayList<>();
   private final List<TestRoute> routes = new ArrayList<>();
-  private final List<ConstrainedTransfer> guaranteedTransfers = new ArrayList<>();
+  private final List<ConstrainedTransfer> constrainedTransfers = new ArrayList<>();
   private final McCostParamsBuilder costParamsBuilder = new McCostParamsBuilder();
 
   @Override
@@ -174,27 +176,34 @@ public TestTransitData withTransfer(int fromStop, TestTransfer transfer) {
   public TestTransitData withGuaranteedTransfer(
           TestTripSchedule fromTrip, int fromStop,
           TestTripSchedule toTrip, int toStop
+  ) {
+    return withConstrainedTransfer(fromTrip, fromStop, toTrip, toStop, TX_GUARANTEED);
+  }
+
+  public void clearConstrainedTransfers() {
+    constrainedTransfers.clear();
+    for (TestRoute route : routes) {
+      route.clearTransferConstraints();
+    }
+  }
+
+  public TestTransitData withConstrainedTransfer(
+          TestTripSchedule fromTrip, int fromStop,
+          TestTripSchedule toTrip, int toStop,
+          TransferConstraint constraint
   ) {
     int fromStopPos = fromTrip.pattern().findStopPositionAfter(0, fromStop);
     int toStopPos = toTrip.pattern().findStopPositionAfter(0, toStop);
 
     for (TestRoute route : routes) {
-      for (int i = 0; i < route.timetable().numberOfTripSchedules(); i++) {
-        var trip = route.timetable().getTripSchedule(i);
-        if(toTrip == trip) {
-          route.addGuaranteedTxForwardSearch(fromTrip, fromStopPos, trip, i, toStopPos);
-        }
-        if(fromTrip == trip) {
-          route.addGuaranteedTxReverseSearch(toTrip, toStopPos, trip, i, fromStopPos);
-        }
-      }
+      route.addTransferConstraint(fromTrip, fromStopPos, toTrip, toStopPos, constraint);
     }
-    guaranteedTransfers.add(
+    constrainedTransfers.add(
         new ConstrainedTransfer(
             null,
-            new TestTransferPoint(fromStop, fromTrip),
-            new TestTransferPoint(toStop, toTrip),
-            GUARANTEED
+            new TestTransferPoint(fromStop, fromTrip, false),
+            new TestTransferPoint(toStop, toTrip, false),
+            constraint
         )
     );
     return this;
@@ -204,13 +213,13 @@ public McCostParamsBuilder mcCostParamsBuilder() {
     return costParamsBuilder;
   }
 
-  public ConstrainedTransfer findGuaranteedTransfer(
+  public ConstrainedTransfer findConstrainedTransfer(
           TestTripSchedule fromTrip,
           int fromStop,
           TestTripSchedule toTrip,
           int toStop
   ) {
-    for (ConstrainedTransfer tx : guaranteedTransfers) {
+    for (ConstrainedTransfer tx : constrainedTransfers) {
       if(
           ((TestTransferPoint)tx.getFrom()).matches(fromTrip, fromStop) &&
           ((TestTransferPoint)tx.getTo()).matches(toTrip, toStop)
@@ -226,7 +235,7 @@ public TransferServiceAdaptor<TestTripSchedule> transferServiceAdaptor() {
       @Override protected ConstrainedTransfer findTransfer(
               TripStopTime<TestTripSchedule> from, TestTripSchedule toTrip, int toStop
       ) {
-        return findGuaranteedTransfer(from.trip(), from.stop(), toTrip, toStop);
+        return findConstrainedTransfer(from.trip(), from.stop(), toTrip, toStop);
       }
     };
   }
diff --git a/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestTripSchedule.java b/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestTripSchedule.java
index 294024592f5..9d9734a04f1 100644
--- a/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestTripSchedule.java
+++ b/src/test/java/org/opentripplanner/transit/raptor/_data/transit/TestTripSchedule.java
@@ -18,7 +18,7 @@ public class TestTripSchedule implements RaptorTripSchedule {
     private final int transitReluctanceIndex;
 
 
-    private TestTripSchedule(
+    protected TestTripSchedule(
         TestTripPattern pattern,
         int[] arrivalTimes,
         int[] departureTimes,
diff --git a/src/test/java/org/opentripplanner/transit/raptor/moduletests/E02_NotAllowedConstrainedTransferTest.java b/src/test/java/org/opentripplanner/transit/raptor/moduletests/E02_NotAllowedConstrainedTransferTest.java
new file mode 100644
index 00000000000..c86b63899e1
--- /dev/null
+++ b/src/test/java/org/opentripplanner/transit/raptor/moduletests/E02_NotAllowedConstrainedTransferTest.java
@@ -0,0 +1,126 @@
+package org.opentripplanner.transit.raptor.moduletests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.opentripplanner.transit.raptor._data.api.PathUtils.pathsToString;
+import static org.opentripplanner.transit.raptor._data.transit.TestRoute.route;
+import static org.opentripplanner.transit.raptor._data.transit.TestTransfer.walk;
+import static org.opentripplanner.transit.raptor._data.transit.TestTripSchedule.schedule;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.opentripplanner.transit.raptor.RaptorService;
+import org.opentripplanner.transit.raptor._data.RaptorTestConstants;
+import org.opentripplanner.transit.raptor._data.transit.TestTransitData;
+import org.opentripplanner.transit.raptor._data.transit.TestTripSchedule;
+import org.opentripplanner.transit.raptor.api.request.Optimization;
+import org.opentripplanner.transit.raptor.api.request.RaptorProfile;
+import org.opentripplanner.transit.raptor.api.request.RaptorRequestBuilder;
+import org.opentripplanner.transit.raptor.api.request.SearchDirection;
+import org.opentripplanner.transit.raptor.rangeraptor.configure.RaptorConfig;
+
+/**
+ * FEATURE UNDER TEST
+ * <p>
+ * Raptor should NOT return a path with a NOT-ALLOWED transfer, instead it should try to find
+ * another option.
+ */
+public class E02_NotAllowedConstrainedTransferTest implements RaptorTestConstants {
+
+    private final TestTransitData data = new TestTransitData();
+    private final RaptorRequestBuilder<TestTripSchedule> requestBuilder =
+            new RaptorRequestBuilder<>();
+    private final RaptorService<TestTripSchedule> raptorService =
+            new RaptorService<>(RaptorConfig.defaultConfigForTest());
+
+    private static final String EXP_PATH = "Walk 30s ~ A ~ BUS R1 0:02 0:05 ~ B "
+            + "~ BUS R3 0:15 0:20 ~ C ~ Walk 30s [0:01:30 0:20:30 19m";
+    private static final String EXP_PATH_NO_COST = EXP_PATH + "]";
+    private static final String EXP_PATH_WITH_COST = EXP_PATH + " $2500]";
+
+    /**
+     * Schedule: Stop:   1       2       3 R1: 00:02 - 00:05 R2:         00:05 - 00:10
+     * <p>
+     * Access(stop 1) and egress(stop 3) is 30s.
+     */
+    @BeforeEach
+    public void setup() {
+        var r1 = route("R1", STOP_A, STOP_B)
+                .withTimetable(schedule("0:02 0:05"));
+        var r2 = route("R2", STOP_B, STOP_C)
+                .withTimetable(
+                        schedule("0:10 0:15"),
+                        // Add another schedule - should not be used even if the not-allowed is
+                        // attached to the first one - not-allowed in Raptor apply to the Route.
+                        // The trip/timetable search should handle not-allowed on trip level.
+                        schedule("0:12 0:17")
+                );
+        var r3 = route("R3", STOP_B, STOP_C)
+                .withTimetable(schedule("0:15 0:20"));
+
+        var tripA = r1.timetable().getTripSchedule(0);
+        var tripB = r2.timetable().getTripSchedule(0);
+
+        data.withRoutes(r1, r2, r3);
+
+        // Apply not-allowed on the first trip from R1 and R2 - when found this will apply to
+        // the second trip in R2 as well. This is of cause not a correct way to implement the
+        // transit model, a not-allowed transfer should apply to ALL trips if constraint is passed
+        // to raptor.
+        data.withConstrainedTransfer(tripA, STOP_B, tripB, STOP_B, TestTransitData.TX_NOT_ALLOWED);
+        data.mcCostParamsBuilder().transferCost(100);
+
+        requestBuilder.searchParams()
+                .constrainedTransfersEnabled(true)
+                .addAccessPaths(walk(STOP_A, D30s))
+                .addEgressPaths(walk(STOP_C, D30s))
+                .earliestDepartureTime(T00_00)
+                .latestArrivalTime(T00_30)
+                .timetableEnabled(true);
+
+        ModuleTestDebugLogging.setupDebugLogging(data, requestBuilder);
+    }
+
+    @Test
+    public void standardOneIteration() {
+        var request = requestBuilder
+                .profile(RaptorProfile.STANDARD)
+                .searchParams().searchOneIterationOnly()
+                .build();
+        var response = raptorService.route(request, data);
+        assertEquals(EXP_PATH_NO_COST, pathsToString(response));
+    }
+
+    @Test
+    public void standardDynamicSearchWindow() {
+        var request = requestBuilder
+                .profile(RaptorProfile.STANDARD)
+                .build();
+        var response = raptorService.route(request, data);
+        assertEquals(EXP_PATH_NO_COST, pathsToString(response));
+    }
+
+    @Test
+    public void standardReverseOneIteration() {
+        var request = requestBuilder
+                .searchDirection(SearchDirection.REVERSE)
+                .profile(RaptorProfile.STANDARD)
+                .searchParams().searchOneIterationOnly()
+                .build();
+
+        var response = raptorService.route(request, data);
+
+        assertEquals(EXP_PATH_NO_COST, pathsToString(response));
+    }
+
+    @Test
+    public void multiCriteria() {
+        requestBuilder.optimizations().add(Optimization.PARETO_CHECK_AGAINST_DESTINATION);
+        var request = requestBuilder
+                .profile(RaptorProfile.MULTI_CRITERIA)
+                .build();
+
+        var response = raptorService.route(request, data);
+
+        assertEquals(EXP_PATH_WITH_COST, pathsToString(response));
+    }
+}
\ No newline at end of file
diff --git a/src/test/java/org/opentripplanner/updater/stoptime/TimetableSnapshotSourceTest.java b/src/test/java/org/opentripplanner/updater/stoptime/TimetableSnapshotSourceTest.java
index c4589eb23c7..a5cebd54e41 100644
--- a/src/test/java/org/opentripplanner/updater/stoptime/TimetableSnapshotSourceTest.java
+++ b/src/test/java/org/opentripplanner/updater/stoptime/TimetableSnapshotSourceTest.java
@@ -1,10 +1,21 @@
 package org.opentripplanner.updater.stoptime;
 
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.opentripplanner.gtfs.GtfsContextBuilder.contextBuilder;
+
 import com.google.protobuf.InvalidProtocolBufferException;
 import com.google.transit.realtime.GtfsRealtime.TripDescriptor;
 import com.google.transit.realtime.GtfsRealtime.TripUpdate;
 import com.google.transit.realtime.GtfsRealtime.TripUpdate.StopTimeEvent;
 import com.google.transit.realtime.GtfsRealtime.TripUpdate.StopTimeUpdate;
+import java.text.ParseException;
+import java.util.Arrays;
+import java.util.Calendar;
 import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Ignore;
@@ -14,7 +25,6 @@
 import org.opentripplanner.gtfs.GtfsContext;
 import org.opentripplanner.model.FeedScopedId;
 import org.opentripplanner.model.PickDrop;
-import org.opentripplanner.model.Stop;
 import org.opentripplanner.model.Timetable;
 import org.opentripplanner.model.TimetableSnapshot;
 import org.opentripplanner.model.Trip;
@@ -25,18 +35,6 @@
 import org.opentripplanner.routing.trippattern.RealTimeState;
 import org.opentripplanner.routing.trippattern.TripTimes;
 
-import java.text.ParseException;
-import java.util.Arrays;
-import java.util.Calendar;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNotSame;
-import static org.junit.Assert.assertSame;
-import static org.junit.Assert.assertTrue;
-import static org.opentripplanner.gtfs.GtfsContextBuilder.contextBuilder;
-
 /**
  * TODO OTP2 - Test is too close to the implementation and will need to be reimplemented.
  */
@@ -126,8 +124,8 @@ public void testHandleCanceledTrip() throws InvalidProtocolBufferException {
 
         final TripTimes tripTimes = forToday.getTripTimes(tripIndex);
         for (int i = 0; i < tripTimes.getNumStops(); i++) {
-            assertEquals(PickDrop.CANCELLED, pattern.getStopPattern().getPickup(i));
-            assertEquals(PickDrop.CANCELLED, pattern.getStopPattern().getDropoff(i));
+            assertEquals(PickDrop.CANCELLED, pattern.getBoardType(i));
+            assertEquals(PickDrop.CANCELLED, pattern.getAlightType(i));
         }
         assertEquals(RealTimeState.CANCELED, tripTimes.getRealTimeState());
     }