You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@bval.apache.org by ju...@apache.org on 2026/08/05 11:30:20 UTC

(bval) branch bval-4.x updated: BVAL-228 - Support discovering ConstraintValidator via service loading

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

jungm pushed a commit to branch bval-4.x
in repository https://gitbox.apache.org/repos/asf/bval.git


The following commit(s) were added to refs/heads/bval-4.x by this push:
     new 141924e  BVAL-228 - Support discovering ConstraintValidator via service loading
141924e is described below

commit 141924e48e8e1b82f1efda4d36076c06864dea2a
Author: Markus Jung <ju...@apache.org>
AuthorDate: Wed Aug 5 13:30:04 2026 +0200

    BVAL-228 - Support discovering ConstraintValidator via service loading
---
 .../apache/bval/jsr/ApacheValidatorFactory.java    |   5 +
 .../java/org/apache/bval/jsr/ConstraintCached.java |  27 ++-
 .../org/apache/bval/jsr/ParticipantFactory.java    |  37 +++-
 .../ServiceLoaderValidatorMappingProvider.java     | 105 ++++++++++
 .../jsr/ServiceLoadedConstraintValidatorTest.java  | 213 +++++++++++++++++++++
 .../jsr/serviceloader/CollidingConstraint.java     |  58 ++++++
 .../serviceloader/CollidingServiceValidator.java   |  30 +++
 .../jsr/serviceloader/ServiceLoadedConstraint.java |  48 +++++
 .../ServiceLoadedConstraintValidator.java          |  30 +++
 .../serviceloader/ServiceLoaderBootstrapTest.java  |  87 +++++++++
 .../jakarta.validation.ConstraintValidator         |  18 ++
 11 files changed, 654 insertions(+), 4 deletions(-)

diff --git a/bval-jsr/src/main/java/org/apache/bval/jsr/ApacheValidatorFactory.java b/bval-jsr/src/main/java/org/apache/bval/jsr/ApacheValidatorFactory.java
index 8fa5785..2983191 100644
--- a/bval-jsr/src/main/java/org/apache/bval/jsr/ApacheValidatorFactory.java
+++ b/bval-jsr/src/main/java/org/apache/bval/jsr/ApacheValidatorFactory.java
@@ -28,6 +28,7 @@ import java.util.concurrent.ConcurrentHashMap;
 import java.util.function.BiConsumer;
 
 import jakarta.validation.ClockProvider;
+import jakarta.validation.ConstraintValidator;
 import jakarta.validation.ConstraintValidatorFactory;
 import jakarta.validation.MessageInterpolator;
 import jakarta.validation.ParameterNameProvider;
@@ -45,6 +46,7 @@ import org.apache.bval.jsr.metadata.MetadataBuilder;
 import org.apache.bval.jsr.metadata.MetadataBuilder.ForBean;
 import org.apache.bval.jsr.metadata.MetadataBuilders;
 import org.apache.bval.jsr.metadata.MetadataSource;
+import org.apache.bval.jsr.metadata.ServiceLoaderValidatorMappingProvider;
 import org.apache.bval.jsr.util.AnnotationsManager;
 import org.apache.bval.jsr.valueextraction.ValueExtractors;
 import org.apache.bval.jsr.valueextraction.ValueExtractors.OnDuplicateContainerElementKey;
@@ -138,6 +140,9 @@ public class ApacheValidatorFactory implements ValidatorFactory, Cloneable {
 
         toClose.add(participantFactory);
 
+        constraintsCache.setServiceLoaderValidatorMappingProvider(new ServiceLoaderValidatorMappingProvider(
+            participantFactory.loadServiceClasses(ConstraintValidator.class)));
+
         valueExtractors = createBaseValueExtractors(participantFactory).createChild();
         configuration.getValueExtractors().forEach(valueExtractors::add);
 
diff --git a/bval-jsr/src/main/java/org/apache/bval/jsr/ConstraintCached.java b/bval-jsr/src/main/java/org/apache/bval/jsr/ConstraintCached.java
index 2dc1279..555e97f 100644
--- a/bval-jsr/src/main/java/org/apache/bval/jsr/ConstraintCached.java
+++ b/bval-jsr/src/main/java/org/apache/bval/jsr/ConstraintCached.java
@@ -106,13 +106,32 @@ public class ConstraintCached {
     private final Lazy<ValidatorMappingProvider> validatorMappingProvider =
         new Lazy<>(this::createValidatorMappingProvider);
 
+    private ValidatorMappingProvider serviceLoaderValidatorMappingProvider;
+
     public ConcurrentMap<ConstraintD<?>, ConstraintValidator<?, ?>> getValidators() {
         return validators;
     }
 
     public void add(ValidatorMappingProvider validatorMappingProvider) {
         customValidatorMappingProviders.add(validatorMappingProvider);
-        this.validatorMappingProvider.reset(this::createValidatorMappingProvider);
+        resetValidatorMappingProvider();
+    }
+
+    /**
+     * Set the provider of the {@link ConstraintValidator} implementations registered via the service loader
+     * mechanism. These rank alongside the built-in validators: they are merged with, rather than overridden by,
+     * {@code @Constraint.validatedBy()} and XML {@code <constraint-definition>} registrations that include existing
+     * validators.
+     */
+    public void setServiceLoaderValidatorMappingProvider(ValidatorMappingProvider serviceLoaderValidatorMappingProvider) {
+        this.serviceLoaderValidatorMappingProvider = serviceLoaderValidatorMappingProvider;
+        resetValidatorMappingProvider();
+    }
+
+    private void resetValidatorMappingProvider() {
+        validatorMappingProvider.reset(this::createValidatorMappingProvider);
+        // memoized per-constraint results were computed from the previous composition:
+        constraintValidatorInfo.clear();
     }
 
     public <A extends Annotation> List<Class<? extends ConstraintValidator<A, ?>>> getConstraintValidatorClasses(
@@ -146,6 +165,10 @@ public class ConstraintCached {
             }
             configured = new DualValidationMappingProvider(AnnotationDeclaredValidatorMappingProvider.INSTANCE, custom);
         }
-        return new DualValidationMappingProvider(ConstraintDefaults.INSTANCE, configured);
+        final ValidatorMappingProvider preconfigured = serviceLoaderValidatorMappingProvider == null
+            ? ConstraintDefaults.INSTANCE
+            : new DualValidationMappingProvider(ConstraintDefaults.INSTANCE, serviceLoaderValidatorMappingProvider);
+
+        return new DualValidationMappingProvider(preconfigured, configured);
     }
 }
diff --git a/bval-jsr/src/main/java/org/apache/bval/jsr/ParticipantFactory.java b/bval-jsr/src/main/java/org/apache/bval/jsr/ParticipantFactory.java
index 1cbdb3d..ae0b8a3 100644
--- a/bval-jsr/src/main/java/org/apache/bval/jsr/ParticipantFactory.java
+++ b/bval-jsr/src/main/java/org/apache/bval/jsr/ParticipantFactory.java
@@ -78,6 +78,29 @@ class ParticipantFactory implements Closeable {
     }
 
     <T> Set<T> loadServices(Class<T> type) { // todo: enable somehow to cache shared classloader (think tomcat/tomee)
+        return serviceEntries(type).stream().<T> map(this::create).collect(ToUnmodifiable.set());
+    }
+
+    /**
+     * Read the service entries declared for {@code type} without instantiating them. Used where the declared classes
+     * themselves are the participants, e.g. {@code ConstraintValidator} implementations, whose instantiation is the
+     * business of the configured {@code ConstraintValidatorFactory}.
+     */
+    <T> Set<Class<? extends T>> loadServiceClasses(Class<T> type) {
+        return serviceEntries(type).stream().map(classname -> asSubclass(loadClass(classname), type))
+            .collect(ToUnmodifiable.set());
+    }
+
+    private static <T> Class<? extends T> asSubclass(Class<?> loaded, Class<T> type) {
+        try {
+            return loaded.asSubclass(type);
+        } catch (ClassCastException e) {
+            throw new ValidationException(String.format("%s declared in %s%s does not implement it", loaded.getName(),
+                META_INF_SERVICES, type.getName()), e);
+        }
+    }
+
+    private Set<String> serviceEntries(Class<?> type) {
         Validate.notNull(type);
         final Set<URL> resources = new LinkedHashSet<>();
         final String resourceName = META_INF_SERVICES + type.getName();
@@ -90,19 +113,29 @@ class ParticipantFactory implements Closeable {
                 log.log(Level.SEVERE, "Error searching for resource(s) " + resourceName, e);
             }
         }
-        return resources.stream().distinct().map(this::read).flatMap(Collection::stream).<T> map(this::create)
+        return resources.stream().distinct().map(this::read).flatMap(Collection::stream)
             .collect(ToUnmodifiable.set());
     }
 
     private Set<String> read(URL url) {
         try (BufferedReader r = new BufferedReader(new InputStreamReader(url.openStream()))) {
-            return r.lines().map(String::trim).filter(line -> line.charAt(0) != '#').collect(Collectors.toSet());
+            return r.lines().map(ParticipantFactory::stripComment).filter(line -> !line.isEmpty())
+                .collect(Collectors.toSet());
         } catch (IOException e) {
             log.log(Level.SEVERE, "Unable to read resource " + url, e);
             return Collections.emptySet();
         }
     }
 
+    /**
+     * Per the service provider configuration file format, a {@code '#'} starts a comment running to the end of the
+     * line, whether or not anything precedes it.
+     */
+    private static String stripComment(String line) {
+        final int comment = line.indexOf('#');
+        return (comment < 0 ? line : line.substring(0, comment)).trim();
+    }
+
     @SuppressWarnings("unchecked")
     private <T> Class<T> loadClass(final String className) {
         for (ClassLoader loader : loaders) {
diff --git a/bval-jsr/src/main/java/org/apache/bval/jsr/metadata/ServiceLoaderValidatorMappingProvider.java b/bval-jsr/src/main/java/org/apache/bval/jsr/metadata/ServiceLoaderValidatorMappingProvider.java
new file mode 100644
index 0000000..aeb0c8e
--- /dev/null
+++ b/bval-jsr/src/main/java/org/apache/bval/jsr/metadata/ServiceLoaderValidatorMappingProvider.java
@@ -0,0 +1,105 @@
+/*
+ *  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.bval.jsr.metadata;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.Type;
+import java.lang.reflect.TypeVariable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import jakarta.validation.ConstraintValidator;
+
+import org.apache.bval.util.Validate;
+import org.apache.bval.util.reflection.TypeUtils;
+
+/**
+ * Provides the {@link ConstraintValidator} implementations registered via the service loader mechanism, i.e. named in
+ * {@code META-INF/services/jakarta.validation.ConstraintValidator} resources. Discovered types are indexed by the
+ * constraint annotation they validate, which is the first type argument of their {@link ConstraintValidator}
+ * implementation.
+ *
+ * @since 4.0
+ */
+public class ServiceLoaderValidatorMappingProvider extends ValidatorMappingProvider {
+    public static final String SOURCE = "META-INF/services/" + ConstraintValidator.class.getName();
+
+    private static final Logger log = Logger.getLogger(ServiceLoaderValidatorMappingProvider.class.getName());
+
+    private final Map<Class<? extends Annotation>, List<Class<? extends ConstraintValidator<?, ?>>>> validatorTypes;
+
+    public ServiceLoaderValidatorMappingProvider(Collection<? extends Class<?>> serviceLoadedTypes) {
+        super();
+        Validate.notNull(serviceLoadedTypes, "serviceLoadedTypes");
+
+        final Map<Class<? extends Annotation>, List<Class<? extends ConstraintValidator<?, ?>>>> index = new HashMap<>();
+        for (Class<?> type : serviceLoadedTypes) {
+            if (Modifier.isAbstract(type.getModifiers())) {
+                ignore(type, "it is not instantiable");
+                continue;
+            }
+            final Class<? extends Annotation> constraintType = constraintTypeOf(type);
+            if (constraintType == null) {
+                continue;
+            }
+            @SuppressWarnings("unchecked")
+            final Class<? extends ConstraintValidator<?, ?>> validatorType =
+                (Class<? extends ConstraintValidator<?, ?>>) type.asSubclass(ConstraintValidator.class);
+
+            index.computeIfAbsent(constraintType, k -> new ArrayList<>()).add(validatorType);
+        }
+        index.replaceAll((k, v) -> Collections.unmodifiableList(v));
+        this.validatorTypes = Collections.unmodifiableMap(index);
+    }
+
+    @Override
+    protected <A extends Annotation> ValidatorMapping<A> doGetValidatorMapping(Class<A> constraintType) {
+        @SuppressWarnings({ "unchecked", "rawtypes" })
+        final List<Class<? extends ConstraintValidator<A, ?>>> types = (List) validatorTypes.get(constraintType);
+
+        return types == null ? null : new ValidatorMapping<>(SOURCE, types);
+    }
+
+    private static Class<? extends Annotation> constraintTypeOf(Class<?> validatorType) {
+        final Map<TypeVariable<?>, Type> typeArguments =
+            TypeUtils.getTypeArguments(validatorType, ConstraintValidator.class);
+
+        final Type constraintParameter =
+            typeArguments == null ? null : typeArguments.get(ConstraintValidator.class.getTypeParameters()[0]);
+
+        if (constraintParameter instanceof Class<?>
+            && Annotation.class.isAssignableFrom((Class<?>) constraintParameter)) {
+            return ((Class<?>) constraintParameter).asSubclass(Annotation.class);
+        }
+        ignore(validatorType, "the constraint annotation type it validates cannot be determined");
+        return null;
+    }
+
+    private static void ignore(Class<?> validatorType, String reason) {
+        if (log.isLoggable(Level.WARNING)) {
+            log.log(Level.WARNING, String.format("Ignoring %s declared in %s: %s", validatorType.getName(), SOURCE,
+                reason));
+        }
+    }
+}
diff --git a/bval-jsr/src/test/java/org/apache/bval/jsr/ServiceLoadedConstraintValidatorTest.java b/bval-jsr/src/test/java/org/apache/bval/jsr/ServiceLoadedConstraintValidatorTest.java
new file mode 100644
index 0000000..39a72a3
--- /dev/null
+++ b/bval-jsr/src/test/java/org/apache/bval/jsr/ServiceLoadedConstraintValidatorTest.java
@@ -0,0 +1,213 @@
+/*
+ * 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.bval.jsr;
+
+import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
+import static java.lang.annotation.ElementType.CONSTRUCTOR;
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.lang.annotation.Annotation;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import jakarta.validation.Payload;
+import jakarta.validation.constraints.Size;
+
+import org.apache.bval.constraints.SizeValidator;
+import org.apache.bval.jsr.metadata.AnnotationBehavior;
+import org.apache.bval.jsr.metadata.ServiceLoaderValidatorMappingProvider;
+import org.apache.bval.jsr.metadata.ValidatorMapping;
+import org.apache.bval.jsr.metadata.ValidatorMappingProvider;
+import org.junit.Test;
+
+/**
+ * Covers the 4.0 service loader registration source: {@link ServiceLoaderValidatorMappingProvider} and the way
+ * {@link ConstraintCached} composes it with the built-in, annotation-declared and XML-declared sources.
+ */
+public class ServiceLoadedConstraintValidatorTest {
+
+    @Target({ TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, ANNOTATION_TYPE })
+    @Retention(RUNTIME)
+    @Constraint(validatedBy = {})
+    public @interface ServiceLoaded {
+        String message() default "";
+
+        Class<?>[] groups() default {};
+
+        Class<? extends Payload>[] payload() default {};
+    }
+
+    @Target({ TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, ANNOTATION_TYPE })
+    @Retention(RUNTIME)
+    @Constraint(validatedBy = { AnnotationDeclaredValidator.class })
+    public @interface AnnotationDeclared {
+        String message() default "";
+
+        Class<?>[] groups() default {};
+
+        Class<? extends Payload>[] payload() default {};
+    }
+
+    public static class ServiceLoadedValidator implements ConstraintValidator<ServiceLoaded, Object> {
+        @Override
+        public boolean isValid(Object value, ConstraintValidatorContext context) {
+            return true;
+        }
+    }
+
+    public static class AnnotationDeclaredValidator implements ConstraintValidator<AnnotationDeclared, Object> {
+        @Override
+        public boolean isValid(Object value, ConstraintValidatorContext context) {
+            return true;
+        }
+    }
+
+    public static class ServiceLoadedAnnotationDeclaredValidator
+        implements ConstraintValidator<AnnotationDeclared, String> {
+        @Override
+        public boolean isValid(String value, ConstraintValidatorContext context) {
+            return true;
+        }
+    }
+
+    public static class SizeValidatorForBook implements ConstraintValidator<Size, Book> {
+        @Override
+        public boolean isValid(Book value, ConstraintValidatorContext context) {
+            return true;
+        }
+    }
+
+    public static class Book {
+    }
+
+    /** Not a {@link ConstraintValidator} of any determinable constraint type. */
+    @SuppressWarnings("rawtypes")
+    public static class RawValidator implements ConstraintValidator {
+        @Override
+        public boolean isValid(Object value, ConstraintValidatorContext context) {
+            return true;
+        }
+    }
+
+    public static abstract class AbstractServiceLoadedValidator implements ConstraintValidator<ServiceLoaded, Object> {
+    }
+
+    private static ServiceLoaderValidatorMappingProvider provider(Class<?>... types) {
+        return new ServiceLoaderValidatorMappingProvider(Arrays.asList(types));
+    }
+
+    private static ConstraintCached cache(ServiceLoaderValidatorMappingProvider serviceLoader) {
+        final ConstraintCached result = new ConstraintCached();
+        result.setServiceLoaderValidatorMappingProvider(serviceLoader);
+        return result;
+    }
+
+    @Test
+    public void testIndexedByConstraintType() {
+        final ServiceLoaderValidatorMappingProvider provider =
+            provider(ServiceLoadedValidator.class, SizeValidatorForBook.class);
+
+        assertEquals(Collections.singletonList(ServiceLoadedValidator.class),
+            provider.getValidatorMapping(ServiceLoaded.class).getValidatorTypes());
+
+        assertEquals(Collections.singletonList(SizeValidatorForBook.class),
+            provider.getValidatorMapping(Size.class).getValidatorTypes());
+    }
+
+    @Test
+    public void testUnknownConstraintType() {
+        assertNull(provider(ServiceLoadedValidator.class).getValidatorMapping(AnnotationDeclared.class));
+    }
+
+    @Test
+    public void testUndeterminableConstraintTypeIgnored() {
+        assertNull(provider(RawValidator.class).getValidatorMapping(ServiceLoaded.class));
+    }
+
+    @Test
+    public void testAbstractValidatorIgnored() {
+        assertNull(provider(AbstractServiceLoadedValidator.class).getValidatorMapping(ServiceLoaded.class));
+    }
+
+    @Test
+    public void testServiceLoadedValidatorForConstraintWithoutValidatedBy() {
+        assertEquals(Collections.singletonList(ServiceLoadedValidator.class),
+            cache(provider(ServiceLoadedValidator.class)).getConstraintValidatorClasses(ServiceLoaded.class));
+    }
+
+    @Test
+    public void testServiceLoadedValidatorMergedWithValidatedBy() {
+        final List<Class<? extends ConstraintValidator<AnnotationDeclared, ?>>> validators =
+            cache(provider(ServiceLoadedAnnotationDeclaredValidator.class))
+                .getConstraintValidatorClasses(AnnotationDeclared.class);
+
+        assertEquals(2, validators.size());
+        assertTrue(validators.containsAll(
+            Arrays.asList(AnnotationDeclaredValidator.class, ServiceLoadedAnnotationDeclaredValidator.class)));
+    }
+
+    @Test
+    public void testServiceLoadedValidatorMergedWithBuiltIn() {
+        final List<Class<? extends ConstraintValidator<Size, ?>>> validators =
+            cache(provider(SizeValidatorForBook.class)).getConstraintValidatorClasses(Size.class);
+
+        assertTrue(validators.contains(SizeValidatorForBook.class));
+        assertTrue(validators.contains(SizeValidator.ForCharSequence.class));
+    }
+
+    @Test
+    public void testXmlOverrideExcludesServiceLoadedValidator() {
+        final ConstraintCached cache = cache(provider(SizeValidatorForBook.class));
+        cache.add(excluding(Size.class, SizeValidator.ForCharSequence.class));
+
+        assertEquals(Collections.singletonList(SizeValidator.ForCharSequence.class),
+            cache.getConstraintValidatorClasses(Size.class));
+    }
+
+    /**
+     * Stands in for an XML {@code <validated-by include-existing-validators="false">} registration.
+     */
+    private static ValidatorMappingProvider excluding(Class<? extends Annotation> constraintType,
+        Class<? extends ConstraintValidator<?, ?>> validatorType) {
+        return new ValidatorMappingProvider() {
+
+            @SuppressWarnings({ "unchecked", "rawtypes" })
+            @Override
+            protected <A extends Annotation> ValidatorMapping<A> doGetValidatorMapping(Class<A> t) {
+                return t.equals(constraintType)
+                    ? new ValidatorMapping<>("test", (List) Collections.singletonList(validatorType),
+                        AnnotationBehavior.EXCLUDE)
+                    : null;
+            }
+        };
+    }
+}
diff --git a/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/CollidingConstraint.java b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/CollidingConstraint.java
new file mode 100644
index 0000000..8a1a312
--- /dev/null
+++ b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/CollidingConstraint.java
@@ -0,0 +1,58 @@
+/*
+ * 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.bval.jsr.serviceloader;
+
+import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
+import static java.lang.annotation.ElementType.CONSTRUCTOR;
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import jakarta.validation.Payload;
+
+/**
+ * Declares a validator for {@link String} while {@link CollidingServiceValidator}, registered via
+ * {@code META-INF/services/jakarta.validation.ConstraintValidator}, declares another for the same target type.
+ */
+@Target({ TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, ANNOTATION_TYPE })
+@Retention(RUNTIME)
+@Constraint(validatedBy = { CollidingConstraint.Validator.class })
+public @interface CollidingConstraint {
+    String message() default "colliding";
+
+    Class<?>[] groups() default {};
+
+    Class<? extends Payload>[] payload() default {};
+
+    class Validator implements ConstraintValidator<CollidingConstraint, String> {
+
+        @Override
+        public boolean isValid(String value, ConstraintValidatorContext context) {
+            return true;
+        }
+    }
+}
diff --git a/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/CollidingServiceValidator.java b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/CollidingServiceValidator.java
new file mode 100644
index 0000000..22bb2dd
--- /dev/null
+++ b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/CollidingServiceValidator.java
@@ -0,0 +1,30 @@
+/*
+ * 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.bval.jsr.serviceloader;
+
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+
+public class CollidingServiceValidator implements ConstraintValidator<CollidingConstraint, String> {
+
+    @Override
+    public boolean isValid(String value, ConstraintValidatorContext context) {
+        return true;
+    }
+}
diff --git a/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/ServiceLoadedConstraint.java b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/ServiceLoadedConstraint.java
new file mode 100644
index 0000000..efc5edd
--- /dev/null
+++ b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/ServiceLoadedConstraint.java
@@ -0,0 +1,48 @@
+/*
+ * 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.bval.jsr.serviceloader;
+
+import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
+import static java.lang.annotation.ElementType.CONSTRUCTOR;
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+
+/**
+ * Declares no validator of its own; the only one comes from
+ * {@code META-INF/services/jakarta.validation.ConstraintValidator}.
+ */
+@Target({ TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, ANNOTATION_TYPE })
+@Retention(RUNTIME)
+@Constraint
+public @interface ServiceLoadedConstraint {
+    String message() default "service-loaded";
+
+    Class<?>[] groups() default {};
+
+    Class<? extends Payload>[] payload() default {};
+}
diff --git a/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/ServiceLoadedConstraintValidator.java b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/ServiceLoadedConstraintValidator.java
new file mode 100644
index 0000000..174589d
--- /dev/null
+++ b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/ServiceLoadedConstraintValidator.java
@@ -0,0 +1,30 @@
+/*
+ * 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.bval.jsr.serviceloader;
+
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+
+public class ServiceLoadedConstraintValidator implements ConstraintValidator<ServiceLoadedConstraint, String> {
+
+    @Override
+    public boolean isValid(String value, ConstraintValidatorContext context) {
+        return "valid".equals(value);
+    }
+}
diff --git a/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/ServiceLoaderBootstrapTest.java b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/ServiceLoaderBootstrapTest.java
new file mode 100644
index 0000000..85ffbc0
--- /dev/null
+++ b/bval-jsr/src/test/java/org/apache/bval/jsr/serviceloader/ServiceLoaderBootstrapTest.java
@@ -0,0 +1,87 @@
+/*
+ * 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.bval.jsr.serviceloader;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Set;
+
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.Validation;
+import jakarta.validation.ValidationException;
+import jakarta.validation.Validator;
+
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/**
+ * Exercises constraint validator discovery through a real
+ * {@code META-INF/services/jakarta.validation.ConstraintValidator} on the test classpath, i.e. the
+ * {@code ParticipantFactory} lookup and the wiring done by {@code ApacheValidatorFactory}, which the provider's own
+ * unit tests bypass.
+ */
+public class ServiceLoaderBootstrapTest {
+
+    public static class ServiceLoadedBean {
+        @ServiceLoadedConstraint
+        public String value;
+
+        public ServiceLoadedBean(String value) {
+            this.value = value;
+        }
+    }
+
+    public static class CollidingBean {
+        @CollidingConstraint
+        public String value;
+    }
+
+    private static Validator validator;
+
+    @BeforeClass
+    public static void setup() {
+        validator = Validation.buildDefaultValidatorFactory().getValidator();
+    }
+
+    @Test
+    public void testServiceLoadedValidatorApplied() {
+        final Set<ConstraintViolation<ServiceLoadedBean>> violations =
+            validator.validate(new ServiceLoadedBean("nope"));
+
+        assertEquals(1, violations.size());
+        assertEquals(ServiceLoadedConstraint.class,
+            violations.iterator().next().getConstraintDescriptor().getAnnotation().annotationType());
+    }
+
+    @Test
+    public void testServiceLoadedValidatorSatisfied() {
+        assertTrue(validator.validate(new ServiceLoadedBean("valid")).isEmpty());
+    }
+
+    /**
+     * Two validators for the same constraint and target type, one from {@code validatedBy()} and one from the service
+     * loader, neither source overriding the other.
+     */
+    @Test
+    public void testCollisionWithAnnotationDeclaredValidator() {
+        assertThrows(ValidationException.class, () -> validator.validate(new CollidingBean()));
+    }
+}
diff --git a/bval-jsr/src/test/resources/META-INF/services/jakarta.validation.ConstraintValidator b/bval-jsr/src/test/resources/META-INF/services/jakarta.validation.ConstraintValidator
new file mode 100644
index 0000000..c48ec04
--- /dev/null
+++ b/bval-jsr/src/test/resources/META-INF/services/jakarta.validation.ConstraintValidator
@@ -0,0 +1,18 @@
+# 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.bval.jsr.serviceloader.ServiceLoadedConstraintValidator
+org.apache.bval.jsr.serviceloader.CollidingServiceValidator   # trailing comments are legal here