diff --git a/pom.xml b/pom.xml
index 8bf8ca0..b73b170 100644
--- a/pom.xml
+++ b/pom.xml
@@ -69,6 +69,19 @@
junit-jupiter
test
+
+ com.tngtech.archunit
+ archunit
+ 1.4.2
+ test
+
+
+
+ org.slf4j
+ slf4j-simple
+ 2.0.18
+ test
+
diff --git a/src/test/java/module-info.java b/src/test/java/module-info.java
index a587a77..f2d4976 100644
--- a/src/test/java/module-info.java
+++ b/src/test/java/module-info.java
@@ -29,4 +29,5 @@
requires org.eclipse.collections.impl;
requires org.junit.jupiter.api;
requires org.junit.jupiter.params;
+ requires com.tngtech.archunit;
}
diff --git a/src/test/java/org/assertj/eclipse/collections/api/arch/PublicApiExposedTypesTest.java b/src/test/java/org/assertj/eclipse/collections/api/arch/PublicApiExposedTypesTest.java
new file mode 100644
index 0000000..4cb04ed
--- /dev/null
+++ b/src/test/java/org/assertj/eclipse/collections/api/arch/PublicApiExposedTypesTest.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * 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
+ *
+ * https://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.assertj.eclipse.collections.api.arch;
+
+import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
+import static org.assertj.eclipse.collections.test.ExposedTypesInPublicApiContractArchCondition.notExposeTypesInPackage;
+
+import org.junit.jupiter.api.Test;
+
+import com.tngtech.archunit.core.domain.JavaClasses;
+import com.tngtech.archunit.core.importer.ClassFileImporter;
+import com.tngtech.archunit.lang.ArchRule;
+
+public class PublicApiExposedTypesTest {
+ @Test
+ void publicApiDoesNotExposeEclipseCollectionsImplTypes() {
+ JavaClasses importedClasses = new ClassFileImporter().importPackages("org.assertj.eclipse.collections");
+ ArchRule rule = classes().that()
+ .resideInAnyPackage(
+ "org.assertj.eclipse.collections.api..",
+ "org.assertj.eclipse.collections.error..",
+ "org.assertj.eclipse.collections.util.."
+ )
+ .should(notExposeTypesInPackage("org.eclipse.collections.impl.")
+ .as("not expose types contained in package org.eclipse.collections.impl"));
+
+ rule.check(importedClasses);
+ }
+}
diff --git a/src/test/java/org/assertj/eclipse/collections/test/ExposedTypesInPublicApiContractArchCondition.java b/src/test/java/org/assertj/eclipse/collections/test/ExposedTypesInPublicApiContractArchCondition.java
new file mode 100644
index 0000000..8dea791
--- /dev/null
+++ b/src/test/java/org/assertj/eclipse/collections/test/ExposedTypesInPublicApiContractArchCondition.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * 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
+ *
+ * https://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.assertj.eclipse.collections.test;
+
+import com.tngtech.archunit.core.domain.JavaClass;
+import com.tngtech.archunit.core.domain.JavaCodeUnit;
+import com.tngtech.archunit.core.domain.JavaModifier;
+import com.tngtech.archunit.core.domain.JavaParameterizedType;
+import com.tngtech.archunit.core.domain.JavaType;
+import com.tngtech.archunit.core.domain.properties.HasModifiers;
+import com.tngtech.archunit.lang.ArchCondition;
+import com.tngtech.archunit.lang.ConditionEvents;
+import com.tngtech.archunit.lang.SimpleConditionEvent;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * An ArchUnit condition that checks if the given package's types are exposed in the public API contract. The public
+ * contract refers to the following:
+ *
+ *
+ *
+ * - Types used as a parent class
+ * - Types used as an interface
+ * - Types used as a public or protected field type
+ * - Types used as a constructor argument
+ * - Types used as a method argument or return type
+ *
+ *
+ *
+ * These checks include parameterized types/generics.
+ */
+public final class ExposedTypesInPublicApiContractArchCondition extends ArchCondition {
+ private final String packageName;
+
+ /**
+ * Entry point for using this condition.
+ *
+ * @param packageName The package name to check for exposed types.
+ * @return An instance of ExposedTypesInPublicApiContractArchCondition.
+ */
+ public static ExposedTypesInPublicApiContractArchCondition notExposeTypesInPackage(String packageName) {
+ return new ExposedTypesInPublicApiContractArchCondition(packageName, "not expose types contained in package %s", packageName);
+ }
+
+ private ExposedTypesInPublicApiContractArchCondition(String packageName, String description, Object... args) {
+ super(description, args);
+ this.packageName = packageName;
+ }
+
+ @Override
+ public void check(JavaClass item, ConditionEvents events) {
+ checkParentClass(item, events, packageName);
+ checkInterfaces(item, events, packageName);
+ checkFields(item, events, packageName);
+ checkConstructors(item, events, packageName);
+ checkMethods(item, events, packageName);
+ }
+
+ private static void checkParentClass(JavaClass javaClass, ConditionEvents events, String packageName) {
+ javaClass.getRawSuperclass()
+ .ifPresent(parentClass -> checkType(javaClass, parentClass, events, packageName, "parent class"));
+ }
+
+ private static void checkInterfaces(JavaClass javaClass, ConditionEvents events, String packageName) {
+ javaClass.getRawInterfaces().stream()
+ .filter(ExposedTypesInPublicApiContractArchCondition::isPublicOrProtected)
+ .forEach(interFaceType -> checkType(javaClass, interFaceType, events, packageName, "interface"));
+ }
+
+ private static void checkFields(JavaClass javaClass, ConditionEvents events, String packageName) {
+ javaClass.getFields().stream()
+ .filter(ExposedTypesInPublicApiContractArchCondition::isPublicOrProtected)
+ .forEach(field -> checkType(javaClass, field.getType(), events, packageName, "field " + field.getFullName()));
+ }
+
+ private static void checkConstructors(JavaClass javaClass, ConditionEvents events, String packageName) {
+ javaClass.getConstructors().stream()
+ .filter(ExposedTypesInPublicApiContractArchCondition::isPublicOrProtected)
+ .forEach(constructor -> checkParameterTypes(javaClass, constructor, events, packageName));
+ }
+
+ private static void checkMethods(JavaClass javaClass, ConditionEvents events, String packageName) {
+ javaClass.getMethods().stream()
+ .filter(ExposedTypesInPublicApiContractArchCondition::isPublicOrProtected)
+ .forEach(method -> {
+ checkType(javaClass, method.getReturnType(), events, packageName, "return type of " + method.getFullName());
+ checkParameterTypes(javaClass, method, events, packageName);
+ });
+ }
+
+ private static void checkParameterTypes(JavaClass owner, JavaCodeUnit codeUnit, ConditionEvents events, String packageName) {
+ codeUnit.getParameterTypes()
+ .forEach(parameterType -> checkType(owner, parameterType, events, packageName, "parameter of " + codeUnit.getFullName()));
+ }
+
+ private static void checkType(JavaClass owner, JavaType type, ConditionEvents events, String packageName, String usageDescription) {
+ if (isTypeContainedInPackage(type.toErasure(), packageName)) {
+ events.add(SimpleConditionEvent.violated(owner, "%s exposes %s in %s".formatted(owner.getFullName(), type.getName(), usageDescription)));
+ }
+ recursiveCheckType(owner, type, events, packageName, usageDescription, new HashSet<>());
+ }
+
+ /**
+ * Don't call this method directly. Use {@link #checkType(JavaClass, JavaType, ConditionEvents, String, String)}
+ * instead.
+ */
+ private static void recursiveCheckType(JavaClass owner, JavaType type, ConditionEvents events,
+ String packageName, String usageDescription, Set visitedTypes) {
+ if (!visitedTypes.add(type.getName())) {
+ return;
+ }
+
+ if (isTypeContainedInPackage(type.toErasure(), packageName)) {
+ events.add(SimpleConditionEvent.violated(owner,
+ "%s exposes %s in %s".formatted(owner.getFullName(), type.getName(), usageDescription)));
+ }
+
+ if (type instanceof JavaParameterizedType parameterizedType) {
+ parameterizedType.getActualTypeArguments()
+ .forEach(typeArgument -> recursiveCheckType(owner, typeArgument, events, packageName, usageDescription, visitedTypes));
+ }
+ }
+
+ public static boolean isPublicOrProtected(HasModifiers member) {
+ return member.getModifiers().contains(JavaModifier.PUBLIC) || member.getModifiers().contains(JavaModifier.PROTECTED);
+ }
+
+ private static boolean isTypeContainedInPackage(JavaClass type, String packageName) {
+ return type.getFullName().startsWith(packageName);
+ }
+}
diff --git a/src/test/java/org/assertj/eclipse/collections/test/ExposedTypesInPublicApiContractArchConditionTest.java b/src/test/java/org/assertj/eclipse/collections/test/ExposedTypesInPublicApiContractArchConditionTest.java
new file mode 100644
index 0000000..4cf9e81
--- /dev/null
+++ b/src/test/java/org/assertj/eclipse/collections/test/ExposedTypesInPublicApiContractArchConditionTest.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ *
+ * 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
+ *
+ * https://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.assertj.eclipse.collections.test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.eclipse.collections.test.ExposedTypesInPublicApiContractArchCondition.notExposeTypesInPackage;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.Test;
+
+import com.tngtech.archunit.core.domain.JavaClass;
+import com.tngtech.archunit.core.importer.ClassFileImporter;
+import com.tngtech.archunit.lang.ConditionEvents;
+
+class ExposedTypesInPublicApiContractArchConditionTest {
+
+ private static final String FORBIDDEN_PACKAGE = "java.util.concurrent.";
+
+ @Test
+ void cleanClassProducesNoViolations() {
+ assertNoViolations(ValidClass.class);
+ }
+
+ @Test
+ void publicFieldWithForbiddenTypeIsFlagged() {
+ assertHasViolationContaining(ExposesForbiddenPublicField.class, "AtomicInteger");
+ }
+
+ @Test
+ void protectedFieldWithForbiddenTypeIsFlagged() {
+ assertHasViolationContaining(ExposesForbiddenProtectedField.class, "AtomicInteger");
+ }
+
+ @Test
+ void privateFieldWithForbiddenTypeIsNotFlagged() {
+ assertNoViolations(ValidPrivateField.class);
+ }
+
+ @Test
+ void publicMethodReturnTypeWithForbiddenTypeIsFlagged() {
+ assertHasViolationContaining(ExposesForbiddenReturnType.class, "AtomicInteger");
+ }
+
+ @Test
+ void publicMethodParameterWithForbiddenTypeIsFlagged() {
+ assertHasViolationContaining(ExposesForbiddenMethodParameter.class, "AtomicInteger");
+ }
+
+ @Test
+ void publicConstructorParameterWithForbiddenTypeIsFlagged() {
+ assertHasViolationContaining(ExposesForbiddenConstructorParameter.class, "AtomicInteger");
+ }
+
+ @Test
+ void forbiddenParentClassIsFlagged() {
+ assertHasViolationContaining(ExposesForbiddenParentClass.class, "AtomicReference");
+ }
+
+ @Test
+ void forbiddenInterfaceIsFlagged() {
+ assertHasViolationContaining(ExposesForbiddenInterface.class, "Callable");
+ }
+
+ @Test
+ void forbiddenGenericTypeArgumentIsFlagged() {
+ assertHasViolationContaining(ExposesForbiddenGenericArgumentInField.class, "AtomicInteger");
+ }
+
+ @Test
+ void deeplyNestedForbiddenGenericTypeArgumentIsFlagged() {
+ assertHasViolationContaining(ExposesDeeplyNestedGenericArgument.class, "AtomicInteger");
+ }
+
+ private static List violationsFor(Class> fixture) {
+ JavaClass javaClass = new ClassFileImporter().importClass(fixture);
+ ConditionEvents events = ConditionEvents.Factory.create();
+ notExposeTypesInPackage(FORBIDDEN_PACKAGE).check(javaClass, events);
+ return events.getViolating().stream()
+ .flatMap(event -> event.getDescriptionLines().stream())
+ .toList();
+ }
+
+ private static void assertNoViolations(Class> fixture) {
+ assertThat(violationsFor(fixture)).isEmpty();
+ }
+
+ private static void assertHasViolationContaining(Class> fixture, String expectedSnippet) {
+ assertThat(violationsFor(fixture)).anyMatch(line -> line.contains(expectedSnippet));
+ }
+
+ @SuppressWarnings("unused")
+ static class ValidClass {
+ public String validField;
+
+ public String validMethod(int input) {
+ return String.valueOf(input);
+ }
+ }
+
+ static class ExposesForbiddenPublicField {
+ @SuppressWarnings("unused")
+ public AtomicInteger counter;
+ }
+
+ static class ExposesForbiddenProtectedField {
+ @SuppressWarnings("unused")
+ protected AtomicInteger counter;
+ }
+
+ static class ValidPrivateField {
+ @SuppressWarnings("unused")
+ private AtomicInteger counter;
+
+ public int value() {
+ return counter == null ? 0 : counter.get();
+ }
+ }
+
+ static class ExposesForbiddenReturnType {
+ @SuppressWarnings("unused")
+ public AtomicInteger create() {
+ return new AtomicInteger();
+ }
+ }
+
+ static class ExposesForbiddenMethodParameter {
+ @SuppressWarnings("unused")
+ public void consume(AtomicInteger value) {
+ // Do nothing
+ }
+ }
+
+ static class ExposesForbiddenConstructorParameter {
+ @SuppressWarnings("unused")
+ public ExposesForbiddenConstructorParameter(AtomicInteger value) {
+ // Do nothing
+ }
+ }
+
+ static class ExposesForbiddenParentClass extends AtomicReference {}
+
+ abstract static class ExposesForbiddenInterface implements Callable {}
+
+ static class ExposesForbiddenGenericArgumentInField {
+ @SuppressWarnings("unused")
+ public List items;
+ }
+
+ static class ExposesDeeplyNestedGenericArgument {
+ @SuppressWarnings("unused")
+ public Map>> deeplyNested;
+ }
+}