You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@karaf.apache.org by jb...@apache.org on 2026/10/19 16:13:34 UTC

(karaf) branch main updated: feat(features): decouple features service from Pax URL (#2928)

This is an automated email from the ASF dual-hosted git repository.

jbonofre pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/karaf.git


The following commit(s) were added to refs/heads/main by this push:
     new 5f992ac7b0 feat(features): decouple features service from Pax URL (#2928)
5f992ac7b0 is described below

commit 5f992ac7b05380bfb294b4a7aa9d63963111add8
Author: JB Onofré <jb...@apache.org>
AuthorDate: Mon Oct 19 17:13:26 2026 +0100

    feat(features): decouple features service from Pax URL (#2928)
    
    * feat(features): decouple features service from Pax URL
    
    The features service linked directly against org.ops4j.pax.url.mvn, plus
    ops4j-base and pax-swissbox for property resolution. That made Pax URL a hard
    dependency of the core provisioning path and blocked assembling a distribution
    without it.
    
    Introduce org.apache.karaf.features.spi, a Karaf owned resolver contract
    (MavenResolver, MavenResolverFactory, MavenResolvers), and consume it
    everywhere the features service resolves mvn: URIs. Providers are discovered as
    an OSGi service inside the framework and with ServiceLoader outside it, so the
    assembly builder and the karaf-maven-plugin keep working unchanged.
    
    The Pax URL binding moves to a new bundle, org.apache.karaf.maven.resolver.paxurl,
    which registers a MavenResolverFactory and a MavenResolver built lazily from the
    org.ops4j.pax.url.mvn PID. It ships in the standard distribution at start level
    14, so behaviour and configuration are unchanged; it is now the only bundle in
    the boot set that imports org.ops4j.pax.url.
    
    The features Activator declares the factory with @RequireService, so the
    features service simply waits for a provider instead of building a resolver
    itself - no new startup ordering machinery.
    
    Property resolution in FeaturesProcessingSerializer no longer uses ops4j-base
    and pax-swissbox. PropertyResolver and PropertySubstitutor reimplement the
    semantics the serializer relies on: system properties take precedence over the
    supplied dictionary, blank means absent, and unresolved ${...} placeholders are
    left untouched so the existing warning still fires. Malformed placeholders are
    now kept verbatim rather than raising EmptyStackException.
    
    org.apache.karaf.features.core and org.apache.karaf.features.command no longer
    have any org.ops4j dependency, import or private package.
    
    Relates to #2212
    
    * Add Pax URL for the itests
    
    * Allow features service without Pax URL
    
    * Update features/core/src/main/java/org/apache/karaf/features/internal/download/impl/LocalMavenResolver.java
    
    Co-authored-by: CptBartender <fr...@gmail.com>
    
    * Make the previous exception aware resolve method optional in MavenResolver
    
    The features service calls resolve(url, previousException) for every attempt,
    with a null exception for the first one. Providing a default implementation
    delegating to resolve(url) means a resolver that has no use for the previous
    failure does not have to override it.
    
    * Document the Maven resolver SPI in the manual
    
    * Load mvn: feature repositories through the MavenResolver SPI
    
    Boot repositories no longer require a mvn URL handler to be registered, which made the features service race with Pax URL at startup.
    
    ---------
    
    Co-authored-by: CptBartender <fr...@gmail.com>
---
 assemblies/features/framework/pom.xml              |   5 +
 .../framework/src/main/feature/feature.xml         |   2 +
 .../karaf/features/command/FeatureExport.java      |   2 +-
 features/core/pom.xml                              |  28 +----
 .../internal/download/DownloadManagers.java        |   2 +-
 .../internal/download/impl/LocalMavenResolver.java |  76 ++++++++++++
 .../download/impl/MavenDownloadManager.java        |   2 +-
 .../internal/download/impl/MavenDownloadTask.java  |   2 +-
 .../karaf/features/internal/osgi/Activator.java    |  57 ++++++++-
 .../service/FeaturesProcessingSerializer.java      |  13 +--
 .../internal/service/FeaturesServiceImpl.java      |  61 ++++++++--
 .../internal/service/RepositoryCacheImpl.java      |   9 +-
 .../features/internal/service/RepositoryImpl.java  |  48 ++++++--
 .../service/SimpleFeaturesServiceImpl.java         |  62 ++++++++--
 .../features/internal/util/PropertyResolver.java   |  89 +++++++++++++++
 .../internal/util/PropertySubstitutor.java         | 104 +++++++++++++++++
 .../apache/karaf/features/spi/MavenResolver.java   | 114 ++++++++++++++++++
 .../karaf/features/spi/MavenResolverFactory.java   |  57 +++++++++
 .../apache/karaf/features/spi/MavenResolvers.java  |  85 ++++++++++++++
 .../internal/maven/TestMavenResolverFactory.java   |  49 ++++++++
 ....apache.karaf.features.spi.MavenResolverFactory |  17 +++
 itests/common/pom.xml                              |   7 ++
 itests/test/pom.xml                                |   6 +
 .../main/asciidoc/developer-guide/extending.adoc   | 111 ++++++++++++++++++
 manual/src/main/asciidoc/user-guide/urls.adoc      |   7 ++
 maven/pom.xml                                      |   1 +
 {itests/common => maven/resolver-paxurl}/pom.xml   |  96 ++++++----------
 .../karaf/maven/resolver/paxurl/Activator.java     |  71 ++++++++++++
 .../maven/resolver/paxurl/LazyMavenResolver.java   | 127 +++++++++++++++++++++
 .../maven/resolver/paxurl/PaxUrlMavenResolver.java |  74 ++++++++++++
 .../paxurl/PaxUrlMavenResolverFactory.java         |  28 +++--
 ....apache.karaf.features.spi.MavenResolverFactory |  17 +++
 profile/pom.xml                                    |   7 ++
 .../org/apache/karaf/profile/assembly/Builder.java |  29 +++--
 .../profile/assembly/CustomDownloadManager.java    |   2 +-
 tooling/karaf-maven-plugin/pom.xml                 |   6 +
 .../java/org/apache/karaf/tooling/VerifyMojo.java  |   6 +-
 .../karaf/tooling/utils/ReactorMavenResolver.java  |  38 +-----
 38 files changed, 1330 insertions(+), 187 deletions(-)

diff --git a/assemblies/features/framework/pom.xml b/assemblies/features/framework/pom.xml
index efc75bc706..be7b04f2f1 100644
--- a/assemblies/features/framework/pom.xml
+++ b/assemblies/features/framework/pom.xml
@@ -186,6 +186,11 @@
             <groupId>org.apache.karaf.features</groupId>
             <artifactId>org.apache.karaf.features.core</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.apache.karaf.maven</groupId>
+            <artifactId>org.apache.karaf.maven.resolver.paxurl</artifactId>
+            <version>${project.version}</version>
+        </dependency>
 
     </dependencies>
 
diff --git a/assemblies/features/framework/src/main/feature/feature.xml b/assemblies/features/framework/src/main/feature/feature.xml
index 00bfdaaa48..443707f903 100644
--- a/assemblies/features/framework/src/main/feature/feature.xml
+++ b/assemblies/features/framework/src/main/feature/feature.xml
@@ -44,6 +44,7 @@
         <bundle start-level="11">mvn:org.apache.felix/org.apache.felix.configurator/${felix.configurator.version}</bundle>
         <bundle start-level="11">mvn:org.apache.karaf.config/org.apache.karaf.config.core/${project.version}</bundle>
         <!-- features service -->
+        <bundle start-level="14">mvn:org.apache.karaf.maven/org.apache.karaf.maven.resolver.paxurl/${project.version}</bundle>
         <bundle start-level="15">mvn:org.apache.karaf.features/org.apache.karaf.features.core/${project.version}</bundle>
         <bundle dependency="true" start-level="14">mvn:jakarta.xml.bind/jakarta.xml.bind-api/${spec.jaxb-api.version}</bundle>
         <bundle dependency="true" start-level="14">mvn:org.glassfish.jaxb/jaxb-runtime/${spec.jaxb.version}</bundle>
@@ -76,6 +77,7 @@
         <!-- file install -->
         <bundle start-level="12">mvn:org.apache.felix/org.apache.felix.fileinstall/${felix.fileinstall.version}</bundle>
         <!-- features service -->
+        <bundle start-level="14">mvn:org.apache.karaf.maven/org.apache.karaf.maven.resolver.paxurl/${project.version}</bundle>
         <bundle start-level="15">mvn:org.apache.karaf.features/org.apache.karaf.features.core/${project.version}</bundle>
         <bundle dependency="true" start-level="14">mvn:jakarta.xml.bind/jakarta.xml.bind-api/${spec.jaxb-api.version}</bundle>
         <bundle dependency="true" start-level="14">mvn:org.glassfish.jaxb/jaxb-runtime/${spec.jaxb.version}</bundle>
diff --git a/features/command/src/main/java/org/apache/karaf/features/command/FeatureExport.java b/features/command/src/main/java/org/apache/karaf/features/command/FeatureExport.java
index efc4b0db90..7ccbddc7b8 100644
--- a/features/command/src/main/java/org/apache/karaf/features/command/FeatureExport.java
+++ b/features/command/src/main/java/org/apache/karaf/features/command/FeatureExport.java
@@ -33,7 +33,7 @@ import org.apache.karaf.shell.api.action.Option;
 import org.apache.karaf.shell.api.action.lifecycle.Reference;
 import org.apache.karaf.shell.api.action.lifecycle.Service;
 import org.apache.karaf.shell.support.completers.FileCompleter;
-import org.ops4j.pax.url.mvn.MavenResolver;
+import org.apache.karaf.features.spi.MavenResolver;
 
 /**
  * Simple {@link FeaturesCommandSupport} implementation that allows a user in
diff --git a/features/core/pom.xml b/features/core/pom.xml
index 23a089cbad..61f4c36442 100644
--- a/features/core/pom.xml
+++ b/features/core/pom.xml
@@ -129,24 +129,6 @@
             <scope>provided</scope>
         </dependency>
 
-        <dependency>
-            <groupId>org.ops4j.pax.url</groupId>
-            <artifactId>pax-url-aether</artifactId>
-        </dependency>
-
-        <dependency>
-            <groupId>org.ops4j.base</groupId>
-            <artifactId>ops4j-base-util-property</artifactId>
-        </dependency>
-        <dependency>
-            <groupId>org.ops4j.base</groupId>
-            <artifactId>ops4j-base-util-collections</artifactId>
-        </dependency>
-        <dependency>
-            <groupId>org.ops4j.pax.swissbox</groupId>
-            <artifactId>pax-swissbox-property</artifactId>
-        </dependency>
-
         <dependency>
             <groupId>jakarta.activation</groupId>
             <artifactId>jakarta.activation-api</artifactId>
@@ -158,12 +140,6 @@
             <scope>test</scope>
         </dependency>
 
-        <dependency>
-            <groupId>org.ops4j.pax.tinybundles</groupId>
-            <artifactId>tinybundles</artifactId>
-            <scope>test</scope>
-        </dependency>
-
         <dependency>
             <groupId>org.hamcrest</groupId>
             <artifactId>hamcrest</artifactId>
@@ -214,6 +190,7 @@
                     <instructions>
                         <Export-Package>
                             org.apache.karaf.features,
+                            org.apache.karaf.features.spi,
                             org.apache.karaf.features.management,
                             org.apache.karaf.features.management.codec;-noimport:=true,
                             org.osgi.service.repository,
@@ -248,9 +225,6 @@
                             org.apache.karaf.util.xml,
                             org.eclipse.equinox.internal.region.*;-split-package:=merge-first,
                             org.apache.felix.resolver.*,
-                            org.ops4j.pax.swissbox.*,
-                            org.ops4j.util.*,
-                            org.ops4j.lang.*,
                             com.fasterxml.jackson*
                         </Private-Package>
                         <Embed-Dependency>
diff --git a/features/core/src/main/java/org/apache/karaf/features/internal/download/DownloadManagers.java b/features/core/src/main/java/org/apache/karaf/features/internal/download/DownloadManagers.java
index 59d37ead3b..f7d10eecd2 100644
--- a/features/core/src/main/java/org/apache/karaf/features/internal/download/DownloadManagers.java
+++ b/features/core/src/main/java/org/apache/karaf/features/internal/download/DownloadManagers.java
@@ -19,7 +19,7 @@ package org.apache.karaf.features.internal.download;
 import java.util.concurrent.ScheduledExecutorService;
 
 import org.apache.karaf.features.internal.download.impl.MavenDownloadManager;
-import org.ops4j.pax.url.mvn.MavenResolver;
+import org.apache.karaf.features.spi.MavenResolver;
 
 public final class DownloadManagers {
 
diff --git a/features/core/src/main/java/org/apache/karaf/features/internal/download/impl/LocalMavenResolver.java b/features/core/src/main/java/org/apache/karaf/features/internal/download/impl/LocalMavenResolver.java
new file mode 100644
index 0000000000..c5fa02ec89
--- /dev/null
+++ b/features/core/src/main/java/org/apache/karaf/features/internal/download/impl/LocalMavenResolver.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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.apache.karaf.features.internal.download.impl;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import org.apache.karaf.features.spi.MavenResolver;
+import org.apache.karaf.util.maven.Parser;
+
+/**
+ * Resolves Maven coordinates from the Karaf distribution's local system repository only.
+ */
+public class LocalMavenResolver implements MavenResolver {
+
+    private final Path systemRepository;
+
+    public LocalMavenResolver(Path systemRepository) {
+        this.systemRepository = systemRepository.toAbsolutePath().normalize();
+    }
+
+    /**
+     * Create a resolver for the repository configured by the running Karaf distribution.
+     *
+     * @return a resolver rooted at {@code karaf.home/karaf.default.repository}.
+     */
+    public static LocalMavenResolver forKarafSystem() {
+        Path repository = Paths.get(System.getProperty("karaf.default.repository", "system"));
+        if (!repository.isAbsolute()) {
+            Path home = Paths.get(System.getProperty("karaf.home", "karaf"));
+            repository = home.resolve(repository);
+        }
+        return new LocalMavenResolver(repository);
+    }
+
+    @Override
+    public File resolve(String url) throws IOException {
+        if (url == null || !url.startsWith("mvn:")) {
+            throw new MalformedURLException("Expected a mvn: URI: " + url);
+        }
+        String artifactPath = Parser.pathFromMaven(url);
+        Path artifact = systemRepository.resolve(artifactPath).normalize();
+        if (!artifact.startsWith(systemRepository)) {
+            throw new IOException("Maven artifact path is outside the Karaf system repository: " + url);
+        }
+        if (!artifact.toFile().isFile()) {
+            throw new FileNotFoundException("Maven artifact " + url + " was not found in the Karaf system repository "
+                    + systemRepository);
+        }
+        return artifact.toFile();
+    }
+
+    @Override
+    public RetryChance isRetryableException(Exception exception) {
+        return RetryChance.NEVER;
+    }
+
+}
diff --git a/features/core/src/main/java/org/apache/karaf/features/internal/download/impl/MavenDownloadManager.java b/features/core/src/main/java/org/apache/karaf/features/internal/download/impl/MavenDownloadManager.java
index ede3337e7d..1f73bf2ac4 100644
--- a/features/core/src/main/java/org/apache/karaf/features/internal/download/impl/MavenDownloadManager.java
+++ b/features/core/src/main/java/org/apache/karaf/features/internal/download/impl/MavenDownloadManager.java
@@ -29,7 +29,7 @@ import org.apache.karaf.features.internal.download.DownloadManager;
 import org.apache.karaf.features.internal.download.Downloader;
 import org.apache.karaf.features.internal.download.StreamProvider;
 import org.apache.karaf.features.internal.util.MultiException;
-import org.ops4j.pax.url.mvn.MavenResolver;
+import org.apache.karaf.features.spi.MavenResolver;
 
 public class MavenDownloadManager implements DownloadManager {
 
diff --git a/features/core/src/main/java/org/apache/karaf/features/internal/download/impl/MavenDownloadTask.java b/features/core/src/main/java/org/apache/karaf/features/internal/download/impl/MavenDownloadTask.java
index 41a1e2b04e..76d8b48180 100644
--- a/features/core/src/main/java/org/apache/karaf/features/internal/download/impl/MavenDownloadTask.java
+++ b/features/core/src/main/java/org/apache/karaf/features/internal/download/impl/MavenDownloadTask.java
@@ -22,7 +22,7 @@ import java.util.concurrent.ScheduledExecutorService;
 import java.util.Objects;
 
 import org.apache.karaf.util.maven.Parser;
-import org.ops4j.pax.url.mvn.MavenResolver;
+import org.apache.karaf.features.spi.MavenResolver;
 
 public class MavenDownloadTask extends AbstractRetryableDownloadTask {
 
diff --git a/features/core/src/main/java/org/apache/karaf/features/internal/osgi/Activator.java b/features/core/src/main/java/org/apache/karaf/features/internal/osgi/Activator.java
index 6640b6d699..3daaaf6153 100644
--- a/features/core/src/main/java/org/apache/karaf/features/internal/osgi/Activator.java
+++ b/features/core/src/main/java/org/apache/karaf/features/internal/osgi/Activator.java
@@ -57,6 +57,7 @@ import org.apache.karaf.features.internal.service.BundleInstallSupportImpl;
 import org.apache.karaf.features.internal.service.StateStorage;
 import org.apache.karaf.features.internal.util.SystemExitManager;
 import org.apache.karaf.util.ThreadUtils;
+import org.apache.karaf.features.spi.MavenResolverFactory;
 import org.apache.karaf.util.tracker.BaseActivator;
 import org.apache.karaf.util.tracker.annotation.ProvideService;
 import org.apache.karaf.util.tracker.annotation.RequireService;
@@ -76,15 +77,13 @@ import org.osgi.service.cm.ConfigurationAdmin;
 import org.osgi.service.cm.ManagedService;
 import org.osgi.service.repository.Repository;
 import org.osgi.service.resolver.Resolver;
-import org.osgi.service.url.URLStreamHandlerService;
 import org.osgi.util.tracker.ServiceTracker;
 import org.osgi.util.tracker.ServiceTrackerCustomizer;
 import org.slf4j.LoggerFactory;
 
 @Services(
     requires = {
-            @RequireService(ConfigurationAdmin.class),
-            @RequireService(value = URLStreamHandlerService.class, filter = "(url.handler.protocol=mvn)")
+            @RequireService(ConfigurationAdmin.class)
     },
     provides = {
             @ProvideService(FeaturesService.class),
@@ -102,6 +101,8 @@ public class Activator extends BaseActivator {
     private static final String STATE_FILE = "state.json";
 
     private ServiceTracker<FeaturesListener, FeaturesListener> featuresListenerTracker;
+    private ServiceTracker<MavenResolverFactory, MavenResolverFactory> mavenResolverFactoryTracker;
+    private volatile MavenResolverFactory mavenResolverFactory;
     private FeaturesServiceImpl featuresService;
     private SimpleFeaturesServiceImpl simpleFeaturesService;
     private StandardManageableRegionDigraph digraphMBean;
@@ -118,6 +119,32 @@ public class Activator extends BaseActivator {
     protected void doOpen() throws Exception {
         super.doOpen();
 
+        // Keep the Maven resolver optional so this service can start without Pax URL.
+        mavenResolverFactoryTracker = new ServiceTracker<>(bundleContext, MavenResolverFactory.class,
+                new ServiceTrackerCustomizer<MavenResolverFactory, MavenResolverFactory>() {
+                    @Override
+                    public MavenResolverFactory addingService(ServiceReference<MavenResolverFactory> reference) {
+                        MavenResolverFactory factory = bundleContext.getService(reference);
+                        setMavenResolverFactory(factory);
+                        return factory;
+                    }
+
+                    @Override
+                    public void modifiedService(ServiceReference<MavenResolverFactory> reference,
+                                                MavenResolverFactory factory) {
+                    }
+
+                    @Override
+                    public void removedService(ServiceReference<MavenResolverFactory> reference,
+                                               MavenResolverFactory factory) {
+                        if (mavenResolverFactory == factory) {
+                            setMavenResolverFactory(null);
+                        }
+                        bundleContext.ungetService(reference);
+                    }
+                });
+        mavenResolverFactoryTracker.open();
+
         Properties configuration = new Properties();
         File configFile = new File(System.getProperty("karaf.etc"), FEATURES_SERVICE_CONFIG_FILE);
         if (configFile.isFile() && configFile.canRead()) {
@@ -159,9 +186,8 @@ public class Activator extends BaseActivator {
                 new LinkedBlockingQueue<>(),
                 ThreadUtils.namedThreadFactory("resolver"));
         Resolver resolver = new ResolverImpl(new Slf4jResolverLog(LoggerFactory.getLogger(ResolverImpl.class)), executorService);
-        URLStreamHandlerService mvnUrlHandler = getTrackedService(URLStreamHandlerService.class);
 
-        if (configurationAdmin == null || mvnUrlHandler == null) {
+        if (configurationAdmin == null) {
             return;
         }
 
@@ -200,6 +226,7 @@ public class Activator extends BaseActivator {
                     configurationAdmin,
                     installSupport,
                     cfg);
+            simpleFeaturesService.setMavenResolverFactory(mavenResolverFactory);
             try {
                 EventAdminListener eventAdminListener = new EventAdminListener(bundleContext);
                 simpleFeaturesService.registerListener(eventAdminListener);
@@ -216,6 +243,7 @@ public class Activator extends BaseActivator {
                     installSupport,
                     globalRepository,
                     cfg);
+            featuresService.setMavenResolverFactory(mavenResolverFactory);
             try {
                 EventAdminListener eventAdminListener = new EventAdminListener(bundleContext);
                 featuresService.registerListener(eventAdminListener);
@@ -394,4 +422,23 @@ public class Activator extends BaseActivator {
         }
     }
 
+    @Override
+    protected void doClose() {
+        if (mavenResolverFactoryTracker != null) {
+            mavenResolverFactoryTracker.close();
+            mavenResolverFactoryTracker = null;
+        }
+        super.doClose();
+    }
+
+    private void setMavenResolverFactory(MavenResolverFactory factory) {
+        mavenResolverFactory = factory;
+        if (featuresService != null) {
+            featuresService.setMavenResolverFactory(factory);
+        }
+        if (simpleFeaturesService != null) {
+            simpleFeaturesService.setMavenResolverFactory(factory);
+        }
+    }
+
 }
diff --git a/features/core/src/main/java/org/apache/karaf/features/internal/service/FeaturesProcessingSerializer.java b/features/core/src/main/java/org/apache/karaf/features/internal/service/FeaturesProcessingSerializer.java
index e89bb349d6..78398b94eb 100644
--- a/features/core/src/main/java/org/apache/karaf/features/internal/service/FeaturesProcessingSerializer.java
+++ b/features/core/src/main/java/org/apache/karaf/features/internal/service/FeaturesProcessingSerializer.java
@@ -46,9 +46,8 @@ import javax.xml.transform.stream.StreamResult;
 import org.apache.karaf.features.internal.model.processing.FeaturesProcessing;
 import org.apache.karaf.features.internal.model.processing.ObjectFactory;
 import org.apache.karaf.util.xml.IndentingXMLEventWriter;
-import org.ops4j.pax.swissbox.property.BundleContextPropertyResolver;
-import org.ops4j.util.property.DictionaryPropertyResolver;
-import org.ops4j.util.property.PropertyResolver;
+import org.apache.karaf.features.internal.util.PropertyResolver;
+import org.apache.karaf.features.internal.util.PropertySubstitutor;
 import org.osgi.framework.Bundle;
 import org.osgi.framework.BundleContext;
 import org.osgi.framework.FrameworkUtil;
@@ -106,9 +105,9 @@ public class FeaturesProcessingSerializer {
         Unmarshaller unmarshaller = FEATURES_PROCESSING_CONTEXT.createUnmarshaller();
         UnmarshallerHandler handler = unmarshaller.getUnmarshallerHandler();
 
-        // BundleContextPropertyResolver gives access to e.g., ${karaf.base}
-        final PropertyResolver resolver = bundleContext == null ? new DictionaryPropertyResolver(versions)
-                : new DictionaryPropertyResolver(versions, new BundleContextPropertyResolver(bundleContext));
+        // the bundle context resolver gives access to e.g., ${karaf.base}
+        final PropertyResolver resolver = bundleContext == null ? PropertyResolver.forDictionary(versions)
+                : PropertyResolver.forDictionary(versions, PropertyResolver.forBundleContext(bundleContext));
 
         // indirect unmarshaling with property resolution inside XML attribute values and CDATA
         SAXParserFactory spf = SAXParserFactory.newInstance();
@@ -314,7 +313,7 @@ public class FeaturesProcessingSerializer {
         }
 
         private String resolve(String value) {
-            String resolved = org.ops4j.util.collections.PropertyResolver.resolve(properties, value);
+            String resolved = PropertySubstitutor.substitute(properties, value);
             if (resolved.contains("${")) {
                 // there are still unresolved properties - just log warning
                 LOG.warn("Value {} has unresolved properties, please check configuration.", value);
diff --git a/features/core/src/main/java/org/apache/karaf/features/internal/service/FeaturesServiceImpl.java b/features/core/src/main/java/org/apache/karaf/features/internal/service/FeaturesServiceImpl.java
index 80c08631ab..e75831fdcd 100644
--- a/features/core/src/main/java/org/apache/karaf/features/internal/service/FeaturesServiceImpl.java
+++ b/features/core/src/main/java/org/apache/karaf/features/internal/service/FeaturesServiceImpl.java
@@ -18,6 +18,7 @@ package org.apache.karaf.features.internal.service;
 
 import java.io.File;
 import java.io.FileInputStream;
+import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -65,6 +66,7 @@ import org.apache.karaf.features.Repository;
 import org.apache.karaf.features.RepositoryEvent;
 import org.apache.karaf.features.internal.download.DownloadManager;
 import org.apache.karaf.features.internal.download.DownloadManagers;
+import org.apache.karaf.features.internal.download.impl.LocalMavenResolver;
 import org.apache.karaf.features.internal.model.Features;
 import org.apache.karaf.features.internal.model.JacksonUtil;
 import org.apache.karaf.features.internal.model.JaxbUtil;
@@ -75,8 +77,9 @@ import org.apache.karaf.util.json.JsonReader;
 import org.apache.karaf.util.json.JsonWriter;
 import org.apache.karaf.util.collections.CopyOnWriteArrayIdentityList;
 import org.eclipse.equinox.region.RegionDigraph;
-import org.ops4j.pax.url.mvn.MavenResolver;
-import org.ops4j.pax.url.mvn.MavenResolvers;
+import org.apache.karaf.features.spi.MavenResolver;
+import org.apache.karaf.features.spi.MavenResolverFactory;
+import org.apache.karaf.features.spi.MavenResolvers;
 import org.osgi.framework.Bundle;
 import org.osgi.framework.BundleException;
 import org.osgi.framework.InvalidSyntaxException;
@@ -112,6 +115,8 @@ public class FeaturesServiceImpl implements FeaturesService, BootManaged, Deploy
     private final FeatureRepoFinder featureFinder;
     private final ConfigurationAdmin configurationAdmin;
     private final Resolver resolver;
+
+    private volatile MavenResolverFactory mavenResolverFactory;
     private final BundleInstallSupport installSupport;
     private final FeaturesServiceConfig cfg;
     private RepositoryCache repositories;
@@ -155,7 +160,7 @@ public class FeaturesServiceImpl implements FeaturesService, BootManaged, Deploy
         this.installSupport = installSupport;
         this.globalRepository = globalRepository;
         this.featuresProcessor = new FeaturesProcessorImpl(cfg);
-        this.repositories = new RepositoryCacheImpl(featuresProcessor);
+        this.repositories = new RepositoryCacheImpl(featuresProcessor, this::openMavenUri);
         this.cfg = cfg;
         this.executor = Executors.newSingleThreadExecutor(ThreadUtils.namedThreadFactory("features"));
         loadState();
@@ -1086,18 +1091,58 @@ public class FeaturesServiceImpl implements FeaturesService, BootManaged, Deploy
         }
     }
 
+    /**
+     * Set the {@link MavenResolverFactory} to use. When left unset, a factory is looked up with
+     * {@link MavenResolvers#findFactory()}, then the local Karaf system repository is used if none is found.
+     *
+     * @param mavenResolverFactory the factory to use.
+     */
+    public void setMavenResolverFactory(MavenResolverFactory mavenResolverFactory) {
+        this.mavenResolverFactory = mavenResolverFactory;
+    }
+
+    /**
+     * Open a <code>mvn:</code> URI through the {@link MavenResolver} SPI, so that no <code>mvn</code>
+     * URL handler is needed.
+     *
+     * @return the stream, or <code>null</code> if the URI should be opened as a regular URL.
+     */
+    private InputStream openMavenUri(URI uri) throws IOException {
+        if (!"mvn".equals(uri.getScheme())) {
+            return null;
+        }
+        MavenResolverFactory factory = mavenResolverFactory != null ? mavenResolverFactory : MavenResolvers.findFactory();
+        if (factory == null) {
+            try (MavenResolver resolver = LocalMavenResolver.forKarafSystem()) {
+                return new FileInputStream(resolver.resolve(uri.toString()));
+            } catch (FileNotFoundException e) {
+                // not in the system repository, let a mvn URL handler (if any) have a go
+                return null;
+            }
+        }
+        try (MavenResolver resolver = factory.create(getMavenConfig(factory.getConfigurationPid()))) {
+            return new FileInputStream(resolver.resolve(uri.toString()));
+        }
+    }
+
     protected DownloadManager createDownloadManager() throws IOException {
-        Dictionary<String, String> props = getMavenConfig();
-        MavenResolver resolver = MavenResolvers.createMavenResolver(props, "org.ops4j.pax.url.mvn");
+        MavenResolverFactory factory = mavenResolverFactory != null ? mavenResolverFactory : MavenResolvers.findFactory();
+        MavenResolver resolver;
+        if (factory == null) {
+            resolver = LocalMavenResolver.forKarafSystem();
+        } else {
+            Dictionary<String, String> props = getMavenConfig(factory.getConfigurationPid());
+            resolver = factory.create(props);
+        }
         ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(cfg.downloadThreads, ThreadUtils.namedThreadFactory("downloader"));
         executor.setMaximumPoolSize(cfg.downloadThreads);
         return DownloadManagers.createDownloadManager(resolver, executor, cfg.scheduleDelay, cfg.scheduleMaxRun);
     }
 
-    private Dictionary<String, String> getMavenConfig() throws IOException {
+    private Dictionary<String, String> getMavenConfig(String pid) throws IOException {
         Hashtable<String, String> props = new Hashtable<>();
         if (configurationAdmin != null) {
-            Configuration config = configurationAdmin.getConfiguration("org.ops4j.pax.url.mvn", null);
+            Configuration config = configurationAdmin.getConfiguration(pid, null);
             if (config != null) {
                 Dictionary<String, Object> cfg = config.getProcessedProperties(null);
                 if (cfg != null) {
@@ -1232,7 +1277,7 @@ public class FeaturesServiceImpl implements FeaturesService, BootManaged, Deploy
         }
         this.refreshRepositories(uris);
         this.featuresProcessor = new FeaturesProcessorImpl(cfg);
-        this.repositories = new RepositoryCacheImpl(featuresProcessor);
+        this.repositories = new RepositoryCacheImpl(featuresProcessor, this::openMavenUri);
 
         State state = copyState();
         doProvisionInThread(state.requirements, emptyMap(), state, getFeaturesById(), options);
diff --git a/features/core/src/main/java/org/apache/karaf/features/internal/service/RepositoryCacheImpl.java b/features/core/src/main/java/org/apache/karaf/features/internal/service/RepositoryCacheImpl.java
index 6438dc1384..07aa829f0f 100644
--- a/features/core/src/main/java/org/apache/karaf/features/internal/service/RepositoryCacheImpl.java
+++ b/features/core/src/main/java/org/apache/karaf/features/internal/service/RepositoryCacheImpl.java
@@ -39,8 +39,15 @@ public class RepositoryCacheImpl implements RepositoryCache {
     private final Map<String, Repository> repositoryCache = new HashMap<>();
     private final FeaturesProcessor featuresProcessor;
 
+    private final RepositoryImpl.UriOpener opener;
+
     public RepositoryCacheImpl(FeaturesProcessor featuresProcessor) {
+        this(featuresProcessor, null);
+    }
+
+    public RepositoryCacheImpl(FeaturesProcessor featuresProcessor, RepositoryImpl.UriOpener opener) {
         this.featuresProcessor = featuresProcessor;
+        this.opener = opener;
     }
 
     @Override
@@ -48,7 +55,7 @@ public class RepositoryCacheImpl implements RepositoryCache {
         if (featuresProcessor != null && featuresProcessor.isRepositoryBlacklisted(uri.toString())) {
             return new RepositoryImpl(uri, new Features(), true);
         }
-        RepositoryImpl repository = new RepositoryImpl(uri, validate);
+        RepositoryImpl repository = new RepositoryImpl(uri, validate, opener);
         if (featuresProcessor != null) {
             // maybe it could be done better - first we have to set if entire repo is blacklisted
             repository.setBlacklisted(featuresProcessor.isRepositoryBlacklisted(uri.toString()));
diff --git a/features/core/src/main/java/org/apache/karaf/features/internal/service/RepositoryImpl.java b/features/core/src/main/java/org/apache/karaf/features/internal/service/RepositoryImpl.java
index 4c1ce07e8d..56e97c3365 100644
--- a/features/core/src/main/java/org/apache/karaf/features/internal/service/RepositoryImpl.java
+++ b/features/core/src/main/java/org/apache/karaf/features/internal/service/RepositoryImpl.java
@@ -47,8 +47,31 @@ public class RepositoryImpl implements Repository {
     }
 
     public RepositoryImpl(URI uri, boolean validate) {
+        this(uri, validate, null);
+    }
+
+    /**
+     * Constructs a repository, loading it through the given opener.
+     * @param uri the repository URI
+     * @param validate whether to validate the features XML
+     * @param opener opens the URI, or <code>null</code> to always use {@link URI#toURL()}
+     */
+    public RepositoryImpl(URI uri, boolean validate, UriOpener opener) {
         this.uri = uri;
-        load(validate);
+        load(validate, opener);
+    }
+
+    /**
+     * Opens a repository URI without going through a {@link java.net.URLStreamHandler}.
+     */
+    @FunctionalInterface
+    public interface UriOpener {
+        /**
+         * @param uri the URI to open
+         * @return the stream, or <code>null</code> if this opener can't handle the URI
+         * @throws IOException if the URI can be handled but not opened
+         */
+        InputStream open(URI uri) throws IOException;
     }
 
     /**
@@ -111,13 +134,16 @@ public class RepositoryImpl implements Repository {
         features.setBlacklisted(blacklisted);
     }
 
-    private void load(boolean validate) {
+    private void load(boolean validate, UriOpener opener) {
         if (features == null) {
-            try (InputStream inputStream = new InterruptibleInputStream(uri.toURL().openStream())) {
-                if (JacksonUtil.isJson(uri.toASCIIString())) {
-                    features = JacksonUtil.unmarshal(uri.toASCIIString());
-                } else {
-                    features = JaxbUtil.unmarshal(uri.toASCIIString(), inputStream, validate);
+            try {
+                InputStream opened = opener != null ? opener.open(uri) : null;
+                boolean resolved = opened != null;
+                if (!resolved) {
+                    opened = uri.toURL().openStream();
+                }
+                try (InputStream inputStream = new InterruptibleInputStream(opened)) {
+                    unmarshal(inputStream, resolved, validate);
                 }
             } catch (Exception e) {
                 throw new RuntimeException(e.getMessage() + " : " + uri, e);
@@ -125,6 +151,14 @@ public class RepositoryImpl implements Repository {
         }
     }
 
+    private void unmarshal(InputStream inputStream, boolean resolved, boolean validate) throws Exception {
+        if (JacksonUtil.isJson(uri.toASCIIString())) {
+            features = resolved ? JacksonUtil.unmarshal(inputStream) : JacksonUtil.unmarshal(uri.toASCIIString());
+        } else {
+            features = JaxbUtil.unmarshal(uri.toASCIIString(), inputStream, validate);
+        }
+    }
+
     /**
      * An extension point to alter {@link Features JAXB model of features}
      * @param processor
diff --git a/features/core/src/main/java/org/apache/karaf/features/internal/service/SimpleFeaturesServiceImpl.java b/features/core/src/main/java/org/apache/karaf/features/internal/service/SimpleFeaturesServiceImpl.java
index 1e33492b47..11e667df6e 100644
--- a/features/core/src/main/java/org/apache/karaf/features/internal/service/SimpleFeaturesServiceImpl.java
+++ b/features/core/src/main/java/org/apache/karaf/features/internal/service/SimpleFeaturesServiceImpl.java
@@ -16,6 +16,8 @@
  */
 package org.apache.karaf.features.internal.service;
 
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.StringWriter;
 import java.net.URI;
@@ -60,13 +62,15 @@ import org.apache.karaf.features.RepositoryEvent;
 import java.io.InputStream;
 import org.apache.karaf.features.internal.download.DownloadManager;
 import org.apache.karaf.features.internal.download.DownloadManagers;
+import org.apache.karaf.features.internal.download.impl.LocalMavenResolver;
 import org.apache.karaf.features.internal.model.Features;
 import org.apache.karaf.features.internal.model.JacksonUtil;
 import org.apache.karaf.features.internal.model.JaxbUtil;
 import org.apache.karaf.util.ThreadUtils;
 import org.apache.karaf.util.collections.CopyOnWriteArrayIdentityList;
-import org.ops4j.pax.url.mvn.MavenResolver;
-import org.ops4j.pax.url.mvn.MavenResolvers;
+import org.apache.karaf.features.spi.MavenResolver;
+import org.apache.karaf.features.spi.MavenResolverFactory;
+import org.apache.karaf.features.spi.MavenResolvers;
 import org.osgi.framework.Bundle;
 import org.osgi.framework.BundleException;
 import org.osgi.framework.InvalidSyntaxException;
@@ -108,6 +112,8 @@ public class SimpleFeaturesServiceImpl implements FeaturesService, BootManaged,
     private final ConfigurationAdmin configurationAdmin;
     private final BundleInstallSupport installSupport;
     private final FeaturesServiceConfig cfg;
+
+    private volatile MavenResolverFactory mavenResolverFactory;
     private RepositoryCache repositories;
     private FeaturesProcessor featuresProcessor;
 
@@ -131,7 +137,7 @@ public class SimpleFeaturesServiceImpl implements FeaturesService, BootManaged,
         this.configurationAdmin = configurationAdmin;
         this.installSupport = installSupport;
         this.featuresProcessor = new FeaturesProcessorImpl(cfg);
-        this.repositories = new RepositoryCacheImpl(featuresProcessor);
+        this.repositories = new RepositoryCacheImpl(featuresProcessor, this::openMavenUri);
         this.cfg = cfg;
         this.executor = Executors.newSingleThreadExecutor(ThreadUtils.namedThreadFactory("simple-features"));
         loadState();
@@ -718,7 +724,7 @@ public class SimpleFeaturesServiceImpl implements FeaturesService, BootManaged,
         }
         this.refreshRepositories(uris);
         this.featuresProcessor = new FeaturesProcessorImpl(cfg);
-        this.repositories = new RepositoryCacheImpl(featuresProcessor);
+        this.repositories = new RepositoryCacheImpl(featuresProcessor, this::openMavenUri);
 
         State state = copyState();
         doProvisionInThread(state.requirements, state, options);
@@ -842,18 +848,58 @@ public class SimpleFeaturesServiceImpl implements FeaturesService, BootManaged,
         }
     }
 
+    /**
+     * Set the {@link MavenResolverFactory} to use. When left unset, a factory is looked up with
+     * {@link MavenResolvers#findFactory()}, then the local Karaf system repository is used if none is found.
+     *
+     * @param mavenResolverFactory the factory to use.
+     */
+    public void setMavenResolverFactory(MavenResolverFactory mavenResolverFactory) {
+        this.mavenResolverFactory = mavenResolverFactory;
+    }
+
+    /**
+     * Open a <code>mvn:</code> URI through the {@link MavenResolver} SPI, so that no <code>mvn</code>
+     * URL handler is needed.
+     *
+     * @return the stream, or <code>null</code> if the URI should be opened as a regular URL.
+     */
+    private InputStream openMavenUri(URI uri) throws IOException {
+        if (!"mvn".equals(uri.getScheme())) {
+            return null;
+        }
+        MavenResolverFactory factory = mavenResolverFactory != null ? mavenResolverFactory : MavenResolvers.findFactory();
+        if (factory == null) {
+            try (MavenResolver resolver = LocalMavenResolver.forKarafSystem()) {
+                return new FileInputStream(resolver.resolve(uri.toString()));
+            } catch (FileNotFoundException e) {
+                // not in the system repository, let a mvn URL handler (if any) have a go
+                return null;
+            }
+        }
+        try (MavenResolver resolver = factory.create(getMavenConfig(factory.getConfigurationPid()))) {
+            return new FileInputStream(resolver.resolve(uri.toString()));
+        }
+    }
+
     protected DownloadManager createDownloadManager() throws IOException {
-        Dictionary<String, String> props = getMavenConfig();
-        MavenResolver resolver = MavenResolvers.createMavenResolver(props, "org.ops4j.pax.url.mvn");
+        MavenResolverFactory factory = mavenResolverFactory != null ? mavenResolverFactory : MavenResolvers.findFactory();
+        MavenResolver resolver;
+        if (factory == null) {
+            resolver = LocalMavenResolver.forKarafSystem();
+        } else {
+            Dictionary<String, String> props = getMavenConfig(factory.getConfigurationPid());
+            resolver = factory.create(props);
+        }
         ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(cfg.downloadThreads, ThreadUtils.namedThreadFactory("downloader"));
         executor.setMaximumPoolSize(cfg.downloadThreads);
         return DownloadManagers.createDownloadManager(resolver, executor, cfg.scheduleDelay, cfg.scheduleMaxRun);
     }
 
-    private Dictionary<String, String> getMavenConfig() throws IOException {
+    private Dictionary<String, String> getMavenConfig(String pid) throws IOException {
         Hashtable<String, String> props = new Hashtable<>();
         if (configurationAdmin != null) {
-            Configuration config = configurationAdmin.getConfiguration("org.ops4j.pax.url.mvn", null);
+            Configuration config = configurationAdmin.getConfiguration(pid, null);
             if (config != null) {
                 Dictionary<String, Object> cfg = config.getProcessedProperties(null);
                 if (cfg != null) {
diff --git a/features/core/src/main/java/org/apache/karaf/features/internal/util/PropertyResolver.java b/features/core/src/main/java/org/apache/karaf/features/internal/util/PropertyResolver.java
new file mode 100644
index 0000000000..77f9093429
--- /dev/null
+++ b/features/core/src/main/java/org/apache/karaf/features/internal/util/PropertyResolver.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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.apache.karaf.features.internal.util;
+
+import java.util.Dictionary;
+
+import org.osgi.framework.BundleContext;
+
+/**
+ * Resolves a property by name, optionally delegating to a fallback resolver.
+ *
+ * <p>A blank value is treated as no value, so resolution falls through to the fallback.</p>
+ */
+@FunctionalInterface
+public interface PropertyResolver {
+
+    /**
+     * Resolve a property.
+     *
+     * @param key the property name.
+     * @return the property value, or <code>null</code> if unknown.
+     */
+    String get(String key);
+
+    /**
+     * A resolver backed by a dictionary.
+     *
+     * @param properties the properties to read, may be <code>null</code>.
+     * @return the resolver.
+     */
+    static PropertyResolver forDictionary(Dictionary<?, ?> properties) {
+        return forDictionary(properties, null);
+    }
+
+    /**
+     * A resolver backed by a dictionary, falling back to another resolver.
+     *
+     * @param properties the properties to read, may be <code>null</code>.
+     * @param fallback the resolver to consult when the dictionary has no value, may be <code>null</code>.
+     * @return the resolver.
+     */
+    static PropertyResolver forDictionary(Dictionary<?, ?> properties, PropertyResolver fallback) {
+        return key -> {
+            String value = null;
+            if (properties != null) {
+                Object raw = properties.get(key);
+                if (raw instanceof String) {
+                    value = (String) raw;
+                }
+            }
+            if (value != null && value.trim().isEmpty()) {
+                value = null;
+            }
+            if (value == null && fallback != null) {
+                value = fallback.get(key);
+            }
+            return value;
+        };
+    }
+
+    /**
+     * A resolver backed by the framework properties, which themselves fall back to system properties.
+     * It gives access to e.g. <code>${karaf.base}</code>.
+     *
+     * @param bundleContext the bundle context to read, may be <code>null</code>.
+     * @return the resolver.
+     */
+    static PropertyResolver forBundleContext(BundleContext bundleContext) {
+        return key -> {
+            String value = bundleContext == null ? null : bundleContext.getProperty(key);
+            return value != null && value.trim().isEmpty() ? null : value;
+        };
+    }
+
+}
diff --git a/features/core/src/main/java/org/apache/karaf/features/internal/util/PropertySubstitutor.java b/features/core/src/main/java/org/apache/karaf/features/internal/util/PropertySubstitutor.java
new file mode 100644
index 0000000000..6e0a76354a
--- /dev/null
+++ b/features/core/src/main/java/org/apache/karaf/features/internal/util/PropertySubstitutor.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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.apache.karaf.features.internal.util;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.Properties;
+import java.util.StringTokenizer;
+
+/**
+ * Substitutes <code>${...}</code> placeholders in a string.
+ *
+ * <p>System properties take precedence over the supplied properties. Placeholders that can't be
+ * resolved are left untouched, so callers can detect and report them. Nesting
+ * (<code>${a${b}}</code>) is supported.</p>
+ */
+public final class PropertySubstitutor {
+
+    private static final String MARKER = "${";
+
+    private PropertySubstitutor() { }
+
+    /**
+     * Substitute the placeholders found in <code>value</code>.
+     *
+     * @param properties the properties to resolve placeholders against.
+     * @param value the value to substitute, may be <code>null</code>.
+     * @return the substituted value.
+     */
+    public static String substitute(Properties properties, String value) {
+        if (value == null || value.indexOf('$') < 0 || !value.contains(MARKER)) {
+            return value;
+        }
+        Deque<String> stack = new ArrayDeque<>();
+        StringTokenizer tokenizer = new StringTokenizer(value, "${}", true);
+        while (tokenizer.hasMoreTokens()) {
+            processToken(tokenizer.nextToken(), stack, properties);
+        }
+        StringBuilder result = new StringBuilder();
+        while (!stack.isEmpty()) {
+            result.insert(0, stack.pop());
+        }
+        return result.toString();
+    }
+
+    private static void processToken(String token, Deque<String> stack, Properties properties) {
+        if ("}".equals(token)) {
+            if (stack.size() < 2) {
+                // unbalanced closing brace - keep it verbatim
+                push(stack, token);
+                return;
+            }
+            String name = stack.pop();
+            String marker = stack.pop();
+            if (MARKER.equals(marker)) {
+                startProperty(name, properties, stack);
+            } else {
+                push(stack, marker);
+                push(stack, MARKER + name + "}");
+            }
+        } else if ("$".equals(token)) {
+            stack.push(token);
+        } else {
+            push(stack, token);
+        }
+    }
+
+    private static void startProperty(String name, Properties properties, Deque<String> stack) {
+        String value = System.getProperty(name);
+        if (value == null && properties != null) {
+            value = properties.getProperty(name);
+        }
+        push(stack, value == null ? MARKER + name + "}" : value);
+    }
+
+    private static void push(Deque<String> stack, String value) {
+        if (stack.isEmpty()) {
+            stack.push(value);
+            return;
+        }
+        String top = stack.pop();
+        if (MARKER.equals(top)) {
+            stack.push(top);
+            stack.push(value);
+        } else {
+            stack.push(top + value);
+        }
+    }
+
+}
diff --git a/features/core/src/main/java/org/apache/karaf/features/spi/MavenResolver.java b/features/core/src/main/java/org/apache/karaf/features/spi/MavenResolver.java
new file mode 100644
index 0000000000..973ba2d8bb
--- /dev/null
+++ b/features/core/src/main/java/org/apache/karaf/features/spi/MavenResolver.java
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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.apache.karaf.features.spi;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * Resolves <code>mvn:</code> URIs to local files.
+ *
+ * <p>This is the only contract the features service requires from a Maven artifact
+ * provider. Implementations are looked up through {@link MavenResolvers}.</p>
+ */
+public interface MavenResolver extends Closeable {
+
+    /**
+     * How likely a failed resolution is to succeed when retried.
+     */
+    enum RetryChance {
+        /** Retrying will never help. */
+        NEVER,
+        /** Retrying may help, but is unlikely to. */
+        LOW,
+        /** Retrying is likely to help. */
+        HIGH,
+        /** Not enough information to tell. */
+        UNKNOWN
+    }
+
+    /**
+     * Resolve a <code>mvn:</code> URI to a local file.
+     *
+     * @param url the URI to resolve.
+     * @return the resolved file.
+     * @throws IOException if the artifact can't be resolved.
+     */
+    File resolve(String url) throws IOException;
+
+    /**
+     * Resolve a <code>mvn:</code> URI to a local file, carrying over the failure of a
+     * previous attempt so the implementation can adjust its behaviour (repository
+     * ordering, update policies, ...).
+     *
+     * <p>This is the method the features service calls for every attempt, the first one
+     * included, in which case <code>previousException</code> is <code>null</code>. The default
+     * implementation ignores the previous failure and delegates to {@link #resolve(String)}.</p>
+     *
+     * @param url the URI to resolve.
+     * @param previousException the exception thrown by the previous attempt, or <code>null</code>.
+     * @return the resolved file.
+     * @throws IOException if the artifact can't be resolved.
+     */
+    default File resolve(String url, Exception previousException) throws IOException {
+        return resolve(url);
+    }
+
+    /**
+     * Resolve a Maven artifact from its coordinates.
+     *
+     * <p>The default implementation builds the equivalent <code>mvn:</code> URI and delegates to
+     * {@link #resolve(String)}.</p>
+     *
+     * @param groupId the group id.
+     * @param artifactId the artifact id.
+     * @param classifier the classifier, or <code>null</code>/empty for none.
+     * @param extension the extension (packaging/type), or <code>null</code>/empty for the default.
+     * @param version the version.
+     * @return the resolved file.
+     * @throws IOException if the artifact can't be resolved.
+     */
+    default File resolve(String groupId, String artifactId, String classifier, String extension, String version)
+            throws IOException {
+        StringBuilder uri = new StringBuilder("mvn:")
+                .append(groupId).append('/').append(artifactId).append('/').append(version);
+        boolean hasClassifier = classifier != null && !classifier.isEmpty();
+        if (hasClassifier || (extension != null && !extension.isEmpty())) {
+            uri.append('/').append(extension == null ? "" : extension);
+        }
+        if (hasClassifier) {
+            uri.append('/').append(classifier);
+        }
+        return resolve(uri.toString());
+    }
+
+    /**
+     * Tell whether a failed resolution is worth retrying.
+     *
+     * @param exception the exception thrown by {@link #resolve(String, Exception)}.
+     * @return the chance that a retry succeeds.
+     */
+    default RetryChance isRetryableException(Exception exception) {
+        return RetryChance.UNKNOWN;
+    }
+
+    @Override
+    default void close() throws IOException {
+    }
+
+}
diff --git a/features/core/src/main/java/org/apache/karaf/features/spi/MavenResolverFactory.java b/features/core/src/main/java/org/apache/karaf/features/spi/MavenResolverFactory.java
new file mode 100644
index 0000000000..a6b7ec69c4
--- /dev/null
+++ b/features/core/src/main/java/org/apache/karaf/features/spi/MavenResolverFactory.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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.apache.karaf.features.spi;
+
+import java.util.Dictionary;
+
+/**
+ * Creates {@link MavenResolver} instances.
+ *
+ * <p>Providers are discovered with {@link java.util.ServiceLoader} (see
+ * {@link MavenResolvers}), so a provider must declare itself in
+ * <code>META-INF/services/org.apache.karaf.features.spi.MavenResolverFactory</code>.</p>
+ */
+public interface MavenResolverFactory {
+
+    /**
+     * The ConfigAdmin PID holding this provider's configuration. It is also the default
+     * prefix of the configuration properties.
+     *
+     * @return the configuration PID.
+     */
+    String getConfigurationPid();
+
+    /**
+     * Create a resolver.
+     *
+     * @param configuration the resolver configuration.
+     * @param propertyPrefix the prefix of the keys to read from <code>configuration</code>.
+     * @return a new resolver.
+     */
+    MavenResolver create(Dictionary<String, String> configuration, String propertyPrefix);
+
+    /**
+     * Create a resolver, reading the configuration under {@link #getConfigurationPid()}.
+     *
+     * @param configuration the resolver configuration.
+     * @return a new resolver.
+     */
+    default MavenResolver create(Dictionary<String, String> configuration) {
+        return create(configuration, getConfigurationPid());
+    }
+
+}
diff --git a/features/core/src/main/java/org/apache/karaf/features/spi/MavenResolvers.java b/features/core/src/main/java/org/apache/karaf/features/spi/MavenResolvers.java
new file mode 100644
index 0000000000..08625d467e
--- /dev/null
+++ b/features/core/src/main/java/org/apache/karaf/features/spi/MavenResolvers.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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.apache.karaf.features.spi;
+
+import java.util.Iterator;
+import java.util.ServiceConfigurationError;
+import java.util.ServiceLoader;
+
+/**
+ * Looks up the {@link MavenResolverFactory} provider to use.
+ */
+public final class MavenResolvers {
+
+    private MavenResolvers() { }
+
+    /**
+     * Look up the factory provided by the class loader of this class.
+     *
+     * @return the factory.
+     * @throws IllegalStateException if no provider is available.
+     */
+    public static MavenResolverFactory factory() {
+        return factory(MavenResolvers.class.getClassLoader());
+    }
+
+    /**
+     * Look up the factory provided by the given class loader.
+     *
+     * @param classLoader the class loader to search for providers.
+     * @return the factory.
+     * @throws IllegalStateException if no provider is available.
+     */
+    public static MavenResolverFactory factory(ClassLoader classLoader) {
+        MavenResolverFactory factory = findFactory(classLoader);
+        if (factory != null) {
+            return factory;
+        }
+        throw new IllegalStateException("No " + MavenResolverFactory.class.getName()
+                + " provider found. Make sure a Maven resolver provider is available.");
+    }
+
+    /**
+     * Look up a factory without failing when no provider is available.
+     *
+     * @return the factory, or <code>null</code> if none is available.
+     */
+    public static MavenResolverFactory findFactory() {
+        return findFactory(MavenResolvers.class.getClassLoader());
+    }
+
+    /**
+     * Look up a factory provided by the given class loader without failing when no provider is available.
+     *
+     * @param classLoader the class loader to search for providers.
+     * @return the factory, or <code>null</code> if none is available.
+     */
+    public static MavenResolverFactory findFactory(ClassLoader classLoader) {
+        try {
+            Iterator<MavenResolverFactory> factories =
+                    ServiceLoader.load(MavenResolverFactory.class, classLoader).iterator();
+            if (factories.hasNext()) {
+                return factories.next();
+            }
+        } catch (ServiceConfigurationError e) {
+            throw new IllegalStateException("Unable to load a " + MavenResolverFactory.class.getName()
+                    + " provider. Make sure a Maven resolver provider is available.", e);
+        }
+        return null;
+    }
+
+}
diff --git a/features/core/src/test/java/org/apache/karaf/features/internal/maven/TestMavenResolverFactory.java b/features/core/src/test/java/org/apache/karaf/features/internal/maven/TestMavenResolverFactory.java
new file mode 100644
index 0000000000..7acbdb242f
--- /dev/null
+++ b/features/core/src/test/java/org/apache/karaf/features/internal/maven/TestMavenResolverFactory.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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.apache.karaf.features.internal.maven;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Dictionary;
+
+import org.apache.karaf.features.spi.MavenResolver;
+import org.apache.karaf.features.spi.MavenResolverFactory;
+
+/**
+ * {@link MavenResolverFactory} for the tests of this module, which never resolve <code>mvn:</code>
+ * URIs - they use their own URL handlers. Any actual resolution is a test bug and fails loudly.
+ */
+public class TestMavenResolverFactory implements MavenResolverFactory {
+
+    public static final String PID = "org.apache.karaf.features.test.mvn";
+
+    @Override
+    public String getConfigurationPid() {
+        return PID;
+    }
+
+    @Override
+    public MavenResolver create(Dictionary<String, String> configuration, String propertyPrefix) {
+        return new MavenResolver() {
+            @Override
+            public File resolve(String url) throws IOException {
+                throw new IOException("No Maven resolver is configured for the features core tests: " + url);
+            }
+        };
+    }
+
+}
diff --git a/features/core/src/test/resources/META-INF/services/org.apache.karaf.features.spi.MavenResolverFactory b/features/core/src/test/resources/META-INF/services/org.apache.karaf.features.spi.MavenResolverFactory
new file mode 100644
index 0000000000..5c8e0b923b
--- /dev/null
+++ b/features/core/src/test/resources/META-INF/services/org.apache.karaf.features.spi.MavenResolverFactory
@@ -0,0 +1,17 @@
+##---------------------------------------------------------------------------
+##  Licensed to the Apache Software Foundation (ASF) under one or more
+##  contributor license agreements.  See the NOTICE file distributed with
+##  this work for additional information regarding copyright ownership.
+##  The ASF licenses this file to You 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
+##
+##     http://www.apache.org/licenses/LICENSE-2.0
+##
+##  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.
+##---------------------------------------------------------------------------
+org.apache.karaf.features.internal.maven.TestMavenResolverFactory
diff --git a/itests/common/pom.xml b/itests/common/pom.xml
index df46b1bf52..a0510d5e48 100644
--- a/itests/common/pom.xml
+++ b/itests/common/pom.xml
@@ -103,6 +103,13 @@
             <groupId>org.apache.karaf.features</groupId>
             <artifactId>org.apache.karaf.features.core</artifactId>
         </dependency>
+
+        <!-- KarafTestContainer resolves mvn: URLs to locate the test distribution;
+             it used to get the Pax URL handler transitively via features.core -->
+        <dependency>
+            <groupId>org.ops4j.pax.url</groupId>
+            <artifactId>pax-url-aether</artifactId>
+        </dependency>
     </dependencies>
 
     <build>
diff --git a/itests/test/pom.xml b/itests/test/pom.xml
index ae41411c47..7c47724e47 100644
--- a/itests/test/pom.xml
+++ b/itests/test/pom.xml
@@ -55,6 +55,12 @@
             <artifactId>common</artifactId>
             <version>${project.version}</version>
         </dependency>
+        <!-- MavenTest grabs the pax-url MavenResolver OSGi service directly -->
+        <dependency>
+            <groupId>org.ops4j.pax.url</groupId>
+            <artifactId>pax-url-aether</artifactId>
+            <scope>test</scope>
+        </dependency>
 
         <dependency>
             <groupId>jakarta.annotation</groupId>
diff --git a/manual/src/main/asciidoc/developer-guide/extending.adoc b/manual/src/main/asciidoc/developer-guide/extending.adoc
index d8f832e124..d821370624 100644
--- a/manual/src/main/asciidoc/developer-guide/extending.adoc
+++ b/manual/src/main/asciidoc/developer-guide/extending.adoc
@@ -57,3 +57,114 @@ See https://github.com/apache/karaf/blob/master/examples/karaf-command-example/R
 You can also extend the Apache Karaf WebConsole by providing and installing a webconsole plugin.
 
 A plugin is an OSGi bundle that registers a Servlet as an OSGi service with webconsole properties.
+
+==== Maven resolver
+
+The features service does not depend on a Maven library to resolve `mvn` URLs. It uses a contract owned by Apache Karaf:
+the `org.apache.karaf.features.spi` package, exported by the `org.apache.karaf.features.core` bundle.
+
+The standard distribution contains one provider of this contract, the `org.apache.karaf.maven.resolver.paxurl` bundle,
+which is backed by Pax URL. It is part of the `framework` feature, at start level 14, just before the features service,
+and it is configured with `etc/org.ops4j.pax.url.mvn.cfg` (see <<_maven_url_handler,Maven URL Handler>>).
+
+You can replace this bundle with your own provider, for instance to create a distribution which does not contain Pax URL.
+
+===== Contract
+
+A provider implements two interfaces:
+
+----
+public interface MavenResolverFactory {
+
+    String getConfigurationPid();
+
+    MavenResolver create(Dictionary<String, String> configuration, String propertyPrefix);
+
+}
+
+public interface MavenResolver extends Closeable {
+
+    File resolve(String url) throws IOException;
+
+}
+----
+
+`getConfigurationPid()` returns the PID of the provider configuration. The features service reads this configuration
+and gives it to `create()`, with the PID as `propertyPrefix`, which is the prefix of the keys to read in `configuration`.
+
+`resolve(String url)` resolves a `mvn` URL to a local file, and throws an `IOException` when the artifact can't be
+resolved. This is the only method a `MavenResolver` has to implement. The other ones have a default implementation:
+
+|===
+|Method |Default implementation
+
+|`resolve(String url, Exception previousException)`
+|Ignores `previousException` and calls `resolve(String url)`.
+
+|`resolve(String groupId, String artifactId, String classifier, String extension, String version)`
+|Creates the corresponding `mvn` URL and calls `resolve(String url)`.
+
+|`isRetryableException(Exception exception)`
+|Returns `UNKNOWN`.
+
+|`close()`
+|Does nothing.
+|===
+
+The features service calls `resolve(String url, Exception previousException)` for each attempt to download an artifact.
+`previousException` is `null` for the first attempt. For the next ones, it is the exception thrown by the previous
+attempt, which a provider can use to resolve the artifact in another way (repositories order, update policy, ...).
+
+When an attempt fails, the features service calls `isRetryableException()` to know if a new attempt is worth it.
+The number of attempts depends on the returned value and on the `scheduleMaxRun` property of `etc/org.apache.karaf.features.cfg`:
+
+* `NEVER`: the download fails without any new attempt. This is what a provider reading a local repository only should return.
+* `LOW` or `HIGH`: half of `scheduleMaxRun` attempts.
+* `UNKNOWN`: `scheduleMaxRun` attempts.
+
+===== Registering a provider
+
+In the container, the features service looks for the `MavenResolverFactory` in the OSGi service registry:
+
+----
+public class Activator implements BundleActivator {
+
+    private ServiceRegistration<MavenResolverFactory> registration;
+
+    @Override
+    public void start(BundleContext context) {
+        registration = context.registerService(MavenResolverFactory.class, new MyMavenResolverFactory(), null);
+    }
+
+    @Override
+    public void stop(BundleContext context) {
+        registration.unregister();
+    }
+
+}
+----
+
+The boot features are installed as soon as the features service starts, so the provider bundle has to be started
+before the features service (start level 15 in the `framework` feature).
+
+A provider replacing the Pax URL one should also register:
+
+* a `MavenResolver` service, used by the components which only have to resolve an artifact, like the `feature:export-bundles` command.
+* a `URLStreamHandlerService` for the `mvn` protocol, as `mvn` URLs are not only used by the features service
+(`bundle:install mvn:...` for instance).
+
+The `karaf-maven-plugin` and the assembly builder are not running in an OSGi framework. They look for the
+`MavenResolverFactory` with `java.util.ServiceLoader`, so a provider used there has to be in the classpath and to contain a
+`META-INF/services/org.apache.karaf.features.spi.MavenResolverFactory` file:
+
+----
+com.example.MyMavenResolverFactory
+----
+
+The `karaf-maven-plugin` depends on `org.apache.karaf.maven.resolver.paxurl`, so it uses Pax URL without any configuration.
+
+===== Without provider
+
+The features service starts even if there is no `MavenResolverFactory`. In that case, it resolves the `mvn` URLs in the
+system repository of the distribution only (`${karaf.home}/${karaf.default.repository}`, `system` by default).
+It never uses a remote repository: an artifact which is not in the system repository can't be resolved.
diff --git a/manual/src/main/asciidoc/user-guide/urls.adoc b/manual/src/main/asciidoc/user-guide/urls.adoc
index 0e578eb4c1..cf2b30981d 100644
--- a/manual/src/main/asciidoc/user-guide/urls.adoc
+++ b/manual/src/main/asciidoc/user-guide/urls.adoc
@@ -49,6 +49,13 @@ In addition to being less verbose, the Maven url handlers can also resolve snaps
 The `org.ops4j.pax.url.mvn` bundle resolves `mvn` URLs. It can be configured using the file `etc/org.ops4j.pax.url.mvn.cfg`.
 Full reference of `org.ops4j.pax.url.mvn` PID configuration can be found on https://ops4j1.jira.com/wiki/display/paxurl/Aether+Configuration[the pax-url Wiki page].
 
+[NOTE]
+====
+The features service does not use Pax URL directly. It resolves `mvn` URLs with a Maven resolver provider.
+The provider of the standard distribution is backed by Pax URL and reads the same `etc/org.ops4j.pax.url.mvn.cfg` file,
+so the properties described here also apply to the features. See <<_maven_resolver,Maven resolver>> in the developer guide to use another provider.
+====
+
 The most important property is:
 
 * `org.ops4j.pax.url.mvn.repositories` : Comma separated list of remote repository URLs that are checked in order of occurence when resolving maven artifacts
diff --git a/maven/pom.xml b/maven/pom.xml
index b0caee874c..0875614361 100644
--- a/maven/pom.xml
+++ b/maven/pom.xml
@@ -35,6 +35,7 @@
 
     <modules>
         <module>core</module>
+        <module>resolver-paxurl</module>
     </modules>
 
 </project>
diff --git a/itests/common/pom.xml b/maven/resolver-paxurl/pom.xml
similarity index 53%
copy from itests/common/pom.xml
copy to maven/resolver-paxurl/pom.xml
index df46b1bf52..0bf6bae259 100644
--- a/itests/common/pom.xml
+++ b/maven/resolver-paxurl/pom.xml
@@ -22,15 +22,17 @@
     <modelVersion>4.0.0</modelVersion>
 
     <parent>
-        <groupId>org.apache.karaf.itests</groupId>
-        <artifactId>itests</artifactId>
+        <groupId>org.apache.karaf</groupId>
+        <artifactId>karaf</artifactId>
         <version>4.5.0-SNAPSHOT</version>
-        <relativePath>../pom.xml</relativePath>
+        <relativePath>../../pom.xml</relativePath>
     </parent>
 
-    <artifactId>common</artifactId>
-    <name>Apache Karaf :: Integration Tests :: Common</name>
+    <groupId>org.apache.karaf.maven</groupId>
+    <artifactId>org.apache.karaf.maven.resolver.paxurl</artifactId>
     <packaging>bundle</packaging>
+    <name>Apache Karaf :: Maven :: Resolver :: Pax URL</name>
+    <description>Provides the Karaf MavenResolver SPI on top of pax-url-aether.</description>
 
     <dependencyManagement>
         <dependencies>
@@ -45,84 +47,54 @@
     </dependencyManagement>
 
     <dependencies>
-        <dependency>
-            <groupId>org.ops4j.pax.exam</groupId>
-            <artifactId>pax-exam-container-karaf</artifactId>
-            <scope>compile</scope>
-            <optional>true</optional>
-            <exclusions>
-                <exclusion>
-                    <groupId>org.apache.felix</groupId>
-                    <artifactId>org.apache.felix.configadmin</artifactId>
-                </exclusion>
-            </exclusions>
-        </dependency>
-
-        <dependency>
-            <groupId>org.ops4j.pax.exam</groupId>
-            <artifactId>pax-exam-junit4</artifactId>
-            <scope>compile</scope>
-        </dependency>
-
-        <dependency>
-            <groupId>junit</groupId>
-            <artifactId>junit</artifactId>
-            <scope>compile</scope>
-        </dependency>
-
-        <dependency>
-            <groupId>org.apache.karaf.shell</groupId>
-            <artifactId>org.apache.karaf.shell.core</artifactId>
-            <scope>compile</scope>
-        </dependency>
-
-        <dependency>
-            <groupId>org.apache.geronimo.specs</groupId>
-            <artifactId>geronimo-atinject_1.0_spec</artifactId>
-            <version>1.2</version>
-            <scope>compile</scope>
-        </dependency>
-
         <dependency>
             <groupId>org.osgi</groupId>
             <artifactId>org.osgi.framework</artifactId>
-            <scope>compile</scope>
         </dependency>
         <dependency>
             <groupId>org.osgi</groupId>
-            <artifactId>org.osgi.util.tracker</artifactId>
-            <scope>compile</scope>
+            <artifactId>org.osgi.service.cm</artifactId>
         </dependency>
-        <dependency>
-            <groupId>org.apache.felix</groupId>
-            <artifactId>org.apache.felix.configadmin</artifactId>
-            <scope>compile</scope>
-        </dependency>
-
         <dependency>
             <groupId>org.apache.karaf.features</groupId>
             <artifactId>org.apache.karaf.features.core</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <!-- compile scope on purpose: non-OSGi users of this provider (the assembly builder,
+             the karaf-maven-plugin) need pax-url-aether on their flat classpath -->
+        <dependency>
+            <groupId>org.ops4j.pax.url</groupId>
+            <artifactId>pax-url-aether</artifactId>
         </dependency>
     </dependencies>
 
     <build>
-        <resources>
-            <resource>
-                <directory>src/main/filtered-resources</directory>
-                <filtering>true</filtering>
-            </resource>
-        </resources>
         <plugins>
             <plugin>
                 <groupId>org.apache.felix</groupId>
                 <artifactId>maven-bundle-plugin</artifactId>
                 <configuration>
                     <instructions>
-                        <Export-Package>
-                            org.apache.karaf.itests
-                        </Export-Package>
+                        <Bundle-SymbolicName>${project.artifactId}</Bundle-SymbolicName>
+                        <Bundle-Activator>
+                            org.apache.karaf.maven.resolver.paxurl.Activator
+                        </Bundle-Activator>
+                        <Bundle-Vendor>The Apache Software Foundation</Bundle-Vendor>
+                        <Import-Package>
+                            org.osgi.framework;version="[1,3)",
+                            org.apache.karaf.features.spi,
+                            org.ops4j.pax.url.mvn,
+                            org.osgi.service.cm;resolution:=optional,
+                            *
+                        </Import-Package>
+                        <Private-Package>
+                            org.apache.karaf.maven.resolver.paxurl
+                        </Private-Package>
+                        <Export-Service>
+                            org.apache.karaf.features.spi.MavenResolverFactory,
+                            org.apache.karaf.features.spi.MavenResolver
+                        </Export-Service>
                     </instructions>
-                    <excludeDependencies>geronimo-atinject_1.0_spec</excludeDependencies>
                 </configuration>
             </plugin>
         </plugins>
diff --git a/maven/resolver-paxurl/src/main/java/org/apache/karaf/maven/resolver/paxurl/Activator.java b/maven/resolver-paxurl/src/main/java/org/apache/karaf/maven/resolver/paxurl/Activator.java
new file mode 100644
index 0000000000..12e5f001be
--- /dev/null
+++ b/maven/resolver-paxurl/src/main/java/org/apache/karaf/maven/resolver/paxurl/Activator.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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.apache.karaf.maven.resolver.paxurl;
+
+import java.io.IOException;
+import java.util.Hashtable;
+
+import org.apache.karaf.features.spi.MavenResolver;
+import org.apache.karaf.features.spi.MavenResolverFactory;
+import org.osgi.framework.BundleActivator;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.ServiceRegistration;
+
+/**
+ * Registers the pax-url backed {@link MavenResolverFactory}, so that the features service can
+ * resolve <code>mvn:</code> URIs, along with a ready to use {@link MavenResolver} for the
+ * components that just want to resolve an artifact.
+ */
+public class Activator implements BundleActivator {
+
+    private ServiceRegistration<MavenResolverFactory> factoryRegistration;
+    private ServiceRegistration<MavenResolver> resolverRegistration;
+    private LazyMavenResolver resolver;
+
+    @Override
+    public void start(BundleContext context) {
+        Hashtable<String, Object> properties = new Hashtable<>();
+        properties.put("provider", "pax-url-aether");
+
+        MavenResolverFactory factory = new PaxUrlMavenResolverFactory();
+        factoryRegistration = context.registerService(MavenResolverFactory.class, factory, properties);
+
+        resolver = new LazyMavenResolver(context, factory);
+        resolverRegistration = context.registerService(MavenResolver.class, resolver, properties);
+    }
+
+    @Override
+    public void stop(BundleContext context) {
+        if (resolverRegistration != null) {
+            resolverRegistration.unregister();
+            resolverRegistration = null;
+        }
+        if (factoryRegistration != null) {
+            factoryRegistration.unregister();
+            factoryRegistration = null;
+        }
+        if (resolver != null) {
+            try {
+                resolver.close();
+            } catch (IOException e) {
+                // the resolver is going away anyway
+            }
+            resolver = null;
+        }
+    }
+
+}
diff --git a/maven/resolver-paxurl/src/main/java/org/apache/karaf/maven/resolver/paxurl/LazyMavenResolver.java b/maven/resolver-paxurl/src/main/java/org/apache/karaf/maven/resolver/paxurl/LazyMavenResolver.java
new file mode 100644
index 0000000000..6199444e3c
--- /dev/null
+++ b/maven/resolver-paxurl/src/main/java/org/apache/karaf/maven/resolver/paxurl/LazyMavenResolver.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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.apache.karaf.maven.resolver.paxurl;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Dictionary;
+import java.util.Enumeration;
+import java.util.Hashtable;
+
+import org.apache.karaf.features.spi.MavenResolver;
+import org.apache.karaf.features.spi.MavenResolverFactory;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.ServiceReference;
+import org.osgi.service.cm.Configuration;
+import org.osgi.service.cm.ConfigurationAdmin;
+
+/**
+ * A {@link MavenResolver} that builds its delegate on first use, from the ConfigAdmin
+ * configuration of the factory's PID.
+ *
+ * <p>Creating it lazily means this bundle does not have to wait for ConfigAdmin to start, and
+ * configuration changes made before the first resolution are picked up.</p>
+ */
+public class LazyMavenResolver implements MavenResolver {
+
+    private final BundleContext bundleContext;
+    private final MavenResolverFactory factory;
+
+    private volatile MavenResolver delegate;
+
+    public LazyMavenResolver(BundleContext bundleContext, MavenResolverFactory factory) {
+        this.bundleContext = bundleContext;
+        this.factory = factory;
+    }
+
+    private MavenResolver delegate() throws IOException {
+        MavenResolver resolver = delegate;
+        if (resolver == null) {
+            synchronized (this) {
+                resolver = delegate;
+                if (resolver == null) {
+                    resolver = factory.create(readConfiguration());
+                    delegate = resolver;
+                }
+            }
+        }
+        return resolver;
+    }
+
+    private Dictionary<String, String> readConfiguration() throws IOException {
+        Hashtable<String, String> properties = new Hashtable<>();
+        ServiceReference<ConfigurationAdmin> reference =
+                bundleContext.getServiceReference(ConfigurationAdmin.class);
+        if (reference == null) {
+            return properties;
+        }
+        try {
+            ConfigurationAdmin configurationAdmin = bundleContext.getService(reference);
+            if (configurationAdmin == null) {
+                return properties;
+            }
+            Configuration configuration = configurationAdmin.getConfiguration(factory.getConfigurationPid(), null);
+            if (configuration != null) {
+                Dictionary<String, Object> cfg = configuration.getProcessedProperties(null);
+                if (cfg != null) {
+                    for (Enumeration<String> keys = cfg.keys(); keys.hasMoreElements(); ) {
+                        String key = keys.nextElement();
+                        Object value = cfg.get(key);
+                        if (key != null && value != null) {
+                            properties.put(key, value.toString());
+                        }
+                    }
+                }
+            }
+            return properties;
+        } finally {
+            bundleContext.ungetService(reference);
+        }
+    }
+
+    @Override
+    public File resolve(String url) throws IOException {
+        return delegate().resolve(url);
+    }
+
+    @Override
+    public File resolve(String url, Exception previousException) throws IOException {
+        return delegate().resolve(url, previousException);
+    }
+
+    @Override
+    public File resolve(String groupId, String artifactId, String classifier, String extension, String version)
+            throws IOException {
+        return delegate().resolve(groupId, artifactId, classifier, extension, version);
+    }
+
+    @Override
+    public RetryChance isRetryableException(Exception exception) {
+        MavenResolver resolver = delegate;
+        return resolver == null ? RetryChance.UNKNOWN : resolver.isRetryableException(exception);
+    }
+
+    @Override
+    public void close() throws IOException {
+        MavenResolver resolver = delegate;
+        delegate = null;
+        if (resolver != null) {
+            resolver.close();
+        }
+    }
+
+}
diff --git a/maven/resolver-paxurl/src/main/java/org/apache/karaf/maven/resolver/paxurl/PaxUrlMavenResolver.java b/maven/resolver-paxurl/src/main/java/org/apache/karaf/maven/resolver/paxurl/PaxUrlMavenResolver.java
new file mode 100644
index 0000000000..c178d422df
--- /dev/null
+++ b/maven/resolver-paxurl/src/main/java/org/apache/karaf/maven/resolver/paxurl/PaxUrlMavenResolver.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * 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.apache.karaf.maven.resolver.paxurl;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Objects;
+
+import org.apache.karaf.features.spi.MavenResolver;
+
+/**
+ * Adapts a pax-url-aether resolver to the Karaf {@link MavenResolver} SPI.
+ *
+ * <p>This bundle is the only place in Karaf that links against <code>org.ops4j.pax.url.mvn</code>.</p>
+ */
+public class PaxUrlMavenResolver implements MavenResolver {
+
+    private final org.ops4j.pax.url.mvn.MavenResolver delegate;
+
+    public PaxUrlMavenResolver(org.ops4j.pax.url.mvn.MavenResolver delegate) {
+        this.delegate = Objects.requireNonNull(delegate, "delegate");
+    }
+
+    @Override
+    public File resolve(String url) throws IOException {
+        return delegate.resolve(url);
+    }
+
+    @Override
+    public File resolve(String url, Exception previousException) throws IOException {
+        return delegate.resolve(url, previousException);
+    }
+
+    @Override
+    public File resolve(String groupId, String artifactId, String classifier, String extension, String version)
+            throws IOException {
+        return delegate.resolve(groupId, artifactId, classifier, extension, version);
+    }
+
+    @Override
+    public RetryChance isRetryableException(Exception exception) {
+        switch (delegate.isRetryableException(exception)) {
+            case NEVER:
+                return RetryChance.NEVER;
+            case LOW:
+                return RetryChance.LOW;
+            case HIGH:
+                return RetryChance.HIGH;
+            case UNKNOWN:
+            default:
+                return RetryChance.UNKNOWN;
+        }
+    }
+
+    @Override
+    public void close() throws IOException {
+        delegate.close();
+    }
+
+}
diff --git a/features/core/src/main/java/org/apache/karaf/features/internal/download/DownloadManagers.java b/maven/resolver-paxurl/src/main/java/org/apache/karaf/maven/resolver/paxurl/PaxUrlMavenResolverFactory.java
similarity index 50%
copy from features/core/src/main/java/org/apache/karaf/features/internal/download/DownloadManagers.java
copy to maven/resolver-paxurl/src/main/java/org/apache/karaf/maven/resolver/paxurl/PaxUrlMavenResolverFactory.java
index 59d37ead3b..3b754ccbf3 100644
--- a/features/core/src/main/java/org/apache/karaf/features/internal/download/DownloadManagers.java
+++ b/maven/resolver-paxurl/src/main/java/org/apache/karaf/maven/resolver/paxurl/PaxUrlMavenResolverFactory.java
@@ -14,23 +14,29 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.karaf.features.internal.download;
+package org.apache.karaf.maven.resolver.paxurl;
 
-import java.util.concurrent.ScheduledExecutorService;
+import java.util.Dictionary;
 
-import org.apache.karaf.features.internal.download.impl.MavenDownloadManager;
-import org.ops4j.pax.url.mvn.MavenResolver;
+import org.apache.karaf.features.spi.MavenResolver;
+import org.apache.karaf.features.spi.MavenResolverFactory;
+import org.ops4j.pax.url.mvn.MavenResolvers;
 
-public final class DownloadManagers {
+/**
+ * {@link MavenResolverFactory} backed by pax-url-aether.
+ */
+public class PaxUrlMavenResolverFactory implements MavenResolverFactory {
 
-    private DownloadManagers() { }
+    public static final String PID = "org.ops4j.pax.url.mvn";
 
-    public static DownloadManager createDownloadManager(MavenResolver resolver, ScheduledExecutorService executorService) {
-        return createDownloadManager(resolver, executorService, 0, 0);
+    @Override
+    public String getConfigurationPid() {
+        return PID;
     }
 
-    public static DownloadManager createDownloadManager(MavenResolver resolver, ScheduledExecutorService executorService,
-                                                        long scheduleDelay, int scheduleMaxRun) {
-        return new MavenDownloadManager(resolver, executorService, scheduleDelay, scheduleMaxRun);
+    @Override
+    public MavenResolver create(Dictionary<String, String> configuration, String propertyPrefix) {
+        return new PaxUrlMavenResolver(MavenResolvers.createMavenResolver(configuration, propertyPrefix));
     }
+
 }
diff --git a/maven/resolver-paxurl/src/main/resources/META-INF/services/org.apache.karaf.features.spi.MavenResolverFactory b/maven/resolver-paxurl/src/main/resources/META-INF/services/org.apache.karaf.features.spi.MavenResolverFactory
new file mode 100644
index 0000000000..9a4f90e3bd
--- /dev/null
+++ b/maven/resolver-paxurl/src/main/resources/META-INF/services/org.apache.karaf.features.spi.MavenResolverFactory
@@ -0,0 +1,17 @@
+##---------------------------------------------------------------------------
+##  Licensed to the Apache Software Foundation (ASF) under one or more
+##  contributor license agreements.  See the NOTICE file distributed with
+##  this work for additional information regarding copyright ownership.
+##  The ASF licenses this file to You 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
+##
+##     http://www.apache.org/licenses/LICENSE-2.0
+##
+##  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.
+##---------------------------------------------------------------------------
+org.apache.karaf.maven.resolver.paxurl.PaxUrlMavenResolverFactory
diff --git a/profile/pom.xml b/profile/pom.xml
index c038a0c096..c1db488ec4 100644
--- a/profile/pom.xml
+++ b/profile/pom.xml
@@ -100,6 +100,13 @@
             <artifactId>org.apache.karaf.features.core</artifactId>
             <optional>true</optional>
         </dependency>
+        <!-- Runtime provider of org.apache.karaf.features.spi.MavenResolverFactory -->
+        <dependency>
+            <groupId>org.apache.karaf.maven</groupId>
+            <artifactId>org.apache.karaf.maven.resolver.paxurl</artifactId>
+            <version>${project.version}</version>
+            <optional>true</optional>
+        </dependency>
 
         <dependency>
             <groupId>org.eclipse.platform</groupId>
diff --git a/profile/src/main/java/org/apache/karaf/profile/assembly/Builder.java b/profile/src/main/java/org/apache/karaf/profile/assembly/Builder.java
index 76bc5d403c..df4820d12d 100644
--- a/profile/src/main/java/org/apache/karaf/profile/assembly/Builder.java
+++ b/profile/src/main/java/org/apache/karaf/profile/assembly/Builder.java
@@ -93,8 +93,9 @@ import org.apache.karaf.util.ThreadUtils;
 import org.apache.karaf.util.Version;
 import org.apache.karaf.util.config.PropertiesLoader;
 import org.apache.karaf.util.maven.Parser;
-import org.ops4j.pax.url.mvn.MavenResolver;
-import org.ops4j.pax.url.mvn.MavenResolvers;
+import org.apache.karaf.features.spi.MavenResolver;
+import org.apache.karaf.features.spi.MavenResolverFactory;
+import org.apache.karaf.features.spi.MavenResolvers;
 import org.osgi.framework.Constants;
 import org.osgi.framework.wiring.BundleRevision;
 import org.osgi.resource.Resource;
@@ -126,6 +127,11 @@ public class Builder {
     private static final String LIBRARY_CLAUSE_DELEGATE = "delegate";
     private static final String START_LEVEL = "start-level";
 
+    /**
+     * @deprecated the configuration PID is now provided by the
+     * {@link MavenResolverFactory} in use, see {@link MavenResolverFactory#getConfigurationPid()}.
+     */
+    @Deprecated
     public static final String ORG_OPS4J_PAX_URL_MVN_PID = "org.ops4j.pax.url.mvn";
 
     /**
@@ -678,7 +684,7 @@ public class Builder {
     }
 
     /**
-     * Configures builder to use offline pax-url-aether resolver
+     * Configures builder to use an offline Maven resolver
      * @return
      */
     public Builder offline() {
@@ -686,7 +692,7 @@ public class Builder {
     }
 
     /**
-     * Configures whether pax-url-aether resolver should work in offline mode
+     * Configures whether the Maven resolver should work in offline mode
      * @param offline
      * @return
      */
@@ -913,8 +919,8 @@ public class Builder {
         LOGGER.info("Generating Karaf assembly: " + homeDirectory);
 
         //
-        // Create download manager - combination of pax-url-aether and a resolver wrapper that may
-        // alter the way pax-url-aether resolver works
+        // Create download manager - combination of the configured MavenResolver provider and a
+        // resolver wrapper that may alter the way that resolver works
         //
         MavenResolver resolver = createMavenResolver();
         manager = new CustomDownloadManager(resolver, executor, null, translatedUrls);
@@ -1503,18 +1509,19 @@ public class Builder {
     }
 
     private MavenResolver createMavenResolver() {
+        MavenResolverFactory factory = MavenResolvers.factory();
+        String pid = factory.getConfigurationPid();
         Dictionary<String, String> props = new Hashtable<>();
         if (offline) {
-            props.put(ORG_OPS4J_PAX_URL_MVN_PID + "offline", "true");
+            props.put(pid + "offline", "true");
         }
         if (localRepository != null) {
-            props.put(ORG_OPS4J_PAX_URL_MVN_PID + ".localRepository", localRepository);
+            props.put(pid + ".localRepository", localRepository);
         }
         if (mavenRepositories != null) {
-            props.put(ORG_OPS4J_PAX_URL_MVN_PID + ".repositories", mavenRepositories);
+            props.put(pid + ".repositories", mavenRepositories);
         }
-        MavenResolver resolver = MavenResolvers.createMavenResolver(props, ORG_OPS4J_PAX_URL_MVN_PID);
-        return resolverWrapper.apply(resolver);
+        return resolverWrapper.apply(factory.create(props, pid));
     }
 
     /**
diff --git a/profile/src/main/java/org/apache/karaf/profile/assembly/CustomDownloadManager.java b/profile/src/main/java/org/apache/karaf/profile/assembly/CustomDownloadManager.java
index ff49f1159c..60c770ba86 100644
--- a/profile/src/main/java/org/apache/karaf/profile/assembly/CustomDownloadManager.java
+++ b/profile/src/main/java/org/apache/karaf/profile/assembly/CustomDownloadManager.java
@@ -23,7 +23,7 @@ import org.apache.karaf.features.internal.download.Downloader;
 import org.apache.karaf.features.internal.download.impl.AbstractDownloadTask;
 import org.apache.karaf.features.internal.download.impl.MavenDownloadManager;
 import org.apache.karaf.profile.Profile;
-import org.ops4j.pax.url.mvn.MavenResolver;
+import org.apache.karaf.features.spi.MavenResolver;
 
 public class CustomDownloadManager extends MavenDownloadManager {
 
diff --git a/tooling/karaf-maven-plugin/pom.xml b/tooling/karaf-maven-plugin/pom.xml
index 2981d1a788..3357b636ac 100644
--- a/tooling/karaf-maven-plugin/pom.xml
+++ b/tooling/karaf-maven-plugin/pom.xml
@@ -208,6 +208,12 @@
             <groupId>org.ops4j.pax.url</groupId>
             <artifactId>pax-url-aether</artifactId>
         </dependency>
+        <!-- Provider of org.apache.karaf.features.spi.MavenResolverFactory -->
+        <dependency>
+            <groupId>org.apache.karaf.maven</groupId>
+            <artifactId>org.apache.karaf.maven.resolver.paxurl</artifactId>
+            <version>${project.version}</version>
+        </dependency>
         <dependency>
             <groupId>org.ops4j.pax.url</groupId>
             <artifactId>pax-url-war</artifactId>
diff --git a/tooling/karaf-maven-plugin/src/main/java/org/apache/karaf/tooling/VerifyMojo.java b/tooling/karaf-maven-plugin/src/main/java/org/apache/karaf/tooling/VerifyMojo.java
index 88da22a5b6..a13bc88b45 100644
--- a/tooling/karaf-maven-plugin/src/main/java/org/apache/karaf/tooling/VerifyMojo.java
+++ b/tooling/karaf-maven-plugin/src/main/java/org/apache/karaf/tooling/VerifyMojo.java
@@ -93,8 +93,8 @@ import org.apache.maven.plugins.annotations.Parameter;
 import org.apache.maven.plugins.annotations.ResolutionScope;
 import org.apache.maven.project.MavenProject;
 import org.eclipse.aether.repository.WorkspaceReader;
-import org.ops4j.pax.url.mvn.MavenResolver;
-import org.ops4j.pax.url.mvn.MavenResolvers;
+import org.apache.karaf.features.spi.MavenResolver;
+import org.apache.karaf.features.spi.MavenResolvers;
 import org.osgi.framework.Bundle;
 import org.osgi.framework.BundleException;
 import org.osgi.framework.Constants;
@@ -200,7 +200,7 @@ public class VerifyMojo extends MojoSupport {
         }
 
         // TODO: add more configuration bits ?
-        resolver = new ReactorMavenResolver(reactor, MavenResolvers.createMavenResolver(config, "maven"));
+        resolver = new ReactorMavenResolver(reactor, MavenResolvers.factory().create(config, "maven"));
         doExecute();
     }
 
diff --git a/tooling/karaf-maven-plugin/src/main/java/org/apache/karaf/tooling/utils/ReactorMavenResolver.java b/tooling/karaf-maven-plugin/src/main/java/org/apache/karaf/tooling/utils/ReactorMavenResolver.java
index c704da1bb9..779017adcc 100644
--- a/tooling/karaf-maven-plugin/src/main/java/org/apache/karaf/tooling/utils/ReactorMavenResolver.java
+++ b/tooling/karaf-maven-plugin/src/main/java/org/apache/karaf/tooling/utils/ReactorMavenResolver.java
@@ -20,12 +20,11 @@ import java.io.File;
 import java.io.IOException;
 import java.net.MalformedURLException;
 
+import org.apache.karaf.features.spi.MavenResolver;
+import org.apache.karaf.util.maven.Parser;
 import org.eclipse.aether.artifact.Artifact;
 import org.eclipse.aether.artifact.DefaultArtifact;
 import org.eclipse.aether.repository.WorkspaceReader;
-import org.ops4j.pax.url.mvn.MavenResolver;
-import org.ops4j.pax.url.mvn.ServiceConstants;
-import org.ops4j.pax.url.mvn.internal.Parser;
 
 /**
  * {@link MavenResolver} that may look up artifacts inside Maven reactor
@@ -41,7 +40,7 @@ public class ReactorMavenResolver implements MavenResolver {
     }
 
     private Artifact toArtifact(String url) throws MalformedURLException {
-        if (url.startsWith(ServiceConstants.PROTOCOL + ":")) {
+        if (url.startsWith("mvn:")) {
             url = url.substring(4);
         }
         Parser parser = new Parser(url);
@@ -64,35 +63,10 @@ public class ReactorMavenResolver implements MavenResolver {
     }
 
     @Override
-    public File resolve(String groupId, String artifactId, String classifier, String extension, String version) throws IOException {
+    public File resolve(String groupId, String artifactId, String classifier, String extension, String version)
+            throws IOException {
         File file = reactor.findArtifact(new DefaultArtifact(groupId, artifactId, classifier, extension, version));
-        return file == null ? fallback.resolve(String.format("mvn:%s/%s/%s/%s/%s", groupId, artifactId, version, extension, classifier)) : file;
-    }
-
-    @Override
-    public File resolve(String groupId, String artifactId, String classifier, String extension, String version, Exception previousException) throws IOException {
-        File file = reactor.findArtifact(new DefaultArtifact(groupId, artifactId, classifier, extension, version));
-        return file == null ? fallback.resolve(String.format("mvn:%s/%s/%s/%s/%s", groupId, artifactId, version, extension, classifier), previousException) : file;
-    }
-
-    @Override
-    public File resolveMetadata(String groupId, String artifactId, String type, String version) throws IOException {
-        return fallback.resolveMetadata(groupId, artifactId, type, version);
-    }
-
-    @Override
-    public File resolveMetadata(String groupId, String artifactId, String type, String version, Exception previousException) throws IOException {
-        return fallback.resolveMetadata(groupId, artifactId, type, version, previousException);
-    }
-
-    @Override
-    public void upload(String groupId, String artifactId, String classifier, String extension, String version, File artifact) throws IOException {
-        fallback.upload(groupId, artifactId, classifier, extension, version, artifact);
-    }
-
-    @Override
-    public void uploadMetadata(String groupId, String artifactId, String type, String version, File artifact) throws IOException {
-        fallback.uploadMetadata(groupId, artifactId, type, version, artifact);
+        return file == null ? fallback.resolve(groupId, artifactId, classifier, extension, version) : file;
     }
 
     @Override