Add Log.child(), Log.problem(), and injection-point-aware DI factory

- Injector.bindFactory(Class, Function<Key, T>): factory-based binding
  that receives the full injection-point Key (including @Named qualifier),
  enabling qualifier-derived instances like hierarchical logger names.

- Log.child(String): returns a child logger with hierarchical naming
  (e.g. "compiler:compile" → "compiler:compile.diagnostics"), useful
  when plugins delegate to sub-components that need independently
  filterable log output.

- Log.problem(BuilderProblem): reports a structured problem to the
  diagnostic collector with dedup key, suggestion, and documentation
  URL, while also logging at the appropriate level. Uses a thread-local
  flag to prevent double-counting by BuildReportCollector's WARN
  auto-promotion.

- DefaultMavenPluginManager: switched from bindInstance to bindFactory
  for Log injection, so @Inject @Named("diagnostics") Log in a
  DI-managed plugin component gets "compiler:compile.diagnostics".

- Fixed pre-existing bug: DefaultLog.warn(Supplier, Throwable) was
  calling logger.info() instead of logger.warn().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java
index 50627ef..8b12b23 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/Log.java
@@ -21,7 +21,9 @@
 import java.util.function.Supplier;
 
 import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
 import org.apache.maven.api.annotations.Provider;
+import org.apache.maven.api.services.BuilderProblem;
 
 /**
  * This interface supplies the API for providing feedback to the user from the {@code Mojo},
@@ -167,4 +169,55 @@ public interface Log {
     void error(Supplier<String> content);
 
     void error(Supplier<String> content, Throwable error);
+
+    /**
+     * Returns a child logger with the given name appended to this logger's name,
+     * enabling hierarchical logger namespacing within a plugin.
+     * <p>
+     * For example, if the current logger is named {@code "compiler:compile"},
+     * calling {@code child("diagnostics")} returns a logger named
+     * {@code "compiler:compile.diagnostics"}.
+     * <p>
+     * This is useful when a plugin delegates to sub-components (e.g. options
+     * resolution, diagnostic reporting, incremental build decisions) and wants
+     * each component's log output to be independently filterable.
+     *
+     * @param name the child logger name segment (appended after a dot separator)
+     * @return a child logger; the default implementation returns {@code this}
+     * @since 4.1.0
+     */
+    @Nonnull
+    default Log child(@Nonnull String name) {
+        return this;
+    }
+
+    /**
+     * Reports a structured {@link BuilderProblem} to the build's diagnostic collector.
+     * <p>
+     * Unlike {@link #warn(CharSequence)}, a structured problem carries a deduplication
+     * {@linkplain BuilderProblem#getKey() key}, an optional
+     * {@linkplain BuilderProblem#getSuggestion() suggestion}, and an optional
+     * {@linkplain BuilderProblem#getDocumentationUrl() documentation URL} — enabling
+     * Maven to deduplicate repeated warnings across modules and present an actionable
+     * end-of-build summary.
+     * <p>
+     * The problem is also logged at the appropriate level (WARN or ERROR) so it
+     * appears in the normal console output. Callers should <em>not</em> additionally
+     * call {@link #warn(CharSequence)} for the same message, as that would produce
+     * duplicate output.
+     * <p>
+     * The default implementation falls back to {@link #warn(CharSequence)} or
+     * {@link #error(CharSequence)} based on the problem's severity.
+     *
+     * @param problem the structured problem to report
+     * @since 4.1.0
+     */
+    default void problem(@Nonnull BuilderProblem problem) {
+        if (problem.getSeverity() == BuilderProblem.Severity.ERROR
+                || problem.getSeverity() == BuilderProblem.Severity.FATAL) {
+            error(problem.getMessage());
+        } else {
+            warn(problem.getMessage());
+        }
+    }
 }
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java
index 6d85b83..598a3a1 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/BuildReportCollector.java
@@ -57,6 +57,7 @@
 import org.apache.maven.execution.ExecutionEvent;
 import org.apache.maven.execution.MavenExecutionResult;
 import org.apache.maven.execution.MavenSession;
+import org.apache.maven.internal.impl.DefaultLog;
 import org.apache.maven.plugin.MojoExecution;
 import org.apache.maven.project.MavenProject;
 import org.apache.maven.slf4j.MavenSimpleLogger;
@@ -367,9 +368,13 @@ private void captureLogEvent(int level, String loggerName, String message, Throw
         // Auto-collect WARN-level log events as build problems, giving Maven 3 plugins
         // automatic deduplication and summary at end of build without code changes.
         // Skip loggers that already pipe structured BuilderProblems directly to the
-        // DiagnosticCollector (avoiding double-counting), and our own logger to avoid
-        // feedback loops from problem summary printing.
-        if (level == LocationAwareLogger.WARN_INT && message != null && !EXCLUDED_LOGGERS.contains(loggerName)) {
+        // DiagnosticCollector (avoiding double-counting), our own logger to avoid
+        // feedback loops from problem summary printing, and messages triggered by
+        // Log.problem() which are already reported as structured problems.
+        if (level == LocationAwareLogger.WARN_INT
+                && message != null
+                && !EXCLUDED_LOGGERS.contains(loggerName)
+                && !DefaultLog.STRUCTURED_PROBLEM_ACTIVE.get()) {
             String syntheticKey = syntheticDiagnosticKey(loggerName, message);
             diagnosticCollector.report(BuilderProblem.builder()
                     .source(loggerName)
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java
index 1a11fe4..a81089d 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultLog.java
@@ -18,18 +18,35 @@
  */
 package org.apache.maven.internal.impl;
 
+import java.util.function.Consumer;
 import java.util.function.Supplier;
 
 import org.apache.maven.api.plugin.Log;
+import org.apache.maven.api.services.BuilderProblem;
 import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import static java.util.Objects.requireNonNull;
 
 public class DefaultLog implements Log {
+
+    /**
+     * Thread-local flag set by {@link #problem(BuilderProblem)} around the SLF4J call
+     * so that {@code BuildReportCollector} can skip auto-promotion for messages that
+     * are already reported as structured problems. This avoids double-counting.
+     */
+    public static final ThreadLocal<Boolean> STRUCTURED_PROBLEM_ACTIVE = ThreadLocal.withInitial(() -> Boolean.FALSE);
+
     private final Logger logger;
+    private final Consumer<BuilderProblem> problemSink;
 
     public DefaultLog(Logger logger) {
+        this(logger, p -> {});
+    }
+
+    public DefaultLog(Logger logger, Consumer<BuilderProblem> problemSink) {
         this.logger = requireNonNull(logger);
+        this.problemSink = requireNonNull(problemSink);
     }
 
     @Override
@@ -127,7 +144,7 @@ public void warn(Supplier<String> content) {
     @Override
     public void warn(Supplier<String> content, Throwable error) {
         if (isWarnEnabled()) {
-            logger.info(content.get(), error);
+            logger.warn(content.get(), error);
         }
     }
 
@@ -184,6 +201,33 @@ public boolean isErrorEnabled() {
         return logger.isErrorEnabled();
     }
 
+    @Override
+    public Log child(String name) {
+        requireNonNull(name, "child logger name must not be null");
+        return new DefaultLog(LoggerFactory.getLogger(logger.getName() + "." + name), problemSink);
+    }
+
+    @Override
+    public void problem(BuilderProblem problem) {
+        requireNonNull(problem, "problem must not be null");
+        // Report to the diagnostic collector for dedup and end-of-build summary
+        problemSink.accept(problem);
+        // Also log the message at the appropriate level for console output.
+        // Set the thread-local flag so BuildReportCollector skips auto-promotion
+        // (avoiding double-counting as both a structured problem and a synthetic one).
+        STRUCTURED_PROBLEM_ACTIVE.set(Boolean.TRUE);
+        try {
+            String message = problem.getMessage();
+            switch (problem.getSeverity()) {
+                case FATAL, ERROR -> logger.error(message);
+                case WARNING -> logger.warn(message);
+                default -> logger.info(message);
+            }
+        } finally {
+            STRUCTURED_PROBLEM_ACTIVE.set(Boolean.FALSE);
+        }
+    }
+
     private String toString(CharSequence content) {
         return content != null ? content.toString() : "";
     }
diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java
index a95cf34..24038a3 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultMavenPluginManager.java
@@ -63,6 +63,7 @@
 import org.apache.maven.execution.MavenSession;
 import org.apache.maven.execution.scope.internal.MojoExecutionScope;
 import org.apache.maven.execution.scope.internal.MojoExecutionScopeModule;
+import org.apache.maven.internal.build.DefaultDiagnosticCollector;
 import org.apache.maven.internal.impl.DefaultLog;
 import org.apache.maven.internal.impl.DefaultMojoExecution;
 import org.apache.maven.internal.impl.InternalMavenSession;
@@ -164,6 +165,7 @@ public class DefaultMavenPluginManager implements MavenPluginManager {
     private final List<MavenPluginConfigurationValidator> configurationValidators;
     private final PluginValidationManager pluginValidationManager;
     private final List<MavenPluginPrerequisitesChecker> prerequisitesCheckers;
+    private final DefaultDiagnosticCollector diagnosticCollector;
     private final ExtensionDescriptorBuilder extensionDescriptorBuilder = new ExtensionDescriptorBuilder();
     private final PluginDescriptorBuilder builder = new PluginDescriptorBuilder();
 
@@ -181,7 +183,8 @@ public DefaultMavenPluginManager(
             MavenPluginValidator pluginValidator,
             List<MavenPluginConfigurationValidator> configurationValidators,
             PluginValidationManager pluginValidationManager,
-            List<MavenPluginPrerequisitesChecker> prerequisitesCheckers) {
+            List<MavenPluginPrerequisitesChecker> prerequisitesCheckers,
+            DefaultDiagnosticCollector diagnosticCollector) {
         this.container = container;
         this.classRealmManager = classRealmManager;
         this.pluginDescriptorCache = pluginDescriptorCache;
@@ -194,6 +197,7 @@ public DefaultMavenPluginManager(
         this.configurationValidators = configurationValidators;
         this.pluginValidationManager = pluginValidationManager;
         this.prerequisitesCheckers = prerequisitesCheckers;
+        this.diagnosticCollector = diagnosticCollector;
     }
 
     @Override
@@ -555,8 +559,7 @@ private <T> T loadV4Mojo(
         Project project = sessionV4.getProject(session.getCurrentProject());
 
         org.apache.maven.api.MojoExecution execution = new DefaultMojoExecution(sessionV4, mojoExecution);
-        org.apache.maven.api.plugin.Log log = new DefaultLog(
-                LoggerFactory.getLogger(mojoExecution.getMojoDescriptor().getFullGoalName()));
+        String baseLoggerName = mojoExecution.getMojoDescriptor().getFullGoalName();
         try {
             Injector injector = Injector.create();
             injector.discover(pluginRealm);
@@ -565,7 +568,16 @@ private <T> T loadV4Mojo(
             injector.bindInstance(Session.class, sessionV4);
             injector.bindInstance(Project.class, project);
             injector.bindInstance(org.apache.maven.api.MojoExecution.class, execution);
-            injector.bindInstance(org.apache.maven.api.plugin.Log.class, log);
+            // Factory-based Log binding: unqualified @Inject Log gets the base logger
+            // (e.g. "compiler:compile"), while @Inject @Named("diagnostics") Log gets
+            // a child logger ("compiler:compile.diagnostics") — enabling hierarchical
+            // logger namespacing within a plugin's sub-components.
+            injector.bindFactory(org.apache.maven.api.plugin.Log.class, key -> {
+                String qualifier = key.getQualifier() instanceof String s ? s : null;
+                String loggerName =
+                        qualifier != null && !qualifier.isEmpty() ? baseLoggerName + "." + qualifier : baseLoggerName;
+                return new DefaultLog(LoggerFactory.getLogger(loggerName), diagnosticCollector::report);
+            });
 
             Map<Class<? extends Service>, Supplier<? extends Service>> services = sessionV4.getAllServices();
             services.forEach((itf, svc) -> injector.bindSupplier((Class<Service>) itf, (Supplier<Service>) svc));
diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java
new file mode 100644
index 0000000..1209c70
--- /dev/null
+++ b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultLogTest.java
@@ -0,0 +1,235 @@
+/*
+ * 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.maven.internal.impl;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.maven.api.plugin.Log;
+import org.apache.maven.api.services.BuilderProblem;
+import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+
+class DefaultLogTest {
+
+    @Test
+    void childCreatesHierarchicalLoggerName() {
+        DefaultLog parent = new DefaultLog(LoggerFactory.getLogger("compiler:compile"));
+        Log child = parent.child("diagnostics");
+        assertNotSame(parent, child);
+        // child is a DefaultLog; verify it works by creating another level
+        Log grandchild = child.child("detail");
+        assertNotSame(child, grandchild);
+    }
+
+    @Test
+    void childInheritsProblemSink() {
+        List<BuilderProblem> collected = new ArrayList<>();
+        DefaultLog parent = new DefaultLog(LoggerFactory.getLogger("test:parent"), collected::add);
+        Log child = parent.child("sub");
+
+        BuilderProblem problem = BuilderProblem.builder()
+                .key("test:key")
+                .message("something wrong")
+                .severity(BuilderProblem.Severity.WARNING)
+                .build();
+        child.problem(problem);
+
+        assertEquals(1, collected.size());
+        assertEquals("test:key", collected.get(0).getKey());
+    }
+
+    @Test
+    void problemReportsToSinkAndSetsThreadLocalFlag() {
+        List<BuilderProblem> collected = new ArrayList<>();
+        DefaultLog log = new DefaultLog(LoggerFactory.getLogger("test:problem"), collected::add);
+
+        // Before calling problem(), flag should be false
+        assertFalse(DefaultLog.STRUCTURED_PROBLEM_ACTIVE.get());
+
+        BuilderProblem problem = BuilderProblem.builder()
+                .key("test:deprecation")
+                .message("deprecated feature")
+                .severity(BuilderProblem.Severity.WARNING)
+                .build();
+        log.problem(problem);
+
+        // After problem() returns, flag should be cleared
+        assertFalse(DefaultLog.STRUCTURED_PROBLEM_ACTIVE.get());
+
+        // Problem should have been reported to the sink
+        assertEquals(1, collected.size());
+        assertEquals("deprecated feature", collected.get(0).getMessage());
+    }
+
+    @Test
+    void problemDefaultImplementationFallsBackToWarnOrError() {
+        // Test the default implementation on the Log interface directly
+        Log defaultLog = new Log() {
+            final List<String> warnings = new ArrayList<>();
+            final List<String> errors = new ArrayList<>();
+
+            @Override
+            public boolean isDebugEnabled() {
+                return false;
+            }
+
+            @Override
+            public void debug(CharSequence content) {}
+
+            @Override
+            public void debug(CharSequence content, Throwable error) {}
+
+            @Override
+            public void debug(Throwable error) {}
+
+            @Override
+            public void debug(java.util.function.Supplier<String> content) {}
+
+            @Override
+            public void debug(java.util.function.Supplier<String> content, Throwable error) {}
+
+            @Override
+            public boolean isInfoEnabled() {
+                return true;
+            }
+
+            @Override
+            public void info(CharSequence content) {}
+
+            @Override
+            public void info(CharSequence content, Throwable error) {}
+
+            @Override
+            public void info(Throwable error) {}
+
+            @Override
+            public void info(java.util.function.Supplier<String> content) {}
+
+            @Override
+            public void info(java.util.function.Supplier<String> content, Throwable error) {}
+
+            @Override
+            public boolean isWarnEnabled() {
+                return true;
+            }
+
+            @Override
+            public void warn(CharSequence content) {
+                warnings.add(content.toString());
+            }
+
+            @Override
+            public void warn(CharSequence content, Throwable error) {}
+
+            @Override
+            public void warn(Throwable error) {}
+
+            @Override
+            public void warn(java.util.function.Supplier<String> content) {}
+
+            @Override
+            public void warn(java.util.function.Supplier<String> content, Throwable error) {}
+
+            @Override
+            public boolean isErrorEnabled() {
+                return true;
+            }
+
+            @Override
+            public void error(CharSequence content) {
+                errors.add(content.toString());
+            }
+
+            @Override
+            public void error(CharSequence content, Throwable error) {}
+
+            @Override
+            public void error(Throwable error) {}
+
+            @Override
+            public void error(java.util.function.Supplier<String> content) {}
+
+            @Override
+            public void error(java.util.function.Supplier<String> content, Throwable error) {}
+        };
+
+        // WARNING severity → warn()
+        defaultLog.problem(BuilderProblem.builder()
+                .message("warn msg")
+                .severity(BuilderProblem.Severity.WARNING)
+                .build());
+        assertEquals(1, ((List<?>) getField(defaultLog, "warnings")).size());
+
+        // ERROR severity → error()
+        defaultLog.problem(BuilderProblem.builder()
+                .message("error msg")
+                .severity(BuilderProblem.Severity.ERROR)
+                .build());
+        assertEquals(1, ((List<?>) getField(defaultLog, "errors")).size());
+    }
+
+    @Test
+    void problemWithNoopSinkDoesNotThrow() {
+        // DefaultLog with default no-op sink should not throw
+        DefaultLog log = new DefaultLog(LoggerFactory.getLogger("test:noop"));
+        BuilderProblem problem = BuilderProblem.builder()
+                .message("just a warning")
+                .severity(BuilderProblem.Severity.WARNING)
+                .build();
+        log.problem(problem); // should not throw
+    }
+
+    @Test
+    void structuredProblemFlagIsClearedOnException() {
+        DefaultLog log = new DefaultLog(LoggerFactory.getLogger("test:exception"), p -> {
+            throw new RuntimeException("sink failed");
+        });
+
+        BuilderProblem problem = BuilderProblem.builder()
+                .message("bad")
+                .severity(BuilderProblem.Severity.WARNING)
+                .build();
+
+        try {
+            log.problem(problem);
+        } catch (RuntimeException e) {
+            // expected
+        }
+        // Even on exception, the flag should NOT be left set
+        // (the exception is thrown before setting the flag in the current impl,
+        // but this tests the contract)
+        assertFalse(DefaultLog.STRUCTURED_PROBLEM_ACTIVE.get());
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Object getField(Object obj, String name) {
+        try {
+            var field = obj.getClass().getDeclaredField(name);
+            field.setAccessible(true);
+            return field.get(obj);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+}
diff --git a/impl/maven-di/src/main/java/org/apache/maven/di/Injector.java b/impl/maven-di/src/main/java/org/apache/maven/di/Injector.java
index 270ea69..57d1d99 100644
--- a/impl/maven-di/src/main/java/org/apache/maven/di/Injector.java
+++ b/impl/maven-di/src/main/java/org/apache/maven/di/Injector.java
@@ -19,6 +19,7 @@
 package org.apache.maven.di;
 
 import java.lang.annotation.Annotation;
+import java.util.function.Function;
 import java.util.function.Supplier;
 
 import org.apache.maven.api.annotations.Nonnull;
@@ -139,6 +140,36 @@ static Injector create() {
     <T> Injector bindSupplier(@Nonnull Class<T> cls, @Nonnull Supplier<T> supplier);
 
     /**
+     * Binds a factory that creates instances based on the injection-point {@link Key}.
+     * <p>
+     * Unlike {@link #bindInstance} or {@link #bindSupplier}, a factory receives the full
+     * {@link Key} (type + qualifier) requested at the injection site and can produce a
+     * different instance per qualifier. This enables patterns like qualifier-derived
+     * hierarchical logger names:
+     * <pre>
+     * injector.bindFactory(Log.class, key -&gt; {
+     *     String qualifier = key.getQualifier() instanceof String s ? s : null;
+     *     String name = qualifier != null ? baseName + "." + qualifier : baseName;
+     *     return new DefaultLog(LoggerFactory.getLogger(name));
+     * });
+     * </pre>
+     * A field annotated {@code @Inject @Named("diagnostics") Log logger} would then
+     * receive a logger named {@code "compiler:compile.diagnostics"}.
+     * <p>
+     * The factory also serves as the fallback for unqualified injection points
+     * ({@code @Inject Log logger}) — the key's qualifier will be {@code null}.
+     *
+     * @param <T> the type of instances the factory produces
+     * @param cls the class to bind the factory to
+     * @param factory a function from injection-point {@link Key} to instance
+     * @return this injector instance for method chaining
+     * @throws NullPointerException if either parameter is null
+     * @since 4.1.0
+     */
+    @Nonnull
+    <T> Injector bindFactory(@Nonnull Class<T> cls, @Nonnull Function<Key<T>, T> factory);
+
+    /**
      * Performs field and method injection on an existing instance.
      * <p>
      * This method will inject dependencies into annotated fields and methods of
diff --git a/impl/maven-di/src/main/java/org/apache/maven/di/impl/InjectorImpl.java b/impl/maven-di/src/main/java/org/apache/maven/di/impl/InjectorImpl.java
index 0b26a22..44c238c 100644
--- a/impl/maven-di/src/main/java/org/apache/maven/di/impl/InjectorImpl.java
+++ b/impl/maven-di/src/main/java/org/apache/maven/di/impl/InjectorImpl.java
@@ -60,6 +60,7 @@
 public class InjectorImpl implements Injector {
 
     private final Map<Key<?>, Set<Binding<?>>> bindings = new HashMap<>();
+    private final Map<Class<?>, Function<Key<?>, ?>> factories = new HashMap<>();
     private final Map<Class<? extends Annotation>, Supplier<Scope>> scopes = new HashMap<>();
     private final Set<String> loadedUrls = new HashSet<>();
     private final ThreadLocal<Set<Key<?>>> resolutionStack = new ThreadLocal<>();
@@ -149,6 +150,15 @@ public <U> Injector bindSupplier(@Nonnull Class<U> clazz, @Nonnull Supplier<U> s
 
     @Nonnull
     @Override
+    public <U> Injector bindFactory(@Nonnull Class<U> clazz, @Nonnull Function<Key<U>, U> factory) {
+        @SuppressWarnings("unchecked")
+        Function<Key<?>, ?> raw = (Function<Key<?>, ?>) (Function<?, ?>) factory;
+        factories.put(clazz, raw);
+        return this;
+    }
+
+    @Nonnull
+    @Override
     public Injector bindImplicit(@Nonnull Class<?> clazz) {
         Key<?> key = Key.of(clazz, ReflectionUtils.qualifierOf(clazz));
         if (clazz.isInterface()) {
@@ -231,6 +241,14 @@ public <Q> Supplier<Q> doGetCompiledBinding(Dependency<Q> dep) {
             Binding<Q> binding = bindingList.get(0);
             return compile(binding);
         }
+        // Factory fallback: if no exact binding, try a registered factory for the raw type.
+        // The factory receives the full Key (including qualifier) and produces the instance.
+        Function<Key<?>, ?> factory = factories.get(key.getRawType());
+        if (factory != null) {
+            @SuppressWarnings("unchecked")
+            Function<Key<?>, Q> typedFactory = (Function<Key<?>, Q>) factory;
+            return () -> typedFactory.apply(key);
+        }
         if (key.getRawType() == List.class) {
             Set<Binding<Object>> res2 = getBindings(key.getTypeParameter(0));
             if (res2 != null) {
@@ -486,6 +504,7 @@ public void dispose() {
 
         // Now clear everything else
         bindings.clear();
+        factories.clear();
         scopes.clear();
         loadedUrls.clear();
         resolutionStack.remove();
diff --git a/impl/maven-di/src/test/java/org/apache/maven/di/impl/InjectorImplTest.java b/impl/maven-di/src/test/java/org/apache/maven/di/impl/InjectorImplTest.java
index 46ea1b3..32a8866 100644
--- a/impl/maven-di/src/test/java/org/apache/maven/di/impl/InjectorImplTest.java
+++ b/impl/maven-di/src/test/java/org/apache/maven/di/impl/InjectorImplTest.java
@@ -514,4 +514,60 @@ static class Foo {}
         @Named
         static class Bar {}
     }
+
+    // ---- bindFactory tests ----
+
+    @Test
+    void factoryUnqualifiedLookup() {
+        Injector injector = Injector.create();
+        injector.bindFactory(String.class, key -> {
+            Object q = key.getQualifier();
+            return q instanceof String s && !s.isEmpty() ? "child:" + s : "root";
+        });
+        String result = injector.getInstance(String.class);
+        assertEquals("root", result);
+    }
+
+    @Test
+    void factoryQualifiedLookup() {
+        Injector injector = Injector.create();
+        injector.bindFactory(String.class, key -> {
+            Object q = key.getQualifier();
+            return q instanceof String s && !s.isEmpty() ? "child:" + s : "root";
+        });
+        String result = injector.getInstance(Key.of(String.class, "diagnostics"));
+        assertEquals("child:diagnostics", result);
+    }
+
+    @Test
+    void factoryInjectedIntoField() {
+        Injector injector = Injector.create();
+        injector.bindFactory(String.class, key -> {
+            Object q = key.getQualifier();
+            return q instanceof String s && !s.isEmpty() ? "child:" + s : "root";
+        });
+        injector.bindImplicit(FactoryConsumer.class);
+        FactoryConsumer consumer = injector.getInstance(FactoryConsumer.class);
+        assertEquals("root", consumer.defaultValue);
+        assertEquals("child:special", consumer.namedValue);
+    }
+
+    @Test
+    void factoryExplicitBindingTakesPrecedence() {
+        Injector injector = Injector.create();
+        injector.bindInstance(String.class, "explicit");
+        injector.bindFactory(String.class, key -> "factory");
+        // Explicit binding should win over factory
+        String result = injector.getInstance(String.class);
+        assertEquals("explicit", result);
+    }
+
+    static class FactoryConsumer {
+        @Inject
+        String defaultValue;
+
+        @Inject
+        @Named("special")
+        String namedValue;
+    }
 }