diff --git a/src/main/java/org/openrewrite/java/migrate/joda/JodaTimeFlowSpec.java b/src/main/java/org/openrewrite/java/migrate/joda/JodaTimeFlowSpec.java
new file mode 100644
index 0000000000..3639b708a3
--- /dev/null
+++ b/src/main/java/org/openrewrite/java/migrate/joda/JodaTimeFlowSpec.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2024 the original author or authors.
+ * <p>
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * https://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.migrate.joda;
+
+import lombok.NonNull;
+import org.openrewrite.analysis.dataflow.DataFlowNode;
+import org.openrewrite.analysis.dataflow.DataFlowSpec;
+import org.openrewrite.java.tree.J;
+import org.openrewrite.java.tree.JavaType;
+
+import static org.openrewrite.java.migrate.joda.templates.TimeClassNames.JODA_CLASS_PATTERN;
+
+public class JodaTimeFlowSpec extends DataFlowSpec {
+
+    @Override
+    public boolean isSource(@NonNull DataFlowNode srcNode) {
+        Object value = srcNode.getCursor().getParentTreeCursor().getValue();
+
+        if (value instanceof J.Assignment && ((J.Assignment) value).getVariable() instanceof J.Identifier) {
+            return isJodaType(((J.Assignment) value).getVariable().getType());
+        }
+
+        if (value instanceof J.VariableDeclarations.NamedVariable) {
+            return isJodaType(((J.VariableDeclarations.NamedVariable) value).getType());
+        }
+        return false;
+    }
+
+    @Override
+    public boolean isSink(@NonNull DataFlowNode sinkNode) {
+        Object value = sinkNode.getCursor().getValue();
+        Object parent = sinkNode.getCursor().getParentTreeCursor().getValue();
+        if (parent instanceof J.MethodInvocation) {
+            J.MethodInvocation method = (J.MethodInvocation) parent;
+            return (method.getSelect() != null && method.getSelect().equals(value)) ||
+                   method.getArguments().stream().anyMatch(a -> a.equals(value));
+        }
+        return parent instanceof J.VariableDeclarations.NamedVariable ||
+               parent instanceof J.NewClass ||
+               parent instanceof J.Assignment ||
+               parent instanceof J.Return;
+    }
+
+    static boolean isJodaType(JavaType type) {
+        if (!(type instanceof JavaType.Class)) {
+            return false;
+        }
+        return type.isAssignableFrom(JODA_CLASS_PATTERN);
+    }
+}
diff --git a/src/main/java/org/openrewrite/java/migrate/joda/JodaTimeScanner.java b/src/main/java/org/openrewrite/java/migrate/joda/JodaTimeScanner.java
new file mode 100644
index 0000000000..9df8f6141b
--- /dev/null
+++ b/src/main/java/org/openrewrite/java/migrate/joda/JodaTimeScanner.java
@@ -0,0 +1,281 @@
+/*
+ * Copyright 2024 the original author or authors.
+ * <p>
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * https://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.migrate.joda;
+
+import fj.data.Option;
+import lombok.Getter;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.Value;
+import org.openrewrite.Cursor;
+import org.openrewrite.ExecutionContext;
+import org.openrewrite.analysis.dataflow.Dataflow;
+import org.openrewrite.analysis.dataflow.analysis.SinkFlowSummary;
+import org.openrewrite.java.JavaIsoVisitor;
+import org.openrewrite.java.tree.Expression;
+import org.openrewrite.java.tree.J;
+import org.openrewrite.java.tree.J.VariableDeclarations.NamedVariable;
+import org.openrewrite.java.tree.JavaType;
+
+import java.util.*;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.openrewrite.java.migrate.joda.templates.TimeClassNames.JODA_CLASS_PATTERN;
+
+public class JodaTimeScanner extends JavaIsoVisitor<ExecutionContext> {
+
+    @Getter
+    private final Set<NamedVariable> unsafeVars = new HashSet<>();
+
+    private final LinkedList<VariablesInScope> scopes = new LinkedList<>();
+
+    private final Map<NamedVariable, Set<NamedVariable>> varDependencies = new HashMap<>();
+
+    @Override
+    public J.CompilationUnit visitCompilationUnit(J.CompilationUnit cu, ExecutionContext ctx) {
+        cu = super.visitCompilationUnit(cu, ctx);
+        Set<NamedVariable> allReachable = new HashSet<>();
+        for (NamedVariable var : unsafeVars) {
+            dfs(var, allReachable);
+        }
+        unsafeVars.addAll(allReachable);
+        return cu;
+    }
+
+    @Override
+    public J.Block visitBlock(J.Block block, ExecutionContext ctx) {
+        scopes.push(new VariablesInScope(getCursor()));
+        J.Block b = super.visitBlock(block, ctx);
+        scopes.pop();
+        return b;
+    }
+
+    @Override
+    public NamedVariable visitVariable(NamedVariable variable, ExecutionContext ctx) {
+        assert !scopes.isEmpty();
+        scopes.peek().variables.add(variable);
+        if (!variable.getType().isAssignableFrom(JODA_CLASS_PATTERN)) {
+            return variable;
+        }
+        // TODO: handle class variables && method parameters
+        if (!isLocalVar(variable)) {
+            unsafeVars.add(variable);
+            return variable;
+        }
+        variable = super.visitVariable(variable, ctx);
+
+        if (!variable.getType().isAssignableFrom(JODA_CLASS_PATTERN) || variable.getInitializer() == null) {
+            return variable;
+        }
+        List<Expression> sinks = findSinks(variable.getInitializer());
+        assert !scopes.isEmpty();
+        Cursor currentScope = scopes.peek().getScope();
+        J.Block block = currentScope.getValue();
+        new AddSafeCheckMarker(sinks).visit(block, ctx, currentScope.getParent());
+        processMarkersOnExpression(sinks, variable);
+        return variable;
+    }
+
+    @Override
+    public J.Assignment visitAssignment(J.Assignment assignment, ExecutionContext ctx) {
+        Expression var = assignment.getVariable();
+        // not joda expr or not local variable
+        if (!isJodaExpr(var) || !(var instanceof J.Identifier)) {
+            return assignment;
+        }
+        J.Identifier ident = (J.Identifier) var;
+        Optional<NamedVariable> mayBeVar = findVarInScope(ident.getSimpleName());
+        if (!mayBeVar.isPresent()) {
+            return assignment;
+        }
+        NamedVariable variable = mayBeVar.get();
+        Cursor varScope = findScope(variable);
+        List<Expression> sinks = findSinks(assignment.getAssignment());
+        new AddSafeCheckMarker(sinks).visit(varScope.getValue(), ctx, varScope.getParent());
+        processMarkersOnExpression(sinks, variable);
+        return assignment;
+    }
+
+    private void processMarkersOnExpression(List<Expression> expressions, NamedVariable var) {
+        for (Expression expr : expressions) {
+            Optional<SafeCheckMarker> mayBeMarker = expr.getMarkers().findFirst(SafeCheckMarker.class);
+            if (!mayBeMarker.isPresent()) {
+                continue;
+            }
+            SafeCheckMarker marker = mayBeMarker.get();
+            if (!marker.isSafe()) {
+                unsafeVars.add(var);
+            }
+            if (!marker.getReferences().isEmpty()) {
+                varDependencies.compute(var, (k, v) -> v == null ? new HashSet<>() : v).addAll(marker.getReferences());
+                for (NamedVariable ref : marker.getReferences()) {
+                    varDependencies.compute(ref, (k, v) -> v == null ? new HashSet<>() : v).add(var);
+                }
+            }
+        }
+    }
+
+    private boolean isJodaExpr(Expression expression) {
+        return expression.getType() != null && expression.getType().isAssignableFrom(JODA_CLASS_PATTERN);
+    }
+
+    private List<Expression> findSinks(Expression expr) {
+        Cursor cursor = new Cursor(getCursor(), expr);
+        Option<SinkFlowSummary> mayBeSinks = Dataflow.startingAt(cursor).findSinks(new JodaTimeFlowSpec());
+        if (mayBeSinks.isNone()) {
+            return Collections.emptyList();
+        }
+        return mayBeSinks.some().getExpressionSinks();
+    }
+
+    private boolean isLocalVar(NamedVariable variable) {
+        if (!(variable.getVariableType().getOwner() instanceof JavaType.Method)) {
+            return false;
+        }
+        J j = getCursor().dropParentUntil(t -> t instanceof J.Block || t instanceof J.MethodDeclaration).getValue();
+        return j instanceof J.Block;
+    }
+
+    // Returns the variable in the closest scope
+    private Optional<NamedVariable> findVarInScope(String varName) {
+        for (VariablesInScope scope : scopes) {
+            for (NamedVariable var : scope.variables) {
+                if (var.getSimpleName().equals(varName)) {
+                    return Optional.of(var);
+                }
+            }
+        }
+        return Optional.empty();
+    }
+
+    private Cursor findScope(NamedVariable variable) {
+        for (VariablesInScope scope : scopes) {
+            if (scope.variables.contains(variable)) {
+                return scope.scope;
+            }
+        }
+        return null;
+    }
+
+    private void dfs(NamedVariable root, Set<NamedVariable> visited) {
+        if (visited.contains(root)) {
+            return;
+        }
+        visited.add(root);
+        for (NamedVariable dep : varDependencies.getOrDefault(root, Collections.emptySet())) {
+            dfs(dep, visited);
+        }
+    }
+
+    @Value
+    private static class VariablesInScope {
+        Cursor scope;
+        Set<NamedVariable> variables;
+
+        public VariablesInScope(Cursor scope) {
+            this.scope = scope;
+            this.variables = new HashSet<>();
+        }
+    }
+
+    @RequiredArgsConstructor
+    private class AddSafeCheckMarker extends JavaIsoVisitor<ExecutionContext> {
+
+        @NonNull
+        private List<Expression> expressions;
+
+        @Override
+        public Expression visitExpression(Expression expression, ExecutionContext ctx) {
+            int index = expressions.indexOf(expression);
+            if (index == -1) {
+                return super.visitExpression(expression, ctx);
+            }
+            Expression withMarker = expression.withMarkers(expression.getMarkers().addIfAbsent(getMarker(expression, ctx)));
+            expressions.set(index, withMarker);
+            return withMarker;
+        }
+
+        private SafeCheckMarker getMarker(Expression expr, ExecutionContext ctx) {
+            Optional<SafeCheckMarker> mayBeMarker = expr.getMarkers().findFirst(SafeCheckMarker.class);
+            if (mayBeMarker.isPresent()) {
+                return mayBeMarker.get();
+            }
+
+            Cursor boundary = findBoundaryCursorForJodaExpr();
+            boolean isSafe = true;
+            // TODO: handle return statement
+            if (boundary.getParentTreeCursor().getValue() instanceof J.Return) {
+                isSafe = false;
+            }
+            Expression boundaryExpr = boundary.getValue();
+            J j = new JodaTimeVisitor(true).visit(boundaryExpr, ctx, boundary.getParentTreeCursor());
+            Set<NamedVariable> referencedVars = new HashSet<>();
+            new FindVarReferences().visit(expr, referencedVars, getCursor().getParentTreeCursor());
+            AtomicBoolean hasJodaType = new AtomicBoolean();
+            new HasJodaType().visit(j, hasJodaType);
+            isSafe = isSafe && !hasJodaType.get() && !referencedVars.contains(null);
+            referencedVars.remove(null);
+            return new SafeCheckMarker(UUID.randomUUID(), isSafe, referencedVars);
+        }
+
+        /**
+         * Traverses the cursor to find the first non-Joda expression in the path.
+         * If no non-Joda expression is found, it returns the cursor pointing
+         * to the last Joda expression whose parent is not an Expression.
+         */
+        private Cursor findBoundaryCursorForJodaExpr() {
+            Cursor cursor = getCursor();
+            while (cursor.getValue() instanceof Expression && isJodaExpr(cursor.getValue())) {
+                Cursor parent = cursor.getParentTreeCursor();
+                if (parent.getValue() instanceof J && !(parent.getValue() instanceof Expression)) {
+                    return cursor;
+                }
+                cursor = parent;
+            }
+            return cursor;
+        }
+    }
+
+    private class FindVarReferences extends JavaIsoVisitor<Set<NamedVariable>> {
+
+        @Override
+        public J.Identifier visitIdentifier(J.Identifier ident, Set<NamedVariable> vars) {
+            if (!isJodaExpr(ident) || ident.getFieldType() == null) {
+                return ident;
+            }
+            if (ident.getFieldType().getOwner() instanceof JavaType.Class) {
+                vars.add(null); // class variable not supported yet.
+            }
+
+            // find variable in the closest scope
+            findVarInScope(ident.getSimpleName()).ifPresent(vars::add);
+            return ident;
+        }
+    }
+
+    private static class HasJodaType extends JavaIsoVisitor<AtomicBoolean> {
+        @Override
+        public Expression visitExpression(Expression expression, AtomicBoolean hasJodaType) {
+            if (hasJodaType.get()) {
+                return expression;
+            }
+            if (expression.getType() != null && expression.getType().isAssignableFrom(JODA_CLASS_PATTERN)) {
+                hasJodaType.set(true);
+            }
+            return super.visitExpression(expression, hasJodaType);
+        }
+    }
+}
diff --git a/src/main/java/org/openrewrite/java/migrate/joda/JodaTimeVisitor.java b/src/main/java/org/openrewrite/java/migrate/joda/JodaTimeVisitor.java
index 120476654f..e54b953449 100644
--- a/src/main/java/org/openrewrite/java/migrate/joda/JodaTimeVisitor.java
+++ b/src/main/java/org/openrewrite/java/migrate/joda/JodaTimeVisitor.java
@@ -26,7 +26,6 @@
 
 import java.util.List;
 import java.util.Optional;
-import java.util.regex.Pattern;
 
 import static org.openrewrite.java.migrate.joda.templates.TimeClassNames.*;
 
@@ -39,6 +38,18 @@ public class JodaTimeVisitor extends JavaVisitor<ExecutionContext> {
     private final MethodMatcher anyTimeFormatter = new MethodMatcher(JODA_TIME_FORMAT + " *(..)");
     private final MethodMatcher anyNewDuration = new MethodMatcher(JODA_DURATION + "<constructor>(..)");
     private final MethodMatcher anyDuration = new MethodMatcher(JODA_DURATION + " *(..)");
+    private final MethodMatcher anyAbstractInstant = new MethodMatcher(JODA_ABSTRACT_INSTANT + " *(..)");
+
+    private boolean scanMode;
+
+    public JodaTimeVisitor(boolean scanMode) {
+        this.scanMode = scanMode;
+    }
+
+    public JodaTimeVisitor() {
+        this(false);
+    }
+
 
     @Override
     public @NonNull J visitCompilationUnit(@NonNull J.CompilationUnit cu, @NonNull ExecutionContext ctx) {
@@ -46,6 +57,7 @@ public class JodaTimeVisitor extends JavaVisitor<ExecutionContext> {
         maybeRemoveImport(JODA_DATE_TIME_ZONE);
         maybeRemoveImport(JODA_TIME_FORMAT);
         maybeRemoveImport(JODA_DURATION);
+        maybeRemoveImport(JODA_ABSTRACT_INSTANT);
         maybeRemoveImport("java.util.Locale");
 
         maybeAddImport(JAVA_DATE_TIME);
@@ -59,6 +71,7 @@ public class JodaTimeVisitor extends JavaVisitor<ExecutionContext> {
         maybeAddImport(JAVA_LOCAL_TIME);
         maybeAddImport(JAVA_TEMPORAL_ISO_FIELDS);
         maybeAddImport(JAVA_CHRONO_FIELD);
+        maybeAddImport(JAVA_UTIL_DATE);
         return super.visitCompilationUnit(cu, ctx);
     }
 
@@ -71,6 +84,12 @@ public class JodaTimeVisitor extends JavaVisitor<ExecutionContext> {
         return super.visitVariable(variable, ctx);
     }
 
+    @Override
+    public @NonNull J visitAssignment(@NonNull J.Assignment assignment, @NonNull ExecutionContext ctx) {
+        J.Assignment a = (J.Assignment) super.visitAssignment(assignment, ctx);
+        return a.withType(a.getVariable().getType());
+    }
+
     @Override
     public @NonNull J visitNewClass(@NonNull J.NewClass newClass, @NonNull ExecutionContext ctx) {
         MethodCall updated = (MethodCall) super.visitNewClass(newClass, ctx);
@@ -102,12 +121,20 @@ public class JodaTimeVisitor extends JavaVisitor<ExecutionContext> {
         if (anyDateTime.matches(method) || anyBaseDateTime.matches(method)) {
             return applyTemplate(method, m, DateTimeTemplates.getTemplates()).orElse(method);
         }
+        if (anyAbstractInstant.matches(method)) {
+            return applyTemplate(method, m, AbstractInstantTemplates.getTemplates()).orElse(method);
+        }
         if (anyTimeFormatter.matches(method)) {
             return applyTemplate(method, m, DateTimeFormatTemplates.getTemplates()).orElse(method);
         }
         if (anyDuration.matches(method)) {
             return applyTemplate(method, m, DurationTemplates.getTemplates()).orElse(method);
         }
+        if (method.getSelect() != null &&
+            method.getSelect().getType() != null &&
+            method.getSelect().getType().isAssignableFrom(JODA_CLASS_PATTERN)) {
+            return method; // unhandled case
+        }
         if (areArgumentsAssignable(m)) {
             return m;
         }
@@ -126,17 +153,35 @@ public class JodaTimeVisitor extends JavaVisitor<ExecutionContext> {
         return f;
     }
 
+    @Override
+    public @NonNull J visitIdentifier(@NonNull J.Identifier ident, @NonNull ExecutionContext ctx) {
+        if (!(isJodaVarRef(ident) && scanMode)) {
+            return super.visitIdentifier(ident, ctx);
+        }
+
+        // TODO: support migration for class variables
+        if (!(ident.getType() instanceof JavaType.Class)) {
+            return ident;
+        }
+
+        JavaType.FullyQualified jodaType = ((JavaType.Class) ident.getType());
+        JavaType.FullyQualified fqType = TimeClassMap.getJavaTimeType(jodaType.getFullyQualifiedName());
+
+        return ident.withType(fqType)
+                .withFieldType(ident.getFieldType().withType(fqType));
+    }
+
     private boolean hasJodaType(List<Expression> exprs) {
         for (Expression expr : exprs) {
             JavaType exprType = expr.getType();
-            if (exprType != null && exprType.isAssignableFrom(Pattern.compile("org.joda.time.*"))) {
+            if (exprType != null && exprType.isAssignableFrom(JODA_CLASS_PATTERN)) {
                 return true;
             }
         }
         return false;
     }
 
-    private Optional<MethodCall> applyTemplate(MethodCall original, MethodCall updated, List<MethodTemplate> templates) {
+    private Optional<J> applyTemplate(MethodCall original, MethodCall updated, List<MethodTemplate> templates) {
         for (MethodTemplate template : templates) {
             if (template.getMatcher().matches(original)) {
                 Expression[] args = template.getTemplateArgsFunc().apply(updated);
@@ -150,9 +195,12 @@ private Optional<MethodCall> applyTemplate(MethodCall original, MethodCall updat
     }
 
     private boolean areArgumentsAssignable(MethodCall m) {
-        if (m.getMethodType() == null || m.getArguments().size() != m.getMethodType().getParameterTypes().size()) {
+        if (m.getMethodType() == null || getArgumentsCount(m) != m.getMethodType().getParameterTypes().size()) {
             return false;
         }
+        if (getArgumentsCount(m) == 0) {
+            return true;
+        }
         for (int i = 0; i < m.getArguments().size(); i++) {
             if (!TypeUtils.isAssignableTo(m.getMethodType().getParameterTypes().get(i), m.getArguments().get(i).getType())) {
                 return false;
@@ -161,6 +209,13 @@ private boolean areArgumentsAssignable(MethodCall m) {
         return true;
     }
 
+    private int getArgumentsCount(MethodCall m) {
+        if (m.getArguments().size() == 1 && m.getArguments().get(0) instanceof J.Empty) {
+            return 0;
+        }
+        return m.getArguments().size();
+    }
+
     private boolean isJodaVarRef(@Nullable Expression expr) {
         if (expr == null || expr.getType() == null || !expr.getType().isAssignableFrom(JODA_CLASS_PATTERN)) {
             return false;
diff --git a/src/main/java/org/openrewrite/java/migrate/joda/SafeCheckMarker.java b/src/main/java/org/openrewrite/java/migrate/joda/SafeCheckMarker.java
new file mode 100644
index 0000000000..bc5ec02b8d
--- /dev/null
+++ b/src/main/java/org/openrewrite/java/migrate/joda/SafeCheckMarker.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2024 the original author or authors.
+ * <p>
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * https://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.migrate.joda;
+
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+import lombok.With;
+import org.openrewrite.java.tree.J.VariableDeclarations.NamedVariable;
+import org.openrewrite.marker.Marker;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * A marker to indicate whether an expression is safe to migrate
+ * and variables that are referenced in the expression.
+ */
+@With
+@Value
+public class SafeCheckMarker implements Marker {
+    @EqualsAndHashCode.Include
+    UUID id;
+    boolean isSafe;
+    Set<NamedVariable> references;
+
+    public SafeCheckMarker(UUID id, boolean isSafe, Set<NamedVariable> references) {
+        this.id = id;
+        this.isSafe = isSafe;
+        this.references = references;
+    }
+
+    public SafeCheckMarker(UUID id, boolean isSafe) {
+        this(id, isSafe, new HashSet<>());
+    }
+}
diff --git a/src/main/java/org/openrewrite/java/migrate/joda/templates/AbstractInstantTemplates.java b/src/main/java/org/openrewrite/java/migrate/joda/templates/AbstractInstantTemplates.java
new file mode 100644
index 0000000000..ded2197aa8
--- /dev/null
+++ b/src/main/java/org/openrewrite/java/migrate/joda/templates/AbstractInstantTemplates.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2024 the original author or authors.
+ * <p>
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * https://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.migrate.joda.templates;
+
+import org.openrewrite.java.JavaTemplate;
+import org.openrewrite.java.MethodMatcher;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.openrewrite.java.migrate.joda.templates.TimeClassNames.JAVA_UTIL_DATE;
+import static org.openrewrite.java.migrate.joda.templates.TimeClassNames.JODA_ABSTRACT_INSTANT;
+
+public class AbstractInstantTemplates {
+    private final MethodMatcher toDate = new MethodMatcher(JODA_ABSTRACT_INSTANT + " toDate()");
+
+    private final JavaTemplate toDateTemplate = JavaTemplate.builder("Date.from(#{any(java.time.ZonedDateTime)}.toInstant())")
+            .imports(JAVA_UTIL_DATE)
+            .build();
+
+    private final List<MethodTemplate> templates = new ArrayList<MethodTemplate>() {
+        {
+            add(new MethodTemplate(toDate, toDateTemplate));
+        }
+    };
+
+    public static List<MethodTemplate> getTemplates() {
+        return new AbstractInstantTemplates().templates;
+    }
+}
diff --git a/src/main/java/org/openrewrite/java/migrate/joda/templates/TimeClassMap.java b/src/main/java/org/openrewrite/java/migrate/joda/templates/TimeClassMap.java
new file mode 100644
index 0000000000..08c2f66787
--- /dev/null
+++ b/src/main/java/org/openrewrite/java/migrate/joda/templates/TimeClassMap.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2024 the original author or authors.
+ * <p>
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * https://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.migrate.joda.templates;
+
+import org.openrewrite.java.tree.JavaType;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.openrewrite.java.migrate.joda.templates.TimeClassNames.*;
+
+public class TimeClassMap {
+
+    private static final JavaType.Class object = JavaType.ShallowClass.build("java.lang.Object");
+
+    private final Map<String, JavaType.Class> jodaToJavaTimeMap = new HashMap<String, JavaType.Class>() {
+        {
+            put(JODA_DATE_TIME, javaTypeClass(JAVA_DATE_TIME, object));
+            put(JODA_BASE_DATE_TIME, javaTypeClass(JAVA_DATE_TIME, object));
+            put(JODA_DATE_TIME_ZONE, javaTypeClass(JAVA_ZONE_ID, object));
+            put(JODA_TIME_FORMATTER, javaTypeClass(JAVA_TIME_FORMATTER, object));
+            put(JODA_DURATION, javaTypeClass(JAVA_DURATION, object));
+            put(JODA_READABLE_DURATION, javaTypeClass(JAVA_DURATION, object));
+        }
+    };
+
+    private static JavaType.Class javaTypeClass(String fqn, JavaType.Class superType) {
+        return new JavaType.Class(null, 0, fqn, JavaType.FullyQualified.Kind.Class, null, superType,
+                null, null, null, null, null);
+    }
+
+    public static JavaType.Class getJavaTimeType(String typeFqn) {
+        return new TimeClassMap().jodaToJavaTimeMap.get(typeFqn);
+    }
+}
diff --git a/src/main/java/org/openrewrite/java/migrate/joda/templates/TimeClassNames.java b/src/main/java/org/openrewrite/java/migrate/joda/templates/TimeClassNames.java
index a9d56b0e40..ad018b0069 100644
--- a/src/main/java/org/openrewrite/java/migrate/joda/templates/TimeClassNames.java
+++ b/src/main/java/org/openrewrite/java/migrate/joda/templates/TimeClassNames.java
@@ -19,6 +19,10 @@
 
 public class TimeClassNames {
     public static final Pattern JODA_CLASS_PATTERN = Pattern.compile("org\\.joda\\.time\\..*");
+
+    // java util
+    public static final String JAVA_UTIL_DATE = "java.util.Date";
+
     // Joda-Time classes
     public static final String JODA_TIME_PKG = "org.joda.time";
     public static final String JODA_BASE_DATE_TIME = JODA_TIME_PKG + ".base.BaseDateTime";
@@ -32,6 +36,7 @@ public class TimeClassNames {
     public static final String JODA_DURATION_FIELD_TYPE = JODA_TIME_PKG + ".DurationFieldType";
     public static final String JODA_DURATION = JODA_TIME_PKG + ".Duration";
     public static final String JODA_READABLE_DURATION = JODA_TIME_PKG + ".ReadableDuration";
+    public static final String JODA_ABSTRACT_INSTANT = JODA_TIME_PKG + ".base.AbstractInstant";
 
     // Java Time classes
     public static final String JAVA_TIME_PKG = "java.time";
diff --git a/src/test/java/org/openrewrite/java/migrate/joda/JodaTimeFlowSpecTest.java b/src/test/java/org/openrewrite/java/migrate/joda/JodaTimeFlowSpecTest.java
new file mode 100644
index 0000000000..a54e65d460
--- /dev/null
+++ b/src/test/java/org/openrewrite/java/migrate/joda/JodaTimeFlowSpecTest.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2024 the original author or authors.
+ * <p>
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * https://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.migrate.joda;
+
+import org.junit.jupiter.api.Test;
+import org.openrewrite.Cursor;
+import org.openrewrite.DocumentExample;
+import org.openrewrite.ExecutionContext;
+import org.openrewrite.analysis.dataflow.Dataflow;
+import org.openrewrite.java.JavaIsoVisitor;
+import org.openrewrite.java.JavaParser;
+import org.openrewrite.java.tree.Expression;
+import org.openrewrite.java.tree.J;
+import org.openrewrite.marker.SearchResult;
+import org.openrewrite.test.RecipeSpec;
+import org.openrewrite.test.RewriteTest;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.openrewrite.java.Assertions.java;
+import static org.openrewrite.test.RewriteTest.toRecipe;
+
+class JodaTimeFlowSpecTest implements RewriteTest {
+    @Override
+    public void defaults(RecipeSpec spec) {
+        spec
+          .recipe(toRecipe(() -> new JavaIsoVisitor<>() {
+              Map<Expression, List<J.Identifier>> exprVarBindings = new HashMap<>();
+
+              @Override
+              public J.VariableDeclarations.NamedVariable visitVariable(J.VariableDeclarations.NamedVariable variable, ExecutionContext ctx) {
+                  if (variable.getInitializer() == null) {
+                      return super.visitVariable(variable, ctx);
+                  }
+                  updateSinks(variable.getInitializer(), variable.getName());
+                  return super.visitVariable(variable, ctx);
+              }
+
+              @Override
+              public J.Assignment visitAssignment(J.Assignment assignment, ExecutionContext ctx) {
+                  if (!(assignment.getVariable() instanceof J.Identifier)) {
+                      return super.visitAssignment(assignment, ctx);
+                  }
+                  updateSinks(assignment.getAssignment(), (J.Identifier) assignment.getVariable());
+                  return super.visitAssignment(assignment, ctx);
+              }
+
+              @Override
+              public Expression visitExpression(Expression expression, ExecutionContext ctx) {
+                  List<J.Identifier> identifiers = exprVarBindings.get(expression);
+                  if (identifiers == null || identifiers.isEmpty()) {
+                      return expression;
+                  }
+                  String desc = identifiers.stream().map(J.Identifier::getSimpleName).collect(Collectors.joining(", "));
+                  return SearchResult.found(expression, desc);
+              }
+
+              private void updateSinks(Expression expr, J.Identifier identifier) {
+                  Cursor cursor = new Cursor(getCursor(), expr);
+                  Dataflow.startingAt(cursor).findSinks(new JodaTimeFlowSpec())
+                    .foreachDoEffect(sinkFlow -> {
+                        for (Expression sink : sinkFlow.getExpressionSinks()) {
+                            exprVarBindings.computeIfAbsent(sink, e -> new ArrayList<>()).add(identifier);
+                        }
+                    });
+              }
+          }))
+          .parser(JavaParser.fromJavaVersion().classpath("joda-time"));
+    }
+
+    @DocumentExample
+    @Test
+    void jodaTimeUsageWithVarBindings() {
+        rewriteRun(
+          // language=java
+          java(
+            """
+              import org.joda.time.DateTime;
+              import org.joda.time.Interval;
+              
+              class A {
+                  public void foo() {
+                      DateTime dateTime = new DateTime(), _dateTime = DateTime.now();
+                      System.out.println(dateTime);
+                      DateTime dateTimePlus2 = dateTime.plusDays(2);
+                      System.out.println(dateTimePlus2);
+                      dateTime = dateTime.minusDays(1);
+                      _dateTime = dateTime;
+                      Interval interval = new Interval(_dateTime, dateTimePlus2);
+                      System.out.println(interval);
+                  }
+              }
+              """,
+            """
+              import org.joda.time.DateTime;
+              import org.joda.time.Interval;
+              
+              class A {
+                  public void foo() {
+                      DateTime dateTime = /*~~(dateTime)~~>*/new DateTime(), _dateTime = /*~~(_dateTime)~~>*/DateTime.now();
+                      System.out.println(/*~~(dateTime)~~>*/dateTime);
+                      DateTime dateTimePlus2 = /*~~(dateTimePlus2)~~>*//*~~(dateTime)~~>*/dateTime.plusDays(2);
+                      System.out.println(/*~~(dateTimePlus2)~~>*/dateTimePlus2);
+                      dateTime = /*~~(dateTime)~~>*//*~~(dateTime)~~>*/dateTime.minusDays(1);
+                      _dateTime = /*~~(dateTime, _dateTime)~~>*/dateTime;
+                      Interval interval = /*~~(interval)~~>*/new Interval(/*~~(dateTime, _dateTime)~~>*/_dateTime, /*~~(dateTimePlus2)~~>*/dateTimePlus2);
+                      System.out.println(/*~~(interval)~~>*/interval);
+                  }
+              }
+              """
+          )
+        );
+    }
+}
diff --git a/src/test/java/org/openrewrite/java/migrate/joda/JodaTimeScannerTest.java b/src/test/java/org/openrewrite/java/migrate/joda/JodaTimeScannerTest.java
new file mode 100644
index 0000000000..5200762363
--- /dev/null
+++ b/src/test/java/org/openrewrite/java/migrate/joda/JodaTimeScannerTest.java
@@ -0,0 +1,177 @@
+/*
+ * Copyright 2024 the original author or authors.
+ * <p>
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * <p>
+ * https://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.migrate.joda;
+
+import org.junit.jupiter.api.Test;
+import org.openrewrite.java.JavaParser;
+import org.openrewrite.java.tree.J;
+import org.openrewrite.test.RecipeSpec;
+import org.openrewrite.test.RewriteTest;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.openrewrite.java.Assertions.java;
+import static org.openrewrite.test.RewriteTest.toRecipe;
+
+class JodaTimeScannerTest implements RewriteTest {
+    @Override
+    public void defaults(RecipeSpec spec) {
+        spec
+          .recipe(toRecipe(JodaTimeScanner::new))
+          .parser(JavaParser.fromJavaVersion().classpath("joda-time"));
+    }
+
+    @Test
+    void noUnsafeVar() {
+        JodaTimeScanner scanner = new JodaTimeScanner();
+        // language=java
+        rewriteRun(
+          spec -> spec.recipe(toRecipe(() -> scanner)),
+          java(
+            """
+               import org.joda.time.DateTime;
+               import org.joda.time.DateTimeZone;
+               import java.util.Date;
+              
+               class A {
+                   public void foo(String city) {
+                       DateTimeZone dtz;
+                       if ("london".equals(city)) {
+                           dtz = DateTimeZone.forID("Europe/London");
+                       } else {
+                           dtz = DateTimeZone.forID("America/New_York");
+                       }
+                       DateTime dt = new DateTime(dtz);
+                       print(dt.toDate());
+                   }
+                   private void print(Date date) {
+                       System.out.println(date);
+                   }
+              }
+              """
+          )
+        );
+        assertTrue(scanner.getUnsafeVars().isEmpty());
+    }
+
+    @Test
+    void hasUnsafeVars() {
+        JodaTimeScanner scanner = new JodaTimeScanner();
+        // language=java
+        rewriteRun(
+          spec -> spec.recipe(toRecipe(() -> scanner)),
+          java(
+            """
+               import org.joda.time.DateTime;
+               import org.joda.time.DateTimeZone;
+              
+               class A {
+                   DateTime dateTime;
+                   public void foo(String city) {
+                       DateTimeZone dtz;
+                       if ("london".equals(city)) {
+                           dtz = DateTimeZone.forID("Europe/London");
+                       } else {
+                           dtz = DateTimeZone.forID("America/New_York");
+                       }
+                       DateTime dt = new DateTime(dtz);
+                       print(dt.toDateTime());
+                   }
+                   private void print(DateTime dateTime) { // method parameter not handled yet
+                       System.out.println(dateTime);
+                   }
+              }
+              """
+          )
+        );
+        // The variable dtz is unsafe due to dt. The dt variable is unsafe because its associated expression
+        // is passed as argument to method, and migration of method parameters has not been implemented yet.
+        assertEquals(4, scanner.getUnsafeVars().size());
+        for (J.VariableDeclarations.NamedVariable var : scanner.getUnsafeVars()) {
+            assertTrue(var.getSimpleName().equals("dtz") ||
+                       var.getSimpleName().equals("dt") ||
+                       var.getSimpleName().equals("dateTime")
+            );
+        }
+    }
+
+    @Test
+    void localVarReferencingClassVar() { // not supported yet
+        JodaTimeScanner scanner = new JodaTimeScanner();
+        // language=java
+        rewriteRun(
+          spec -> spec.recipe(toRecipe(() -> scanner)),
+          java(
+            """
+               import org.joda.time.DateTime;
+               import org.joda.time.DateTimeZone;
+              
+               class A {
+                   DateTime dateTime;
+                   public void foo(String city) {
+                       DateTimeZone dtz;
+                       if ("london".equals(city)) {
+                           dtz = DateTimeZone.forID("Europe/London");
+                       } else {
+                           dtz = DateTimeZone.forID("America/New_York");
+                       }
+                       DateTime dt = dateTime.minus(2);
+                       System.out.println(dt);
+                   }
+              }
+              """
+          )
+        );
+        // The local variable dt is unsafe due to class var datetime.
+        assertEquals(2, scanner.getUnsafeVars().size());
+        for (J.VariableDeclarations.NamedVariable var : scanner.getUnsafeVars()) {
+            assertTrue(var.getSimpleName().equals("dateTime") || var.getSimpleName().equals("dt"));
+        }
+    }
+
+    @Test
+    void localVarUsedReferencedInReturnStatement() { // not supported yet
+        JodaTimeScanner scanner = new JodaTimeScanner();
+        // language=java
+        rewriteRun(
+          spec -> spec.recipe(toRecipe(() -> scanner)),
+          java(
+            """
+               import org.joda.time.DateTime;
+               import org.joda.time.DateTimeZone;
+              
+               class A {
+                   public DateTime foo(String city) {
+                       DateTimeZone dtz;
+                       if ("london".equals(city)) {
+                           dtz = DateTimeZone.forID("Europe/London");
+                       } else {
+                           dtz = DateTimeZone.forID("America/New_York");
+                       }
+                       DateTime dt = new DateTime(dtz);
+                       return dt.plus(2);
+                   }
+              }
+              """
+          )
+        );
+        // The local variable dt used in return statement.
+        assertEquals(2, scanner.getUnsafeVars().size());
+        for (J.VariableDeclarations.NamedVariable var : scanner.getUnsafeVars()) {
+            assertTrue(var.getSimpleName().equals("dtz") || var.getSimpleName().equals("dt"));
+        }
+    }
+}
diff --git a/src/test/java/org/openrewrite/java/migrate/joda/JodaTimeVisitorTest.java b/src/test/java/org/openrewrite/java/migrate/joda/JodaTimeVisitorTest.java
index 37b8734d99..a56da3890d 100644
--- a/src/test/java/org/openrewrite/java/migrate/joda/JodaTimeVisitorTest.java
+++ b/src/test/java/org/openrewrite/java/migrate/joda/JodaTimeVisitorTest.java
@@ -28,7 +28,7 @@ class JodaTimeVisitorTest implements RewriteTest {
     @Override
     public void defaults(RecipeSpec spec) {
         spec
-          .recipe(toRecipe(JodaTimeVisitor::new))
+          .recipe(toRecipe(() -> new JodaTimeVisitor()))
           .parser(JavaParser.fromJavaVersion().classpath("joda-time"));
     }
 
@@ -413,6 +413,34 @@ public void foo() {
         );
     }
 
+    @Test
+    void migrateAbstractInstant() {
+        // language=java
+        rewriteRun(
+          java(
+            """
+              import org.joda.time.DateTime;
+              
+              class A {
+                  public void foo() {
+                      new DateTime().toDate();
+                  }
+              }
+              """,
+            """
+              import java.time.ZonedDateTime;
+              import java.util.Date;
+              
+              class A {
+                  public void foo() {
+                      Date.from(ZonedDateTime.now().toInstant());
+                  }
+              }
+              """
+          )
+        );
+    }
+
     @Test
     void migrateClassesWithFqn() {
         // language=java
@@ -502,10 +530,12 @@ void dontChangeIncompatibleType() {
               class A {
                   public void foo() {
                       new B().print(new DateTime()); // print is public method accepting DateTime, not handled yet
+                      System.out.println(new B().dateTime);
                   }
               }
               
               class B {
+                  DateTime dateTime = new DateTime();
                   public void print(DateTime dateTime) {
                       System.out.println(dateTime);
                   }
@@ -534,4 +564,24 @@ public void foo() {
           )
         );
     }
+
+    @Test
+    void unhandledCases() {
+        //language=java
+        rewriteRun(
+          java(
+            """
+              import org.joda.time.DateTime;
+              import org.joda.time.Instant;
+              
+              class A {
+                  public void foo() {
+                      new Instant();
+                      new DateTime().getZone();
+                  }
+              }
+              """
+          )
+        );
+    }
 }