diff --git a/spring-modulith-core/src/main/java/com/tngtech/archunit/core/importer/Location.java b/spring-modulith-core/src/main/java/com/tngtech/archunit/core/importer/Location.java
new file mode 100644
index 00000000..aec9121b
--- /dev/null
+++ b/spring-modulith-core/src/main/java/com/tngtech/archunit/core/importer/Location.java
@@ -0,0 +1,395 @@
+/*
+ * Copyright 2014-2023 TNG Technology Consulting GmbH
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * 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 com.tngtech.archunit.core.importer;
+
+import static com.tngtech.archunit.PublicAPI.Usage.*;
+import static com.tngtech.archunit.thirdparty.com.google.common.base.Preconditions.*;
+import static java.util.Collections.*;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+import com.tngtech.archunit.PublicAPI;
+import com.tngtech.archunit.base.ArchUnitException.LocationException;
+import com.tngtech.archunit.base.ArchUnitException.UnsupportedUriSchemeException;
+import com.tngtech.archunit.core.InitialConfiguration;
+import com.tngtech.archunit.thirdparty.com.google.common.cache.Cache;
+import com.tngtech.archunit.thirdparty.com.google.common.cache.CacheBuilder;
+import com.tngtech.archunit.thirdparty.com.google.common.collect.ImmutableList;
+
+/**
+ * Handles various forms of location from where classes can be imported in a consistent way. Any location will be
+ * treated like a {@link URI}, thus there will not be any platform dependent file separator problems.
+ *
+ * Examples for locations could be
+ *
+ * file:///home/someuser/workspace/myproject/target/classes/myproject/Foo.class
+ * jar:file:///home/someuser/.m2/repository/myproject/foolib.jar!/myproject/Foo.class
+ *
+ */
+@PublicAPI(usage = ACCESS)
+public abstract class Location {
+ private static final InitialConfiguration> factories = new InitialConfiguration<>();
+
+ static {
+ ImportPlugin.Loader.loadForCurrentPlatform().plugInLocationFactories(factories);
+ }
+
+ private static final Cache> ENTRY_CACHE = CacheBuilder.newBuilder()
+ .build();
+
+ final NormalizedUri uri;
+
+ Location(NormalizedUri uri) {
+ this.uri = checkNotNull(uri);
+ }
+
+ @PublicAPI(usage = ACCESS)
+ public URI asURI() {
+ return uri.toURI();
+ }
+
+ abstract ClassFileSource asClassFileSource(ImportOptions importOptions);
+
+ /**
+ * @param part A part to check the respective location {@link URI} for
+ * @return {@code true}, if the respective {@link URI} contains the given part, {@code false} otherwise
+ */
+ @PublicAPI(usage = ACCESS)
+ public boolean contains(String part) {
+ return uri.toString().contains(part);
+ }
+
+ /**
+ * @param pattern A pattern to compare the respective location {@link URI} against
+ * @return {@code true}, if the respective {@link URI} matches the given pattern, {@code false} otherwise
+ */
+ @PublicAPI(usage = ACCESS)
+ public boolean matches(Pattern pattern) {
+ return pattern.matcher(uri.toString()).matches();
+ }
+
+ @PublicAPI(usage = ACCESS)
+ public abstract boolean isJar();
+
+ /**
+ * This is a generalization of {@link #isJar()}. Before JDK 9, the only archives were Jar files, starting with JDK 9,
+ * we also have JRTs (the JDK modules).
+ *
+ * @return {@code true}, if this location represents an archive, like a JAR or JRT, {@code false} otherwise
+ */
+ @PublicAPI(usage = ACCESS)
+ public abstract boolean isArchive();
+
+ // NOTE: URI behaves strange, if it is a JAR Uri, i.e. jar:file://.../some.jar!/, resolve does not work like expected
+ Location append(String relativeURI) {
+ relativeURI = encodeIllegalCharacters(relativeURI);
+ if (uri.toString().endsWith("/") && relativeURI.startsWith("/")) {
+ relativeURI = relativeURI.substring(1);
+ }
+ if (!uri.toString().endsWith("/") && !relativeURI.startsWith("/")) {
+ relativeURI = "/" + relativeURI;
+ }
+ return Location.of(URI.create(uri + relativeURI));
+ }
+
+ // NOTE: new URI(..) with more than one argument does URL encoding of illegal characters. URLEncoder on the other
+ // hand form-encodes all characters, even '/' which we do not want.
+ private String encodeIllegalCharacters(String relativeURI) {
+ try {
+ return new URI(null, null, relativeURI, null).toString();
+ } catch (URISyntaxException e) {
+ throw new LocationException(e);
+ }
+ }
+
+ void checkScheme(String scheme, NormalizedUri uri) {
+ String actualScheme = uri.getScheme();
+ checkArgument(scheme.equals(actualScheme),
+ "URI %s of Location must have scheme %s, but has %s", uri, scheme, actualScheme);
+ }
+
+ /**
+ * @return A Stream containing all class file names under this location, e.g. relative file names, Jar entry names,
+ * ...
+ */
+ final Stream streamEntries() {
+ try {
+ return ENTRY_CACHE.get(uri, this::readResourceEntries).stream();
+ } catch (ExecutionException e) {
+ throw new LocationException(e);
+ }
+ }
+
+ abstract Collection readResourceEntries();
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(uri);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null || getClass() != obj.getClass()) {
+ return false;
+ }
+ Location other = (Location) obj;
+ return Objects.equals(this.uri, other.uri);
+ }
+
+ @Override
+ public String toString() {
+ return "Location{uri=" + uri + '}';
+ }
+
+ @PublicAPI(usage = ACCESS)
+ public static Location of(URL url) {
+ return of(toURI(url));
+ }
+
+ @PublicAPI(usage = ACCESS)
+ public static Location of(URI uri) {
+ uri = JarFileLocation.ensureJarProtocol(uri);
+ for (Factory factory : factories.get()) {
+ if (factory.supports(uri.getScheme())) {
+ return factory.create(uri);
+ }
+ }
+ throw new UnsupportedUriSchemeException(uri);
+ }
+
+ @PublicAPI(usage = ACCESS)
+ public static Location of(JarFile jar) {
+ return JarFileLocation.from(jar);
+ }
+
+ @PublicAPI(usage = ACCESS)
+ public static Location of(Path path) {
+ return FilePathLocation.from(path.toUri());
+ }
+
+ static URI toURI(URL url) {
+ try {
+ return url.toURI();
+ } catch (URISyntaxException e) {
+ throw new LocationException(e);
+ }
+ }
+
+ interface Factory {
+ boolean supports(String scheme);
+
+ Location create(URI uri);
+ }
+
+ static class JarFileLocationFactory implements Factory {
+ @Override
+ public boolean supports(String scheme) {
+ return JarFileLocation.SCHEME.equals(scheme);
+ }
+
+ @Override
+ public Location create(URI uri) {
+ return JarFileLocation.from(uri);
+ }
+ }
+
+ static class FilePathLocationFactory implements Factory {
+ @Override
+ public boolean supports(String scheme) {
+ return FilePathLocation.SCHEME.equals(scheme);
+ }
+
+ @Override
+ public Location create(URI uri) {
+ return FilePathLocation.from(uri);
+ }
+ }
+
+ private static class JarFileLocation extends Location {
+ private static final String SCHEME = "jar";
+
+ private JarFileLocation(NormalizedUri uri) {
+ super(uri);
+ checkScheme(SCHEME, uri);
+ }
+
+ static URI ensureJarProtocol(URI uri) {
+ return !SCHEME.equals(uri.getScheme()) && uri.getPath().endsWith(".jar") ? newJarUri(uri) : uri;
+ }
+
+ static JarFileLocation from(URI uri) {
+ checkArgument(uri.toString().contains("!/"), "JAR URI must contain '!/'");
+ return new JarFileLocation(NormalizedUri.from(uri));
+ }
+
+ static JarFileLocation from(JarFile jar) {
+ return from(newJarUri(FilePathLocation.newFileUri(jar.getName())));
+ }
+
+ private static URI newJarUri(URI uri) {
+ return URI.create(String.format("%s:%s!/", SCHEME, uri));
+ }
+
+ @Override
+ ClassFileSource asClassFileSource(ImportOptions importOptions) {
+ try {
+ String uriString = uri.toString();
+ int index = uriString.lastIndexOf("!/");
+ String[] parts = { uriString.substring(0, index), uriString.substring(index + 2) };
+ return new ClassFileSource.FromJar(new URL(parts[0] + "!/"), parts[1], importOptions);
+ } catch (IOException e) {
+ throw new LocationException(e);
+ }
+ }
+
+ @Override
+ public boolean isJar() {
+ return true;
+ }
+
+ @Override
+ public boolean isArchive() {
+ return true;
+ }
+
+ @Override
+ Collection readResourceEntries() {
+ File file = getFileOfJar();
+ if (!file.exists()) {
+ return emptySet();
+ }
+
+ return readJarFileContent(file);
+ }
+
+ private File getFileOfJar() {
+ return new File(URI.create(uri.toString()
+ .replaceAll("^" + SCHEME + ":", "")
+ .replaceAll("!/.*", "")));
+ }
+
+ private Collection readJarFileContent(File fileOfJar) {
+ ImmutableList.Builder result = ImmutableList.builder();
+ String prefix = uri.toString().replaceAll(".*!/", "");
+ try (JarFile jarFile = new JarFile(fileOfJar)) {
+ result.addAll(readEntries(prefix, jarFile));
+ } catch (IOException e) {
+ throw new LocationException(e);
+ }
+ return result.build();
+ }
+
+ private List readEntries(String prefix, JarFile jarFile) {
+ List result = new ArrayList<>();
+ Enumeration entries = jarFile.entries();
+ while (entries.hasMoreElements()) {
+ JarEntry entry = entries.nextElement();
+ if (entry.getName().startsWith(prefix) && entry.getName().endsWith(".class")) {
+ result.add(NormalizedResourceName.from(entry.getName()));
+ }
+ }
+ return result;
+ }
+ }
+
+ private static class FilePathLocation extends Location {
+ private static final String SCHEME = "file";
+
+ private FilePathLocation(NormalizedUri uri) {
+ super(uri);
+ checkScheme(SCHEME, uri);
+ }
+
+ static URI newFileUri(String fileName) {
+ return new File(fileName).toURI();
+ }
+
+ static FilePathLocation from(URI uri) {
+ return new FilePathLocation(NormalizedUri.from(uri));
+ }
+
+ @Override
+ ClassFileSource asClassFileSource(ImportOptions importOptions) {
+ return new ClassFileSource.FromFilePath(Paths.get(uri.toURI()), importOptions);
+ }
+
+ @Override
+ public boolean isJar() {
+ return false;
+ }
+
+ @Override
+ public boolean isArchive() {
+ return false;
+ }
+
+ @Override
+ Collection readResourceEntries() {
+ try {
+ return getAllFilesBeneath(uri);
+ } catch (IOException e) {
+ throw new LocationException(e);
+ }
+ }
+
+ private List getAllFilesBeneath(NormalizedUri uri) throws IOException {
+ File rootFile = new File(uri.toURI());
+ if (!rootFile.exists()) {
+ return emptyList();
+ }
+
+ return getAllFilesBeneath(rootFile.toPath());
+ }
+
+ private List getAllFilesBeneath(Path root) throws IOException {
+ ImmutableList.Builder result = ImmutableList.builder();
+ Files.walkFileTree(root, new SimpleFileVisitor() {
+ @Override
+ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
+ if (file.toString().endsWith(".class")) {
+ result.add(NormalizedResourceName.from(root.relativize(file).toString()));
+ }
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ return result.build();
+ }
+ }
+}