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

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new 9be7cecfcf feat: dynamic child REST resources + inject-aware 
microservice
9be7cecfcf is described below

commit 9be7cecfcf79059442047b51a3a9fdc93e5079a3
Author: James Bognar <[email protected]>
AuthorDate: Fri May 15 10:44:40 2026 -0400

    feat: dynamic child REST resources + inject-aware microservice
---
 .../apache/juneau/microservice/Microservice.java   | 135 +++++-
 .../microservice/jetty/JettyMicroservice.java      | 128 ++++-
 .../apache/juneau/ng/rest/client/NgRestClient.java |   2 +-
 .../java/org/apache/juneau/rest/RestChildren.java  | 329 ++++++++++++-
 .../java/org/apache/juneau/rest/RestContext.java   |  53 ++-
 .../org/apache/juneau/rest/arg/DefaultArg.java     |   3 +
 .../juneau/rest/servlet/BasicRestObjectGroup.java  |  93 +++-
 .../juneau/rest/servlet/BasicRestServletGroup.java |  94 +++-
 .../microservice/Microservice_Inject_Test.java     | 235 ++++++++++
 .../jetty/JettyMicroservice_Inject_Test.java       | 252 ++++++++++
 ...BasicRestServletGroup_DynamicChildren_Test.java | 515 +++++++++++++++++++++
 ...d => FINISHED-11a-restclient-ng-design-plan.md} |   6 +-
 todo/FINISHED-31-inject-aware-microservice.md      | 159 +++++++
 todo/FINISHED-33-dynamic-rest-children.md          |  65 +++
 todo/TODO-11-restclient-ng-coverage-closeout.md    |  70 +++
 todo/TODO.md                                       |   4 +-
 16 files changed, 2069 insertions(+), 74 deletions(-)

diff --git 
a/juneau-microservice/juneau-microservice-core/src/main/java/org/apache/juneau/microservice/Microservice.java
 
b/juneau-microservice/juneau-microservice-core/src/main/java/org/apache/juneau/microservice/Microservice.java
index b20f9aa349..e8f099a1f3 100755
--- 
a/juneau-microservice/juneau-microservice-core/src/main/java/org/apache/juneau/microservice/Microservice.java
+++ 
b/juneau-microservice/juneau-microservice-core/src/main/java/org/apache/juneau/microservice/Microservice.java
@@ -32,6 +32,7 @@ import java.util.jar.*;
 import java.util.logging.*;
 
 import org.apache.juneau.collections.*;
+import org.apache.juneau.commons.inject.*;
 import org.apache.juneau.commons.reflect.*;
 import org.apache.juneau.commons.runtime.*;
 import org.apache.juneau.commons.svl.*;
@@ -124,6 +125,8 @@ public class Microservice implements ConfigEventListener {
                PrintWriter consoleWriter;
                MicroserviceListener listener;
                File workingDir = 
env("juneau.workingDir").map(File::new).orElse(null);
+               WritableBeanStore beanStore;
+               List<Class<?>> configurations = list();
 
                /**
                 * Constructor.
@@ -147,6 +150,8 @@ public class Microservice implements ConfigEventListener {
                        this.consoleReader = copyFrom.consoleReader;
                        this.consoleWriter = copyFrom.consoleWriter;
                        this.workingDir = copyFrom.workingDir;
+                       this.beanStore = copyFrom.beanStore;
+                       this.configurations = new 
ArrayList<>(copyFrom.configurations);
                }
 
                /**
@@ -501,6 +506,67 @@ public class Microservice implements ConfigEventListener {
                        return this;
                }
 
+               /**
+                * Registers one or more <c>@Configuration</c>-annotated 
classes whose <c>@Bean</c> methods/fields
+                * will be processed during microservice bootstrap.
+                *
+                * <p>
+                * Beans contributed by <c>@Configuration</c> classes are 
stored in the microservice's internal
+                * {@link WritableBeanStore} (accessible via {@link 
Microservice#getBeanStore()}).  They become
+                * available to other <c>@Bean</c> factory methods via 
constructor injection.
+                *
+                * <p>
+                * Explicit builder calls (e.g. {@link #config(Config)}, {@link 
#args(Args)}) take precedence over
+                * <c>@Bean</c>-supplied values — the resolved field is 
registered into the bean store <i>after</i>
+                * configurations are processed, overwriting any same-type 
<c>@Bean</c> contribution.
+                *
+                * @param configurations The configuration classes.  Can be 
<jk>null</jk> or empty.
+                * @return This object.
+                * @since 9.5.0
+                */
+               public Builder configurations(Class<?>... configurations) {
+                       if (nn(configurations))
+                               for (var c : configurations)
+                                       if (nn(c))
+                                               this.configurations.add(c);
+                       return this;
+               }
+
+               /**
+                * Registers one or more <c>@Configuration</c>-annotated 
classes.
+                *
+                * @param configurations The configuration classes.  Can be 
<jk>null</jk> or empty.
+                * @return This object.
+                * @see #configurations(Class...)
+                * @since 9.5.0
+                */
+               public Builder configurations(List<Class<?>> configurations) {
+                       if (nn(configurations))
+                               for (var c : configurations)
+                                       if (nn(c))
+                                               this.configurations.add(c);
+                       return this;
+               }
+
+               /**
+                * Specifies an external {@link WritableBeanStore} to use 
instead of constructing a fresh one.
+                *
+                * <p>
+                * Useful for testing and for composing a microservice into a 
larger application that already owns
+                * a bean store (e.g. a Spring-backed parent store).  When set, 
configurations supplied via
+                * {@link #configurations(Class...)} are registered into this 
store, and the microservice's
+                * resolved values (args, manifest, config, var resolver, 
listener, microservice itself) are also
+                * registered into it.
+                *
+                * @param beanStore The external bean store.  Can be 
<jk>null</jk> to use a fresh internal store.
+                * @return This object.
+                * @since 9.5.0
+                */
+               public Builder beanStore(WritableBeanStore beanStore) {
+                       this.beanStore = beanStore;
+                       return this;
+               }
+
                /**
                 * Resolves the specified path.
                 *
@@ -562,6 +628,7 @@ public class Microservice implements ConfigEventListener {
        private final Thread consoleThread;
        final File workingDir;
        private final String configName;
+       private final WritableBeanStore beanStore;
 
        private final AtomicReference<Logger> logger = new AtomicReference<>();
 
@@ -574,7 +641,8 @@ public class Microservice implements ConfigEventListener {
         */
        @SuppressWarnings({
                "resource", // Resources are managed by caller
-               "java:S3776" // Cognitive complexity acceptable for 
microservice initialization
+               "java:S3776", // Cognitive complexity acceptable for 
microservice initialization
+               "java:S106" // Console fallback intentionally writes to 
System.out when no Console is available.
        })
        protected Microservice(Builder builder) throws IOException, 
ParseException {
                setInstance(this);
@@ -582,12 +650,27 @@ public class Microservice implements ConfigEventListener {
                this.workingDir = builder.workingDir;
                this.configName = builder.configName;
 
-               this.args = nn(builder.args) ? builder.args : new Args(new 
String[0]);
+               // 
--------------------------------------------------------------------------------
+               // Initialize the bean store and register any @Configuration 
classes.
+               // @Bean-supplied values become candidate inputs for field 
resolution below.
+               // Explicit builder calls always win and overwrite @Bean 
contributions afterward.
+               // 
--------------------------------------------------------------------------------
+               this.beanStore = nn(builder.beanStore) ? builder.beanStore : 
new BasicBeanStore();
+               if (! builder.configurations.isEmpty())
+                       
beanStore.registerConfigurations(builder.configurations.toArray(new 
Class<?>[0]));
+
+               // Resolve Args: builder > @Bean > empty.
+               this.args = nn(builder.args)
+                       ? builder.args
+                       : beanStore.getBean(Args.class).orElseGet(() -> new 
Args(new String[0]));
+               beanStore.addBean(Args.class, this.args);
 
                // 
--------------------------------------------------------------------------------
                // Try to get the manifest file if it wasn't already set.
                // 
--------------------------------------------------------------------------------
                var manifest2 = builder.manifest;
+               if (manifest2 == null)
+                       manifest2 = 
beanStore.getBean(ManifestFile.class).orElse(null);
                if (manifest2 == null) {
                        var m = new Manifest();
 
@@ -613,6 +696,7 @@ public class Microservice implements ConfigEventListener {
                        manifest2 = new ManifestFile(m);
                }
                this.manifest = manifest2;
+               beanStore.addBean(ManifestFile.class, this.manifest);
                builder.varResolver
                        .vars(ArgsVar.create(() -> this.args))
                        .vars(ManifestFileVar.create(() -> this.manifest));
@@ -620,7 +704,7 @@ public class Microservice implements ConfigEventListener {
                // 
--------------------------------------------------------------------------------
                // Try to resolve the configuration if not specified.
                // 
--------------------------------------------------------------------------------
-               var config2 = builder.config;
+               var config2 = nn(builder.config) ? builder.config : 
beanStore.getBean(Config.class).orElse(null);
                var configBuilder = 
builder.configBuilder.varResolver(builder.varResolver.build()).store(MemoryStore.DEFAULT);
                if (config2 == null) {
                        var store = builder.configStore;
@@ -649,7 +733,9 @@ public class Microservice implements ConfigEventListener {
                this.config = config2;
                Config.setSystemDefault(this.config);
                this.config.addListener(this);
+               beanStore.addBean(Config.class, this.config);
                this.varResolver = builder.varResolver.bean(Config.class, 
config2).build();
+               beanStore.addBean(VarResolver.class, this.varResolver);
 
                // 
--------------------------------------------------------------------------------
                // Initialize console commands.
@@ -660,6 +746,9 @@ public class Microservice implements ConfigEventListener {
                        this.consoleReader = 
firstNonNull(builder.consoleReader, new Scanner(c == null ? new 
InputStreamReader(System.in) : c.reader()));
                        this.consoleWriter = 
firstNonNull(builder.consoleWriter, c == null ? new PrintWriter(System.out, 
true) : c.writer());
 
+                       // @Bean-supplied console commands (registered first, 
then overridable by builder/config below).
+                       for (var cc : 
beanStore.getBeansOfType(ConsoleCommand.class).values())
+                               consoleCommandMap.put(cc.getName(), cc);
                        for (var cc : builder.consoleCommands) {
                                consoleCommandMap.put(cc.getName(), cc);
                        }
@@ -697,7 +786,14 @@ public class Microservice implements ConfigEventListener {
                        this.consoleWriter = null;
                        this.consoleThread = null;
                }
-               this.listener = nn(builder.listener) ? builder.listener : new 
BasicMicroserviceListener();
+               // Resolve listener: builder > @Bean > default.
+               this.listener = nn(builder.listener)
+                       ? builder.listener
+                       : 
beanStore.getBean(MicroserviceListener.class).orElseGet(BasicMicroserviceListener::new);
+               beanStore.addBean(MicroserviceListener.class, this.listener);
+
+               // Self-register so @Bean factory methods can take Microservice 
as a constructor/method param.
+               beanStore.addBean(Microservice.class, this);
 
                init();
        }
@@ -907,6 +1003,29 @@ public class Microservice implements ConfigEventListener {
         */
        public Logger getLogger() { return logger.get(); }
 
+       /**
+        * Returns the internal {@link WritableBeanStore} that backs this 
microservice's inject-aware bootstrap.
+        *
+        * <p>
+        * The store is populated with this microservice's resolved values 
(<c>Args</c>, <c>ManifestFile</c>,
+        * <c>Config</c>, <c>VarResolver</c>, <c>MicroserviceListener</c>, and 
<c>Microservice</c> itself),
+        * plus any beans contributed by <c>@Configuration</c> classes 
registered via
+        * {@link Builder#configurations(Class...)}.
+        *
+        * <p>
+        * Subclasses (e.g. <c>JettyMicroservice</c>) consult the store at 
start-up to discover additional
+        * beans (servlets, listeners, factories) to register.  External 
callers can query the store directly
+        * to look up beans contributed by user-supplied <c>@Configuration</c> 
classes.
+        *
+        * <p>
+        * The store is closed during {@link #stop()}, which triggers 
<c>@PreDestroy</c> hooks on resolved
+        * beans.
+        *
+        * @return The microservice's bean store.  Never <jk>null</jk>.
+        * @since 9.5.0
+        */
+       public WritableBeanStore getBeanStore() { return beanStore; }
+
        /**
         * Returns the main jar manifest file contents as a simple {@link 
JsonMap}.
         *
@@ -1130,6 +1249,14 @@ public class Microservice implements ConfigEventListener 
{
        })
        public Microservice stop() throws Exception {
                listener.onStop(this);
+               try {
+                       beanStore.close();
+               } catch (Exception e) {
+                       // @PreDestroy errors should not prevent the rest of 
the shutdown sequence; surface via logger.
+                       var lg = getLogger();
+                       if (nn(lg))
+                               lg.log(Level.WARNING, lm(e), e);
+               }
                return this;
        }
 
diff --git 
a/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyMicroservice.java
 
b/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyMicroservice.java
index 92675f4862..b924f00754 100644
--- 
a/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyMicroservice.java
+++ 
b/juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyMicroservice.java
@@ -31,6 +31,7 @@ import java.util.*;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.logging.*;
 
+import org.apache.juneau.commons.inject.*;
 import org.apache.juneau.commons.reflect.*;
 import org.apache.juneau.commons.runtime.*;
 import org.apache.juneau.commons.svl.*;
@@ -389,6 +390,24 @@ public class JettyMicroservice extends Microservice {
                        return this;
                }
 
+               @Override /* Overridden from MicroserviceBuilder */
+               public Builder configurations(Class<?>...configurations) {
+                       super.configurations(configurations);
+                       return this;
+               }
+
+               @Override /* Overridden from MicroserviceBuilder */
+               public Builder configurations(List<Class<?>> configurations) {
+                       super.configurations(configurations);
+                       return this;
+               }
+
+               @Override /* Overridden from MicroserviceBuilder */
+               public Builder beanStore(WritableBeanStore beanStore) {
+                       super.beanStore(beanStore);
+                       return this;
+               }
+
                @Override /* Overridden from MicroserviceBuilder */
                public Builder workingDir(String path) {
                        super.workingDir(path);
@@ -483,12 +502,26 @@ public class JettyMicroservice extends Microservice {
         * @throws IOException Problem occurred reading file.
         * @throws ParseException Malformed content found in config file.
         */
+       @SuppressWarnings({
+               "resource" // getBeanStore() is microservice-owned and closed 
in Microservice.stop(); fluent addBean() should not be auto-closed here.
+       })
        protected JettyMicroservice(Builder builder) throws ParseException, 
IOException {
                super(builder);
                setInstance(this);
                this.builder = builder.copy();
-               this.listener = nn(builder.listener2) ? builder.listener2 : new 
BasicJettyMicroserviceListener();
-               this.factory = nn(builder.factory) ? builder.factory : new 
BasicJettyServerFactory();
+               var store = getBeanStore();
+               // Listener: explicit builder > @Bean (Jetty-specific first, 
then core) > default.
+               this.listener = nn(builder.listener2)
+                       ? builder.listener2
+                       : 
store.getBean(JettyMicroserviceListener.class).orElseGet(BasicJettyMicroserviceListener::new);
+               store.addBean(JettyMicroserviceListener.class, this.listener);
+               // Factory: explicit builder > @Bean > default.
+               this.factory = nn(builder.factory)
+                       ? builder.factory
+                       : 
store.getBean(JettyServerFactory.class).orElseGet(BasicJettyServerFactory::new);
+               store.addBean(JettyServerFactory.class, this.factory);
+               // Self-register so @Bean factory methods can take 
JettyMicroservice as a constructor/method param.
+               store.addBean(JettyMicroservice.class, this);
        }
 
        /**
@@ -554,7 +587,8 @@ public class JettyMicroservice extends Microservice {
         * @throws ExecutableException Exception occurred on invoked 
constructor/method/field.
         */
        @SuppressWarnings({
-               "java:S3776" // Cognitive complexity acceptable for server 
creation logic
+               "java:S3776", // Cognitive complexity acceptable for server 
creation logic
+               "resource" // getBeanStore() is owned by the microservice 
lifecycle; do not close in createServer().
        })
        public Server createServer() throws ParseException, IOException, 
ExecutableException {
                listener.onCreateServer(this);
@@ -562,6 +596,7 @@ public class JettyMicroservice extends Microservice {
                var cf = getConfig();
                var mf = getManifest();
                var vr = getVarResolver();
+               var store = getBeanStore();
 
                var ports = firstNonNull(builder.ports, 
cf.get("Jetty/port").as(int[].class).orElseGet(() -> 
mf.get("Jetty-Port").map(JettyMicroservice::parseIntArray).orElseGet(() -> 
ints(8000))));
                var availablePort = findOpenPort(ports);
@@ -569,33 +604,45 @@ public class JettyMicroservice extends Microservice {
                if (env("availablePort").isEmpty())
                        System.setProperty("availablePort", 
String.valueOf(availablePort));
 
-               var jettyXml = builder.jettyXml;
-               var jettyConfig = cf.get("Jetty/config").orElseGet(() -> 
mf.get("Jetty-Config").orElse("jetty.xml"));
-               var resolveVars = firstNonNull(builder.jettyXmlResolveVars, 
cf.get("Jetty/resolveVars").asBoolean().orElse(false));
-               boolean resolveVars2 = isTrue(resolveVars);
+               // Prefer a @Bean-supplied Server if one was contributed via 
@Configuration.
+               // Otherwise build from jetty.xml via the configured factory.
+               var injectedServer = store.getBean(Server.class).orElse(null);
+               if (nn(injectedServer)) {
+                       server.set(injectedServer);
+               } else {
+                       var jettyXml = builder.jettyXml;
+                       var jettyConfig = cf.get("Jetty/config").orElseGet(() 
-> mf.get("Jetty-Config").orElse("jetty.xml"));
+                       var resolveVars = 
firstNonNull(builder.jettyXmlResolveVars, 
cf.get("Jetty/resolveVars").asBoolean().orElse(false));
+                       boolean resolveVars2 = isTrue(resolveVars);
 
-               if (jettyXml == null)
-                       jettyXml = loadSystemResourceAsString("jetty.xml", ".", 
"files");
-               if (jettyXml == null)
-                       throw rex("jetty.xml file ''{0}'' was not found on the 
file system or classpath.", jettyConfig);
+                       if (jettyXml == null)
+                               jettyXml = 
loadSystemResourceAsString("jetty.xml", ".", "files");
+                       if (jettyXml == null)
+                               throw rex("jetty.xml file ''{0}'' was not found 
on the file system or classpath.", jettyConfig);
 
-               if (resolveVars2)
-                       jettyXml = vr.resolve(jettyXml);
+                       if (resolveVars2)
+                               jettyXml = vr.resolve(jettyXml);
 
-               getLogger().info(jettyXml);
+                       getLogger().info(jettyXml);
 
-               try {
-                       server.set(factory.create(jettyXml));
-               } catch (Exception e2) {
-                       throw new ExecutableException(e2);
+                       try {
+                               server.set(factory.create(jettyXml));
+                       } catch (Exception e2) {
+                               throw new ExecutableException(e2);
+                       }
                }
+               // Publish the Server back to the store so @Bean factory 
methods of other beans can depend on it.
+               store.addBean(Server.class, server.get());
+
+               // Track each servlet pathSpec with its declaring source so we 
can fail loudly on collisions.
+               var mountedPaths = new LinkedHashMap<String,String>();
 
                for (var s : 
cf.get("Jetty/servlets").asStringArray().orElse(new String[0])) {
                        try {
                                var c = info(Class.forName(s));
                                if (c.isAssignableTo(RestServlet.class)) {
                                        var rs = (RestServlet)c.newInstance();
-                                       addServlet(rs, rs.getPath());
+                                       mountWithCollisionCheck(rs, 
rs.getPath(), "Jetty/servlets[" + s + "]", mountedPaths);
                                } else {
                                        throw rex("Invalid servlet specified in 
Jetty/servlets.  Must be a subclass of RestServlet: {0}", s);
                                }
@@ -609,7 +656,7 @@ public class JettyMicroservice extends Microservice {
                                var c = info(Class.forName(v.toString()));
                                if (c.isAssignableTo(Servlet.class)) {
                                        var rs = (Servlet)c.newInstance();
-                                       addServlet(rs, k);
+                                       mountWithCollisionCheck(rs, k, 
"Jetty/servletMap[" + k + "]", mountedPaths);
                                } else {
                                        throw rex("Invalid servlet specified in 
Jetty/servletMap.  Must be a subclass of Servlet: {0}", cn(v));
                                }
@@ -620,16 +667,55 @@ public class JettyMicroservice extends Microservice {
 
                
cf.get("Jetty/servletAttributes").asMap().orElse(EMPTY_MAP).forEach(this::addServletAttribute);
 
-               builder.servlets.forEach((k, v) -> addServlet(v, k));
+               builder.servlets.forEach((k, v) -> mountWithCollisionCheck(v, 
k, "Builder.servlet(" + cn(v) + ")", mountedPaths));
 
                builder.servletAttributes.forEach(this::addServletAttribute);
 
+               // Auto-discover @Rest servlets contributed via 
@Configuration/@Bean methods.
+               // Path source precedence: @Rest(path=...) on the resource 
class, else "/".
+               // Builder-supplied servlets already won (registered above), so 
duplicate mount paths fail hard.
+               for (var e : store.getBeansOfType(Servlet.class).entrySet()) {
+                       var servlet = e.getValue();
+                       var cls = servlet.getClass();
+                       if (cls.getAnnotation(Rest.class) == null)
+                               continue;
+                       var pathSpec = restPathFor(cls);
+                       mountWithCollisionCheck(servlet, pathSpec, "@Bean " + 
cls.getName() + (ne(e.getKey()) ? "[" + e.getKey() + "]" : ""), mountedPaths);
+               }
+
                if (env("juneau.serverPort").isEmpty())
                        System.setProperty("juneau.serverPort", 
String.valueOf(availablePort));
 
                return server.get();
        }
 
+       private void mountWithCollisionCheck(Servlet servlet, String rawPath, 
String source, Map<String,String> mountedPaths) {
+               var pathSpec = normalizePathSpec(rawPath);
+               var prior = mountedPaths.get(pathSpec);
+               if (nn(prior))
+                       throw rex("Servlet mount path collision: ''{0}'' is 
already mounted by {1}; refused by {2}.", pathSpec, prior, source);
+               mountedPaths.put(pathSpec, source);
+               addServlet(servlet, pathSpec);
+       }
+
+       private static String normalizePathSpec(String rawPath) {
+               var p = rawPath == null ? "" : rawPath;
+               if (p.isEmpty() || "/".equals(p))
+                       return "/*";
+               if (! p.startsWith("/"))
+                       p = "/" + p;
+               if (! p.endsWith("/*"))
+                       p = trimTrailingSlashes(p) + "/*";
+               return p;
+       }
+
+       private static String restPathFor(Class<?> cls) {
+               var r = cls.getAnnotation(Rest.class);
+               if (r == null || r.path().isEmpty())
+                       return "/";
+               return r.path();
+       }
+
        /**
         * Calls {@link Server#destroy()} on the underlying Jetty server if it 
exists.
         *
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestClient.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestClient.java
index 48959fa26d..8a46a79be8 100644
--- 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestClient.java
+++ 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestClient.java
@@ -72,7 +72,7 @@ public final class NgRestClient implements Closeable {
         * Call {@link Builder#bodyConverters(BodyConverter[])} to replace all 
defaults.
         */
        public static final List<BodyConverter<?>> DEFAULT_BODY_CONVERTERS = 
List.of(
-               BodyConverter.of(HttpBody.class, body -> 
TransportBody.of(body)),
+               BodyConverter.of(HttpBody.class, TransportBody::of),
                BodyConverter.of(InputStream.class, is -> 
TransportBody.of(StreamBody.of(is))),
                BodyConverter.of(byte[].class, bytes -> 
TransportBody.of(ByteArrayBody.of(bytes))),
                BodyConverter.of(java.io.File.class, file -> 
TransportBody.of(FileBody.of(file)))
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestChildren.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestChildren.java
index 928ae30fb9..1be33ca18f 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestChildren.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestChildren.java
@@ -17,11 +17,15 @@
 package org.apache.juneau.rest;
 
 import static org.apache.juneau.commons.utils.CollectionUtils.*;
+import static org.apache.juneau.commons.utils.ThrowableUtils.*;
 import static org.apache.juneau.commons.utils.Utils.*;
 
+import java.lang.reflect.*;
 import java.util.*;
+import java.util.function.*;
 
-import org.apache.juneau.commons.inject.BeanStore;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.commons.reflect.*;
 import org.apache.juneau.rest.annotation.*;
 import org.apache.juneau.rest.util.*;
 
@@ -30,6 +34,14 @@ import jakarta.servlet.*;
 /**
  * Implements the child resources of a {@link Rest}-annotated class.
  *
+ * <p>
+ * Holds a registry of {@link RestContext} instances keyed by {@link 
RestContext#getPath() composed path}.
+ * Reads (request routing via {@link #findMatch(RestSession.Builder)} and the 
{@link #asMap()} accessor) are
+ * lock-free against a volatile copy-on-write snapshot; mutations
+ * ({@link #addChild(Class) addChild} / {@link #removeChild(String) 
removeChild}) synchronize on an internal
+ * write lock and atomically replace the snapshot. This makes runtime child 
management safe even while
+ * requests are in flight on other threads.
+ *
  * <h5 class='section'>See Also:</h5><ul>
  *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/RestAnnotatedClassBasics";>@Rest-Annotated
 Class Basics</a>
  * </ul>
@@ -41,17 +53,27 @@ public class RestChildren {
         */
        public static class Builder {
 
-               private final BeanStore beanStore;
+               final RestContext parent;
+               final BeanStore beanStore;
+               final ServletConfig servletConfig;
                final List<RestContext> list;
 
                /**
                 * Constructor.
                 *
-                * @param beanStore The bean store to use for creating beans.
+                * @param parent The parent {@link RestContext} that owns these 
children. Used for parent-context wiring
+                *      on dynamically added children. May be {@code null} when 
constructing from contexts already built
+                *      externally and runtime add/remove is not required.
+                * @param beanStore The bean store used for dependency 
injection on dynamically added children. Must not be
+                *      {@code null}.
+                * @param servletConfig The {@link ServletConfig} passed 
through to dynamically added child contexts.
+                *      May be {@code null}.
                 */
-               protected Builder(BeanStore beanStore) {
+               protected Builder(RestContext parent, BeanStore beanStore, 
ServletConfig servletConfig) {
+                       this.parent = parent;
                        this.beanStore = beanStore;
-                       list = list();
+                       this.servletConfig = servletConfig;
+                       this.list = list();
                }
 
                /**
@@ -87,14 +109,22 @@ public class RestChildren {
        /**
         * Static creator.
         *
-        * @param beanStore The bean store to use for creating beans.
+        * @param parent The parent {@link RestContext}. Required for runtime 
{@link #addChild(Class) addChild} support
+        *      (may be {@code null} otherwise).
+        * @param beanStore The bean store used for child instantiation.
+        * @param servletConfig The {@link ServletConfig} propagated to 
dynamically added children. May be {@code null}.
         * @return A new builder for this object.
         */
-       public static Builder create(BeanStore beanStore) {
-               return new Builder(beanStore);
+       public static Builder create(RestContext parent, BeanStore beanStore, 
ServletConfig servletConfig) {
+               return new Builder(parent, beanStore, servletConfig);
        }
 
-       private final Map<String,RestContext> children = synced(map());
+       private final RestContext parent;
+       private final BeanStore beanStore;
+       private final ServletConfig servletConfig;
+       private final Object writeLock = new Object();
+
+       private volatile Map<String,RestContext> children;
 
        /**
         * Constructor.
@@ -102,31 +132,35 @@ public class RestChildren {
         * @param builder The builder containing the settings for this object.
         */
        public RestChildren(Builder builder) {
+               this.parent = builder.parent;
+               this.beanStore = builder.beanStore;
+               this.servletConfig = builder.servletConfig;
+               var initial = new LinkedHashMap<String,RestContext>();
                for (var rc : builder.list)
-                       children.put(rc.getPath(), rc);
+                       initial.put(rc.getPath(), rc);
+               this.children = Collections.unmodifiableMap(initial);
        }
 
        /**
         * Returns the children in this object as a map.
         *
         * <p>
-        * The keys are the {@link RestContext#getPath() paths} of the child 
contexts.
+        * The keys are the {@link RestContext#getPath() paths} of the child 
contexts. The returned map is an
+        * unmodifiable snapshot taken at the time of the call; subsequent 
{@link #addChild(Class) addChild} /
+        * {@link #removeChild(String) removeChild} calls do not affect this 
view.
         *
         * @return The children as an unmodifiable map.
         */
        public Map<String,RestContext> asMap() {
-               return u(children);
+               return children;
        }
 
        /**
         * Called during servlet destruction on all children to invoke all 
{@link RestDestroy} and {@link Servlet#destroy()} methods.
         */
        public void destroy() {
-               for (var r : children.values()) {
-                       r.destroy();
-                       if (r.getResource() instanceof Servlet r2)
-                               r2.destroy();
-               }
+               for (var r : children.values())
+                       destroyChild(r);
        }
 
        /**
@@ -136,9 +170,10 @@ public class RestChildren {
         * @return The child that best matches the call, or an empty {@link 
Optional} if a match could not be made.
         */
        public Optional<RestChildMatch> findMatch(RestSession.Builder builder) {
+               var snapshot = children;  // single volatile read; consistent 
for the rest of the method
                var pi = builder.getPathInfoUndecoded();
-               if ((! children.isEmpty()) && nn(pi) && ! pi.equals("/")) {
-                       for (var rc : children.values()) {
+               if ((! snapshot.isEmpty()) && nn(pi) && ! pi.equals("/")) {
+                       for (var rc : snapshot.values()) {
                                UrlPathMatcher upp = rc.getPathMatcher();
                                UrlPathMatch uppm = 
upp.match(builder.getUrlPath());
                                if (nn(uppm)) {
@@ -168,4 +203,258 @@ public class RestChildren {
                for (var childContext : children.values())
                        childContext.postInitChildFirst();
        }
-}
\ No newline at end of file
+
+       
//-------------------------------------------------------------------------------------------------------------
+       // Dynamic add/remove API.
+       //
+       // Routing reads (findMatch / asMap) are lock-free against the volatile 
`children` snapshot.
+       // Mutations synchronize on writeLock and atomically swap in a new 
unmodifiable LinkedHashMap.
+       
//-------------------------------------------------------------------------------------------------------------
+
+       /**
+        * Dynamically registers a child REST resource by class, instantiating 
it via the parent's {@link BeanStore}.
+        *
+        * <p>
+        * The path under which the child is registered is determined by the 
{@link Rest#path() @Rest(path)} annotation
+        * on the resource class composed against the parent's full path.
+        *
+        * <p>
+        * The new child's {@link RestContext#postInit()} and {@link 
RestContext#postInitChildFirst()} lifecycle hooks
+        * are invoked before this method returns, mirroring the eager-init 
behavior of {@code @Rest(children = ...)}.
+        *
+        * @param resourceClass The {@code @Rest}-annotated resource class.
+        * @return The newly-built child {@link RestContext}.
+        * @throws ServletException If construction or lifecycle initialization 
of the child fails.
+        * @throws IllegalStateException If a child is already registered at 
the resolved path.
+        */
+       public RestContext addChild(Class<?> resourceClass) throws 
ServletException {
+               return addChildInternal(resourceClass, null, "", false);
+       }
+
+       /**
+        * Dynamically registers a pre-instantiated child REST resource.
+        *
+        * <p>
+        * The path under which the child is registered is determined by the 
{@link Rest#path() @Rest(path)} annotation
+        * on the resource's class composed against the parent's full path.
+        *
+        * @param resource The {@code @Rest}-annotated resource instance.
+        * @return The newly-built child {@link RestContext}.
+        * @throws ServletException If construction or lifecycle initialization 
of the child fails.
+        * @throws IllegalStateException If a child is already registered at 
the resolved path.
+        */
+       public RestContext addChild(Object resource) throws ServletException {
+               return addChildInternal(resource.getClass(), resource, "", 
false);
+       }
+
+       /**
+        * Dynamically registers a pre-instantiated child REST resource, 
optionally replacing any existing child at the
+        * same resolved path.
+        *
+        * @param resource The {@code @Rest}-annotated resource instance.
+        * @param replace If {@code true}, an existing child at the same path 
is destroyed and removed before the new
+        *      child is added. If {@code false}, an existing child causes an 
{@link IllegalStateException}.
+        * @return The newly-built child {@link RestContext}.
+        * @throws ServletException If construction or lifecycle initialization 
of the child fails.
+        */
+       public RestContext addChild(Object resource, boolean replace) throws 
ServletException {
+               return addChildInternal(resource.getClass(), resource, "", 
replace);
+       }
+
+       /**
+        * Dynamically registers a pre-instantiated child REST resource at an 
explicit path.
+        *
+        * <p>
+        * The supplied {@code path} overrides whatever {@link Rest#path() 
@Rest(path)} would normally provide. Useful
+        * for mounting the same servlet class at multiple paths, or for test 
fixtures that compose paths programmatically.
+        *
+        * @param path The path segment under the parent at which to mount this 
child. Leading slashes are trimmed.
+        * @param resource The {@code @Rest}-annotated resource instance.
+        * @return The newly-built child {@link RestContext}.
+        * @throws ServletException If construction or lifecycle initialization 
of the child fails.
+        * @throws IllegalStateException If a child is already registered at 
the resolved path.
+        */
+       public RestContext addChild(String path, Object resource) throws 
ServletException {
+               return addChildInternal(resource.getClass(), resource, path, 
false);
+       }
+
+       /**
+        * Dynamically registers a pre-instantiated child REST resource at an 
explicit path, optionally replacing any
+        * existing child at the same path.
+        *
+        * @param path The path segment under the parent at which to mount this 
child. Leading slashes are trimmed.
+        * @param resource The {@code @Rest}-annotated resource instance.
+        * @param replace If {@code true}, an existing child at the same path 
is destroyed and removed before the new
+        *      child is added. If {@code false}, an existing child causes an 
{@link IllegalStateException}.
+        * @return The newly-built child {@link RestContext}.
+        * @throws ServletException If construction or lifecycle initialization 
of the child fails.
+        */
+       public RestContext addChild(String path, Object resource, boolean 
replace) throws ServletException {
+               return addChildInternal(resource.getClass(), resource, path, 
replace);
+       }
+
+       /**
+        * Removes the child registered at the given composed path.
+        *
+        * <p>
+        * The removed child has {@link RestContext#destroy()} invoked (which 
runs {@code @RestDestroy} hooks and
+        * recursively destroys any grandchildren), followed by {@link 
Servlet#destroy()} if the underlying resource is
+        * a {@link Servlet}.
+        *
+        * @param path The composed path key (as returned by {@link 
RestContext#getPath()}) of the child to remove.
+        * @return The removed and destroyed {@link RestContext}, or {@code 
null} if no child was registered at the
+        *      given path.
+        */
+       public RestContext removeChild(String path) {
+               RestContext removed;
+               synchronized (writeLock) {
+                       removed = children.get(path);
+                       if (removed == null)
+                               return null;
+                       var next = new LinkedHashMap<>(children);
+                       next.remove(path);
+                       children = Collections.unmodifiableMap(next);
+               }
+               destroyChild(removed);
+               return removed;
+       }
+
+       /**
+        * Removes the first child whose resource class matches the given type.
+        *
+        * @param resourceClass The resource class to match. Matching is 
performed via {@link RestContext#getResourceClass()}.
+        * @return The removed and destroyed {@link RestContext}, or {@code 
null} if no matching child was found.
+        */
+       public RestContext removeChild(Class<?> resourceClass) {
+               String key = null;
+               RestContext removed = null;
+               synchronized (writeLock) {
+                       for (var e : children.entrySet()) {
+                               if (e.getValue().getResourceClass() == 
resourceClass) {
+                                       key = e.getKey();
+                                       removed = e.getValue();
+                                       break;
+                               }
+                       }
+                       if (removed == null)
+                               return null;
+                       var next = new LinkedHashMap<>(children);
+                       next.remove(key);
+                       children = Collections.unmodifiableMap(next);
+               }
+               destroyChild(removed);
+               return removed;
+       }
+
+       
//-------------------------------------------------------------------------------------------------------------
+       // Internal helpers.
+       
//-------------------------------------------------------------------------------------------------------------
+
+       /**
+        * Shared child-context construction recipe — used both by the eager 
init memoizer in {@link RestContext} and by
+        * the runtime {@link #addChild(Class) addChild} family of methods.
+        *
+        * <p>
+        * Resolves a {@link Supplier} for the resource (prefers an existing 
bean of the same class in the bean store,
+        * otherwise instantiates via {@link BeanInstantiator}), constructs the 
child {@link RestContext} using the
+        * supplied {@code parent} / {@code servletConfig} / {@code 
pathOverride}, and invokes any
+        * {@code setContext(RestContext)} method on the resource via 
reflection (so that {@code RestServlet}-style
+        * resources receive their context handle).
+        *
+        * @param parent The parent {@link RestContext}. Must not be {@code 
null}.
+        * @param beanStore The bean store to resolve instances and 
dependencies from. Must not be {@code null}.
+        * @param servletConfig The {@link ServletConfig} to pass through to 
the child. May be {@code null}.
+        * @param resourceClass The resource class. Must not be {@code null}.
+        * @param resourceInstance A pre-supplied instance of the resource. If 
{@code null}, the instance is resolved
+        *      from the bean store or freshly instantiated via {@link 
BeanInstantiator}.
+        * @param pathOverride An explicit path segment (relative to the 
parent). Use {@code ""} to read the path from
+        *      {@link Rest#path() @Rest(path)} on the resource class.
+        * @return The newly-built child {@link RestContext}, with its {@code 
setContext} method (if any) invoked.
+        * @throws Exception If construction or reflective wiring fails.
+        */
+       static RestContext buildChildContext(
+                       RestContext parent,
+                       BeanStore beanStore,
+                       ServletConfig servletConfig,
+                       Class<?> resourceClass,
+                       Object resourceInstance,
+                       String pathOverride) throws Exception {
+               Supplier<?> so;
+               if (resourceInstance != null) {
+                       final Object r = resourceInstance;
+                       so = () -> r;
+               } else if (beanStore.getBean(resourceClass).isPresent()) {
+                       so = () -> beanStore.getBean(resourceClass).get();
+               } else {
+                       Object o = BeanInstantiator.of(resourceClass, 
beanStore).run();
+                       so = () -> o;
+               }
+               var cc = new RestContext(new RestContext.Args(resourceClass, 
parent, servletConfig, so, pathOverride, null));
+               var mi = ClassInfo.of(so.get())
+                       .getMethod(x -> x.hasName("setContext") && 
x.hasParameterTypes(RestContext.class))
+                       .orElse(null);
+               if (nn(mi))
+                       mi.accessible().invoke(so.get(), cc);
+               return cc;
+       }
+
+       private RestContext addChildInternal(Class<?> resourceClass, Object 
resourceInstance, String pathOverride, boolean replace) throws ServletException 
{
+               if (parent == null || beanStore == null)
+                       throw illegalState("Cannot add a child to a 
RestChildren that was not initialized with a parent RestContext.");
+               RestContext cc;
+               try {
+                       cc = buildChildContext(parent, beanStore, 
servletConfig, resourceClass, resourceInstance, pathOverride);
+               } catch (Exception e) {
+                       throw new ServletException("Failed to build child REST 
context for " + resourceClass.getName(), unwrapThrowable(e));
+               }
+               var key = cc.getPath();
+               RestContext replaced = null;
+               synchronized (writeLock) {
+                       if (children.containsKey(key)) {
+                               if (! replace) {
+                                       destroyQuietly(cc);
+                                       throw illegalState("Child resource 
already registered at path ''{0}''.", key);
+                               }
+                               replaced = children.get(key);
+                               var withoutExisting = new 
LinkedHashMap<>(children);
+                               withoutExisting.remove(key);
+                               // We don't publish the intermediate "without 
existing" snapshot — single atomic swap below.
+                               withoutExisting.put(key, cc);
+                               children = 
Collections.unmodifiableMap(withoutExisting);
+                       } else {
+                               var next = new LinkedHashMap<>(children);
+                               next.put(key, cc);
+                               children = Collections.unmodifiableMap(next);
+                       }
+               }
+               if (replaced != null)
+                       destroyChild(replaced);
+               cc.postInit();
+               cc.postInitChildFirst();
+               return cc;
+       }
+
+       private static void destroyChild(RestContext child) {
+               // Servlet-backed resources delegate Servlet.destroy() through 
to RestContext.destroy() (see RestServlet.destroy),
+               // so we must avoid double-destruction in that case — call 
Servlet.destroy() and let it transitively call
+               // RestContext.destroy() exactly once.
+               if (child.getResource() instanceof Servlet s)
+                       s.destroy();
+               else
+                       child.destroy();
+       }
+
+       private static void destroyQuietly(RestContext child) {
+               try {
+                       destroyChild(child);
+               } catch (Exception ignored) {
+                       // best-effort cleanup of a child we're about to throw 
away
+               }
+       }
+
+       private static Throwable unwrapThrowable(Throwable t) {
+               if (t instanceof InvocationTargetException t2)
+                       return t2.getTargetException();
+               return t;
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
index 740f63647b..61f9406fdf 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
@@ -1115,7 +1115,7 @@ public class RestContext extends Context {
        private final Memoizer<RestChildren> restChildren = memoizer(() -> 
safe(() -> {
                var bs = beanStore();
                var servletConfig = 
bs.getBean(ServletConfig.class).orElse(null);
-               var b = RestChildren.create(bs);
+               var b = RestChildren.create(this, bs, servletConfig);
 
                // Collect child classes from @Rest(children) on the annotation 
chain (parent-to-child order).
                // Deduplicate so the same child class registered on both a 
parent and child annotation
@@ -1126,18 +1126,7 @@ public class RestContext extends Context {
                for (var rc2 : seen) {
                        if (rc2 == resourceClass())
                                continue;  // Guard against self-reference 
infinite loop.
-                       Supplier<?> so;
-                       if (bs.getBean(rc2).isPresent()) {
-                               so = () -> bs.getBean(rc2).get();
-                       } else {
-                               Object o2 = BeanInstantiator.of(rc2, bs).run();
-                               so = () -> o2;
-                       }
-                       var cc = new RestContext(new Args(rc2, this, 
servletConfig, so, "", null));
-                       var mi = ClassInfo.of(so.get()).getMethod(x -> 
x.hasName("setContext") && x.hasParameterTypes(RestContext.class)).orElse(null);
-                       if (nn(mi))
-                               mi.accessible().invoke(so.get(), cc);
-                       b.add(cc);
+                       b.add(RestChildren.buildChildContext(this, bs, 
servletConfig, rc2, null, ""));
                }
 
                // @Bean override — allows replacing the entire RestChildren 
instance.
@@ -1318,14 +1307,21 @@ public class RestContext extends Context {
 
                        // --- end beanStore setup ---
 
-                       // Path is read directly from the @Rest(path) 
annotation chain (most-derived class wins),
-                       // replacing the prior Builder.path staging field 
eliminated in the May 2026 refactor.
-                       path = getRestAnnotations().stream()
-                               .map(ai -> ai.inner().path())
-                               .filter(StringUtils::isNotEmpty)
-                               .findFirst()
-                               .map(s -> trimLeadingSlashes(s))
-                               .orElse("");
+                       // Path resolution: explicit Args.path (non-empty) wins 
so callers like
+                       // RestChildren.addChild(String,Object) can mount the 
same class at a custom path; otherwise read
+                       // from the @Rest(path) annotation chain (most-derived 
class wins). This restores override behavior
+                       // removed in the May 2026 Builder.path elimination, 
without resurrecting the staging field.
+                       var argsPath = builder.args.path();
+                       if (argsPath != null && ! argsPath.isEmpty()) {
+                               path = trimLeadingSlashes(argsPath);
+                       } else {
+                               path = getRestAnnotations().stream()
+                                       .map(ai -> ai.inner().path())
+                                       .filter(StringUtils::isNotEmpty)
+                                       .findFirst()
+                                       .map(s -> trimLeadingSlashes(s))
+                                       .orElse("");
+                       }
                        fullPath = (parentContext == null ? "" : 
(parentContext.fullPath + '/')) + path;
                        var p = path;
                        if (! p.endsWith("/*"))
@@ -1754,6 +1750,11 @@ public class RestContext extends Context {
         * Called during servlet destruction to invoke all {@link RestDestroy} 
methods.
         */
        public void destroy() {
+               // @RestDestroy hooks first, then children (recursive cleanup), 
then close this context's bean store.
+               // Destroying children before closing the bean store is 
required: getRestChildren() reads from the bean
+               // store and BasicBeanStore.getBean() rejects calls after 
close(), so the previous post-close lookup was
+               // a latent bug that only surfaced when destroy() was invoked 
outside the JVM-shutdown happy path
+               // (e.g. dynamic RestChildren.removeChild — see TODO-33).
                for (var x : destroyInvokerPair.get().invokers) {
                        try {
                                x.invoke(beanStore, getResource());
@@ -1761,13 +1762,14 @@ public class RestContext extends Context {
                                getLogger().log(Level.WARNING, unwrap(e), () -> 
f("Error occurred invoking servlet-destroy method ''{0}''.", x.getFullName()));
                        }
                }
+               var childrenRef = getRestChildren();
+               if (nn(childrenRef))
+                       childrenRef.destroy();
                try {
                        beanStore.close();
                } catch (Exception e) {
                        getLogger().log(Level.WARNING, unwrap(e), () -> "Error 
occurred closing bean store.");
                }
-
-               getRestChildren().destroy();
        }
 
        /**
@@ -2520,7 +2522,10 @@ public class RestContext extends Context {
                if (initialized.get())
                        return this;
                var resource2 = getResource();
-               var mi = ClassInfo.of(getResource()).getPublicMethod(x -> 
x.hasName("setContext") && x.hasParameterTypes(RestContext.class)).orElse(null);
+               // Use getMethod (not getPublicMethod) to match the child-init 
memoizer at RestContext.restChildren — covers
+               // the protected `setContext` declared on `RestServlet` / 
`RestObject` so external callers (e.g. MockRestClient,
+               // the @RestInit / lifecycle wiring) can hand the resource its 
RestContext post-construction.
+               var mi = ClassInfo.of(getResource()).getMethod(x -> 
x.hasName("setContext") && x.hasParameterTypes(RestContext.class)).orElse(null);
                if (nn(mi)) {
                        try {
                                mi.accessible().invoke(resource2, this);
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/DefaultArg.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/DefaultArg.java
index 7c8e20daf3..abb993f22c 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/DefaultArg.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/DefaultArg.java
@@ -70,6 +70,9 @@ public class DefaultArg implements RestOpArg {
        }
 
        @Override /* Overridden from RestOpArg */
+       @SuppressWarnings({
+               "resource" // Bean values may be AutoCloseable but lifecycle is 
owned by the container, not this resolver.
+       })
        public Object resolve(RestOpSession opSession) throws Exception {
                return opSession.getBeanStore().getBean(type, 
qualifier).orElseThrow(() -> new ArgException(paramInfo, "Could not resolve 
bean type {0}", cn(type)));
        }
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestObjectGroup.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestObjectGroup.java
index a57783d94b..f6c394a9c8 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestObjectGroup.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestObjectGroup.java
@@ -21,6 +21,7 @@ import org.apache.juneau.rest.annotation.*;
 import org.apache.juneau.rest.beans.*;
 import org.apache.juneau.rest.config.*;
 
+import jakarta.servlet.*;
 import jakarta.servlet.http.*;
 
 /**
@@ -39,6 +40,14 @@ import jakarta.servlet.http.*;
  * <p>
  * Children are attached to this resource using the {@link Rest#children() 
@Rest(children)} annotation.
  *
+ * <h5 class='section'>Dynamic children:</h5>
+ *
+ * <p>
+ * Additional child resources can be registered or unregistered at runtime via 
{@link #addChild(Class)} /
+ * {@link #removeChild(String)} (and overloads). This is particularly useful 
in test fixtures where a single
+ * group resource is mounted once and individual tests plug in their own 
{@code @Rest} resources without restarting
+ * the container.
+ *
  * <h5 class='section'>See Also:</h5><ul>
  *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/RestAnnotatedClassBasics";>@Rest-Annotated
 Class Basics</a>
  * </ul>
@@ -50,4 +59,86 @@ public abstract class BasicRestObjectGroup extends 
BasicRestObject implements Ba
        public ChildResourceDescriptions getChildren(RestRequest req) {
                return ChildResourceDescriptions.of(req);
        }
-}
\ No newline at end of file
+
+       /**
+        * Returns the {@link RestChildren} registry backing this group 
resource.
+        *
+        * <p>
+        * Use this for full access to the dynamic add/remove API, including 
the {@code replace} overloads and the
+        * unmodifiable {@link RestChildren#asMap() asMap()} snapshot.
+        *
+        * @return The {@link RestChildren} registry for this resource's {@link 
RestContext}.
+        */
+       public RestChildren getChildResources() {
+               return getContext().getRestChildren();
+       }
+
+       /**
+        * Dynamically registers a child REST resource by class.
+        *
+        * <p>
+        * Convenience pass-through to {@link RestChildren#addChild(Class)} on 
{@link #getChildResources()}.
+        *
+        * @param resourceClass The {@code @Rest}-annotated resource class.
+        * @return The newly-built child {@link RestContext}.
+        * @throws ServletException If construction or lifecycle initialization 
of the child fails.
+        */
+       public RestContext addChild(Class<?> resourceClass) throws 
ServletException {
+               return getChildResources().addChild(resourceClass);
+       }
+
+       /**
+        * Dynamically registers a pre-instantiated child REST resource.
+        *
+        * <p>
+        * Convenience pass-through to {@link RestChildren#addChild(Object)} on 
{@link #getChildResources()}.
+        *
+        * @param resource The {@code @Rest}-annotated resource instance.
+        * @return The newly-built child {@link RestContext}.
+        * @throws ServletException If construction or lifecycle initialization 
of the child fails.
+        */
+       public RestContext addChild(Object resource) throws ServletException {
+               return getChildResources().addChild(resource);
+       }
+
+       /**
+        * Dynamically registers a pre-instantiated child REST resource at an 
explicit path.
+        *
+        * <p>
+        * Convenience pass-through to {@link RestChildren#addChild(String, 
Object)} on {@link #getChildResources()}.
+        *
+        * @param path The path segment under this group at which to mount the 
child.
+        * @param resource The {@code @Rest}-annotated resource instance.
+        * @return The newly-built child {@link RestContext}.
+        * @throws ServletException If construction or lifecycle initialization 
of the child fails.
+        */
+       public RestContext addChild(String path, Object resource) throws 
ServletException {
+               return getChildResources().addChild(path, resource);
+       }
+
+       /**
+        * Removes the child registered at the given composed path.
+        *
+        * <p>
+        * Convenience pass-through to {@link RestChildren#removeChild(String)} 
on {@link #getChildResources()}.
+        *
+        * @param path The composed path key (as returned by {@link 
RestContext#getPath()}).
+        * @return The removed and destroyed {@link RestContext}, or {@code 
null} if no child was registered there.
+        */
+       public RestContext removeChild(String path) {
+               return getChildResources().removeChild(path);
+       }
+
+       /**
+        * Removes the first child whose resource class matches the given type.
+        *
+        * <p>
+        * Convenience pass-through to {@link RestChildren#removeChild(Class)} 
on {@link #getChildResources()}.
+        *
+        * @param resourceClass The resource class to match.
+        * @return The removed and destroyed {@link RestContext}, or {@code 
null} if no matching child was found.
+        */
+       public RestContext removeChild(Class<?> resourceClass) {
+               return getChildResources().removeChild(resourceClass);
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestServletGroup.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestServletGroup.java
index f1f83da556..f41c412ea3 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestServletGroup.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestServletGroup.java
@@ -21,6 +21,8 @@ import org.apache.juneau.rest.annotation.*;
 import org.apache.juneau.rest.beans.*;
 import org.apache.juneau.rest.config.*;
 
+import jakarta.servlet.*;
+
 /**
  * Specialized subclass of {@link BasicRestServlet} for showing "group" pages.
  *
@@ -37,6 +39,14 @@ import org.apache.juneau.rest.config.*;
  * <p>
  * Children are attached to this resource using the {@link Rest#children() 
@Rest(children)} annotation.
  *
+ * <h5 class='section'>Dynamic children:</h5>
+ *
+ * <p>
+ * Additional child resources can be registered or unregistered at runtime via 
{@link #addChild(Class)} /
+ * {@link #removeChild(String)} (and overloads). This is particularly useful 
in test fixtures where a single
+ * group servlet is mounted once and individual tests plug in their own {@code 
@Rest} resources without restarting
+ * the servlet container.
+ *
  * <h5 class='section'>See Also:</h5><ul>
  *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/RestAnnotatedClassBasics";>@Rest-Annotated
 Class Basics</a>
  * </ul>
@@ -51,4 +61,86 @@ public abstract class BasicRestServletGroup extends 
BasicRestServlet implements
        public ChildResourceDescriptions getChildren(RestRequest req) {
                return ChildResourceDescriptions.of(req);
        }
-}
\ No newline at end of file
+
+       /**
+        * Returns the {@link RestChildren} registry backing this group 
resource.
+        *
+        * <p>
+        * Use this for full access to the dynamic add/remove API, including 
the {@code replace} overloads and the
+        * unmodifiable {@link RestChildren#asMap() asMap()} snapshot.
+        *
+        * @return The {@link RestChildren} registry for this resource's {@link 
RestContext}.
+        */
+       public RestChildren getChildResources() {
+               return getContext().getRestChildren();
+       }
+
+       /**
+        * Dynamically registers a child REST resource by class.
+        *
+        * <p>
+        * Convenience pass-through to {@link RestChildren#addChild(Class)} on 
{@link #getChildResources()}.
+        *
+        * @param resourceClass The {@code @Rest}-annotated resource class.
+        * @return The newly-built child {@link RestContext}.
+        * @throws ServletException If construction or lifecycle initialization 
of the child fails.
+        */
+       public RestContext addChild(Class<?> resourceClass) throws 
ServletException {
+               return getChildResources().addChild(resourceClass);
+       }
+
+       /**
+        * Dynamically registers a pre-instantiated child REST resource.
+        *
+        * <p>
+        * Convenience pass-through to {@link RestChildren#addChild(Object)} on 
{@link #getChildResources()}.
+        *
+        * @param resource The {@code @Rest}-annotated resource instance.
+        * @return The newly-built child {@link RestContext}.
+        * @throws ServletException If construction or lifecycle initialization 
of the child fails.
+        */
+       public RestContext addChild(Object resource) throws ServletException {
+               return getChildResources().addChild(resource);
+       }
+
+       /**
+        * Dynamically registers a pre-instantiated child REST resource at an 
explicit path.
+        *
+        * <p>
+        * Convenience pass-through to {@link RestChildren#addChild(String, 
Object)} on {@link #getChildResources()}.
+        *
+        * @param path The path segment under this group at which to mount the 
child.
+        * @param resource The {@code @Rest}-annotated resource instance.
+        * @return The newly-built child {@link RestContext}.
+        * @throws ServletException If construction or lifecycle initialization 
of the child fails.
+        */
+       public RestContext addChild(String path, Object resource) throws 
ServletException {
+               return getChildResources().addChild(path, resource);
+       }
+
+       /**
+        * Removes the child registered at the given composed path.
+        *
+        * <p>
+        * Convenience pass-through to {@link RestChildren#removeChild(String)} 
on {@link #getChildResources()}.
+        *
+        * @param path The composed path key (as returned by {@link 
RestContext#getPath()}).
+        * @return The removed and destroyed {@link RestContext}, or {@code 
null} if no child was registered there.
+        */
+       public RestContext removeChild(String path) {
+               return getChildResources().removeChild(path);
+       }
+
+       /**
+        * Removes the first child whose resource class matches the given type.
+        *
+        * <p>
+        * Convenience pass-through to {@link RestChildren#removeChild(Class)} 
on {@link #getChildResources()}.
+        *
+        * @param resourceClass The resource class to match.
+        * @return The removed and destroyed {@link RestContext}, or {@code 
null} if no matching child was found.
+        */
+       public RestContext removeChild(Class<?> resourceClass) {
+               return getChildResources().removeChild(resourceClass);
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Inject_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Inject_Test.java
new file mode 100644
index 0000000000..14e8569555
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Inject_Test.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.juneau.microservice;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.commons.runtime.*;
+import org.apache.juneau.microservice.console.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for the inject-aware {@link Microservice} bootstrap.
+ *
+ * <p>
+ * Verifies that <c>@Configuration</c> classes registered via
+ * {@link Microservice.Builder#configurations(Class...)} are processed at 
construction time,
+ * that <c>@Bean</c>-supplied values feed into field resolution when no 
builder value is present,
+ * that explicit builder calls always win, and that the bean store is closed on
+ * {@link Microservice#stop()} so <c>@PreDestroy</c> hooks fire.
+ */
+class Microservice_Inject_Test extends TestBase {
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // A.  Bean store presence and self-registration.
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void a01_beanStore_neverNull() throws Exception {
+               var ms = Microservice.create().build();
+               try {
+                       assertNotNull(ms.getBeanStore());
+               } finally {
+                       ms.stop();
+               }
+       }
+
+       @Test void a02_beanStore_containsMicroserviceItself() throws Exception {
+               var ms = Microservice.create().build();
+               try {
+                       assertSame(ms, 
ms.getBeanStore().getBean(Microservice.class).orElse(null));
+               } finally {
+                       ms.stop();
+               }
+       }
+
+       @Test void a03_beanStore_containsResolvedArgs() throws Exception {
+               var args = new Args(new String[]{"--port", "8080"});
+               var ms = Microservice.create().args(args).build();
+               try {
+                       assertSame(args, 
ms.getBeanStore().getBean(Args.class).orElse(null));
+               } finally {
+                       ms.stop();
+               }
+       }
+
+       @Test void a04_beanStore_isFreshWhenNotSupplied() throws Exception {
+               var ms1 = Microservice.create().build();
+               var ms2 = Microservice.create().build();
+               try {
+                       assertNotSame(ms1.getBeanStore(), ms2.getBeanStore());
+               } finally {
+                       ms1.stop();
+                       ms2.stop();
+               }
+       }
+
+       @Test void a05_beanStore_externalStoreUsed() throws Exception {
+               WritableBeanStore external = new BasicBeanStore();
+               var ms = Microservice.create().beanStore(external).build();
+               try {
+                       assertSame(external, ms.getBeanStore());
+               } finally {
+                       ms.stop();
+               }
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // B.  @Configuration class registration via builder.
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Configuration
+       static class B_SimpleConfig {
+               @Bean MyService myService() { return new 
MyService("from-config"); }
+       }
+
+       static class MyService {
+               final String tag;
+               MyService(String tag) { this.tag = tag; }
+       }
+
+       @Test void b01_configuration_beanIsRegistered() throws Exception {
+               var ms = 
Microservice.create().configurations(B_SimpleConfig.class).build();
+               try {
+                       var svc = 
ms.getBeanStore().getBean(MyService.class).orElse(null);
+                       assertNotNull(svc);
+                       assertEquals("from-config", svc.tag);
+               } finally {
+                       ms.stop();
+               }
+       }
+
+       @Test void b02_configurations_noneRegistered_emptyStore() throws 
Exception {
+               var ms = Microservice.create().build();
+               try {
+                       
assertFalse(ms.getBeanStore().getBean(MyService.class).isPresent());
+               } finally {
+                       ms.stop();
+               }
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // C.  Resolution priority: builder > @Bean > default.
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Configuration
+       static class C_ArgsConfig {
+               @Bean Args args() { return new Args(new String[]{"--from", 
"config"}); }
+       }
+
+       @Test void c01_builderArgs_beatsConfigurationArgs() throws Exception {
+               var builderArgs = new Args(new String[]{"--from", "builder"});
+               var ms = Microservice.create()
+                       .args(builderArgs)
+                       .configurations(C_ArgsConfig.class)
+                       .build();
+               try {
+                       assertSame(builderArgs, ms.getArgs());
+                       assertSame(builderArgs, 
ms.getBeanStore().getBean(Args.class).orElse(null));
+               } finally {
+                       ms.stop();
+               }
+       }
+
+       @Test void c02_configurationArgs_usedWhenNoBuilderArgs() throws 
Exception {
+               var ms = Microservice.create()
+                       .configurations(C_ArgsConfig.class)
+                       .build();
+               try {
+                       assertEquals("config", 
ms.getArgs().get("from").orElse(null));
+               } finally {
+                       ms.stop();
+               }
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // D.  Console commands contributed via @Bean.
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Configuration
+       static class D_CommandConfig {
+               @Bean ConsoleCommand customCommand() {
+                       return new ConsoleCommand() {
+                               @Override public String getName() { return 
"custom"; }
+                               @Override public String getInfo() { return 
"Custom command for tests."; }
+                               @Override public boolean 
execute(java.util.Scanner in, java.io.PrintWriter out, Args a) { return false; }
+                       };
+               }
+       }
+
+       @Test void d01_configurationProvidedConsoleCommand_isPickedUp() throws 
Exception {
+               // Console commands are only wired when consoleEnabled=true.
+               var ms = Microservice.create()
+                       .consoleEnabled(true)
+                       .configurations(D_CommandConfig.class)
+                       .build();
+               try {
+                       
assertTrue(ms.getConsoleCommands().containsKey("custom"));
+               } finally {
+                       ms.stop();
+               }
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // E.  Lifecycle hooks: @PostConstruct + @PreDestroy.
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       static final AtomicInteger POST_CONSTRUCT_COUNT = new AtomicInteger();
+       static final AtomicInteger PRE_DESTROY_COUNT = new AtomicInteger();
+
+       public static class LifecycleBean {
+               @PostConstruct public void onCreate() { 
POST_CONSTRUCT_COUNT.incrementAndGet(); }
+               @PreDestroy   public void onDestroy() { 
PRE_DESTROY_COUNT.incrementAndGet(); }
+       }
+
+       @Configuration
+       static class E_LifecycleConfig {
+               @Bean LifecycleBean lifecycleBean() { return new 
LifecycleBean(); }
+       }
+
+       @Test void e01_lifecycle_postConstructAndPreDestroy_fire() throws 
Exception {
+               POST_CONSTRUCT_COUNT.set(0);
+               PRE_DESTROY_COUNT.set(0);
+               var ms = 
Microservice.create().configurations(E_LifecycleConfig.class).build();
+               // @PostConstruct does NOT fire for beans returned from @Bean 
methods unless they pass through
+               // BeanInstantiator (which @Bean returns don't).  This test 
pins the documented behavior:
+               // @PreDestroy fires when the bean has been resolved + the 
store is closed.
+               var bean = 
ms.getBeanStore().getBean(LifecycleBean.class).orElse(null);
+               assertNotNull(bean);
+               ms.stop();
+               assertEquals(1, PRE_DESTROY_COUNT.get(), "@PreDestroy should 
fire once after stop()");
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // F.  Back-compat: existing builder-only path still works.
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void f01_legacyBuilderPath_stillWorks() throws Exception {
+               var ms = Microservice.create()
+                       .args("--from", "builder")
+                       .build();
+               try {
+                       assertEquals("builder", 
ms.getArgs().get("from").orElse(null));
+                       assertNotNull(ms.getBeanStore()); // store is now 
always present, but legacy path stays functional
+               } finally {
+                       ms.stop();
+               }
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/JettyMicroservice_Inject_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/JettyMicroservice_Inject_Test.java
new file mode 100644
index 0000000000..5f3fcfe947
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/JettyMicroservice_Inject_Test.java
@@ -0,0 +1,252 @@
+/*
+ * 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.juneau.microservice.jetty;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.servlet.*;
+import org.eclipse.jetty.ee11.servlet.*;
+import org.eclipse.jetty.server.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.servlet.*;
+import jakarta.servlet.http.*;
+
+/**
+ * Tests for the inject-aware {@link JettyMicroservice} bootstrap.
+ *
+ * <p>
+ * Verifies that <c>@Configuration</c>-supplied <c>JettyServerFactory</c> /
+ * <c>JettyMicroserviceListener</c> / <c>Server</c> / <c>Servlet</c> beans are 
picked up,
+ * that explicit builder calls take precedence, that auto-discovered 
<c>@Rest</c> servlets
+ * are mounted at <c>@Rest(path=...)</c>, and that duplicate mount paths fail 
fast.
+ */
+class JettyMicroservice_Inject_Test extends TestBase {
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // A.  Bean store presence + self-registration of Jetty-specific beans.
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Test void a01_beanStore_containsJettyMicroservice() throws Exception {
+               var ms = JettyMicroservice.create().build();
+               try {
+                       assertSame(ms, 
ms.getBeanStore().getBean(JettyMicroservice.class).orElse(null));
+               } finally {
+                       ms.stop();
+               }
+       }
+
+       @Test void a02_beanStore_containsJettyListener() throws Exception {
+               var ms = JettyMicroservice.create().build();
+               try {
+                       
assertNotNull(ms.getBeanStore().getBean(JettyMicroserviceListener.class).orElse(null));
+               } finally {
+                       ms.stop();
+               }
+       }
+
+       @Test void a03_beanStore_containsJettyServerFactory() throws Exception {
+               var ms = JettyMicroservice.create().build();
+               try {
+                       
assertNotNull(ms.getBeanStore().getBean(JettyServerFactory.class).orElse(null));
+               } finally {
+                       ms.stop();
+               }
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // B.  Resolution priority: explicit builder > @Bean > default.
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       static class TestFactory extends BasicJettyServerFactory {
+               // Marker for identity checks; behavior inherited from 
BasicJettyServerFactory.
+       }
+
+       @Configuration
+       static class B_FactoryConfig {
+               @Bean JettyServerFactory factory() { return new TestFactory(); }
+       }
+
+       @Test void b01_explicitFactory_beatsConfigurationFactory() throws 
Exception {
+               var explicit = new BasicJettyServerFactory();
+               var ms = JettyMicroservice.create()
+                       .jettyServerFactory(explicit)
+                       .configurations(B_FactoryConfig.class)
+                       .build();
+               try {
+                       assertSame(explicit, 
ms.getBeanStore().getBean(JettyServerFactory.class).orElse(null));
+               } finally {
+                       ms.stop();
+               }
+       }
+
+       @Test void b02_configurationFactory_usedWhenNoBuilderFactory() throws 
Exception {
+               var ms = JettyMicroservice.create()
+                       .configurations(B_FactoryConfig.class)
+                       .build();
+               try {
+                       assertInstanceOf(TestFactory.class, 
ms.getBeanStore().getBean(JettyServerFactory.class).orElse(null));
+               } finally {
+                       ms.stop();
+               }
+       }
+
+       @Configuration
+       static class B_ListenerConfig {
+               @Bean JettyMicroserviceListener listener() { return new 
BasicJettyMicroserviceListener() {}; }
+       }
+
+       @Test void b03_configurationListener_usedWhenNoBuilderListener() throws 
Exception {
+               var ms = JettyMicroservice.create()
+                       .configurations(B_ListenerConfig.class)
+                       .build();
+               try {
+                       // The contributed listener bean should be the resolved 
one.
+                       var configured = 
ms.getBeanStore().getBeansOfType(JettyMicroserviceListener.class).values().iterator().next();
+                       assertNotNull(configured);
+                       assertNotSame(BasicJettyMicroserviceListener.class, 
configured.getClass()); // it's the anonymous subclass
+               } finally {
+                       ms.stop();
+               }
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // C.  End-to-end: @Bean Server + @Bean Servlet auto-mounted at 
@Rest(path=...).
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Rest(path = "/api")
+       public static class ApiServlet extends RestServlet {
+               private static final long serialVersionUID = 1L;
+       }
+
+       public static class PlainServlet extends HttpServlet {
+               private static final long serialVersionUID = 1L;
+       }
+
+       @Configuration
+       static class C_AutoMountConfig {
+               @Bean Server jettyServer() {
+                       var server = new Server();
+                       var ctx = new ServletContextHandler();
+                       ctx.setContextPath("/");
+                       server.setAttribute("ServletContextHandler", ctx);
+                       server.setHandler(ctx);
+                       return server;
+               }
+               // Declare return type as Servlet so the bean is registered 
under Servlet.class
+               // (BeanStore.getBeansOfType is exact-type, so subtype lookup 
needs the supertype declaration).
+               @Bean Servlet apiServlet() { return new ApiServlet(); }
+       }
+
+       @Test void c01_restServletAutoMounted_atRestPath() throws Exception {
+               var ms = JettyMicroservice.create()
+                       .configurations(C_AutoMountConfig.class)
+                       .build();
+               try {
+                       ms.createServer();
+                       var ctx = ms.getServletContextHandler();
+                       var mounted = false;
+                       for (var h : 
ctx.getServletHandler().getServletMappings()) {
+                               for (var p : h.getPathSpecs()) {
+                                       if ("/api/*".equals(p)) {
+                                               mounted = true;
+                                               break;
+                                       }
+                               }
+                       }
+                       assertTrue(mounted, "@Rest servlet should be 
auto-mounted at /api/*");
+               } finally {
+                       ms.stop();
+               }
+       }
+
+       @Configuration
+       static class C_NoRestServletConfig {
+               @Bean Server jettyServer() {
+                       var server = new Server();
+                       var ctx = new ServletContextHandler();
+                       ctx.setContextPath("/");
+                       server.setAttribute("ServletContextHandler", ctx);
+                       server.setHandler(ctx);
+                       return server;
+               }
+               @Bean Servlet plainServlet() { return new PlainServlet(); }
+       }
+
+       @Test void c02_servletWithoutRestAnnotation_isNotAutoMounted() throws 
Exception {
+               var ms = JettyMicroservice.create()
+                       .configurations(C_NoRestServletConfig.class)
+                       .build();
+               try {
+                       ms.createServer();
+                       var ctx = ms.getServletContextHandler();
+                       // PlainServlet has no @Rest, so it should NOT be 
auto-mounted.
+                       assertEquals(0, 
ctx.getServletHandler().getServletMappings().length,
+                               "plain Servlet without @Rest should not be 
auto-mounted");
+               } finally {
+                       ms.stop();
+               }
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // D.  Path collision is a hard startup failure.
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       @Configuration
+       static class D_CollisionConfig {
+               @Bean Server jettyServer() {
+                       var server = new Server();
+                       var ctx = new ServletContextHandler();
+                       ctx.setContextPath("/");
+                       server.setAttribute("ServletContextHandler", ctx);
+                       server.setHandler(ctx);
+                       return server;
+               }
+               @Bean Servlet apiServlet1() { return new ApiServlet(); }
+               @Bean(name = "apiServletDup") Servlet apiServlet2() { return 
new ApiServlet(); }
+       }
+
+       @Test void d01_pathCollision_failsHard() throws Exception {
+               var ms = JettyMicroservice.create()
+                       .configurations(D_CollisionConfig.class)
+                       .build();
+               try {
+                       var ex = assertThrows(Exception.class, 
ms::createServer);
+                       var root = ThrowableUtils.getCause(ex);
+                       assertTrue(root.getMessage().contains("Servlet mount 
path collision"),
+                               "expected path-collision error, got: " + 
root.getMessage());
+               } finally {
+                       ms.stop();
+               }
+       }
+
+       
//-----------------------------------------------------------------------------------------------------------------
+       // E.  Helper for unwrapping wrapped exceptions.
+       
//-----------------------------------------------------------------------------------------------------------------
+
+       static final class ThrowableUtils {
+               static Throwable getCause(Throwable t) {
+                       var cur = t;
+                       while (cur.getCause() != null && cur.getCause() != cur)
+                               cur = cur.getCause();
+                       return cur;
+               }
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/BasicRestServletGroup_DynamicChildren_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/BasicRestServletGroup_DynamicChildren_Test.java
new file mode 100644
index 0000000000..c0d74cf17c
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/BasicRestServletGroup_DynamicChildren_Test.java
@@ -0,0 +1,515 @@
+/*
+ * 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.juneau.rest;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for runtime add/remove of child REST resources via {@link 
BasicRestServletGroup#addChild} and friends, which
+ * delegate to {@link RestChildren}.
+ *
+ * <p>Each scenario uses a distinct parent class so the {@link 
MockRestClient}'s per-class
+ * {@link RestContext} cache does not bleed mutations across tests.
+ */
+class BasicRestServletGroup_DynamicChildren_Test extends TestBase {
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // Reusable child resources.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest(path = "/alpha")
+       public static class AlphaChild {
+               @RestGet(path = "/ping")
+               public String ping() {
+                       return "alpha-pong";
+               }
+       }
+
+       @Rest(path = "/beta")
+       public static class BetaChild {
+               @RestGet(path = "/ping")
+               public String ping() {
+                       return "beta-pong";
+               }
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // a01: addChild(Class<?>) — instantiates via bean store, mounts at 
@Rest(path).
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest(path = "/root")
+       public static class A_Parent extends BasicRestServletGroup {
+               private static final long serialVersionUID = 1L;
+       }
+
+       @Test
+       void a01_addChild_byClass() throws Exception {
+               var parent = new A_Parent();
+               var client = MockRestClient.createLax(parent).build();
+
+               client.get("/alpha/ping").run().assertStatus(404);
+
+               parent.addChild(AlphaChild.class);
+
+               
client.get("/alpha/ping").run().assertStatus(200).assertContent("alpha-pong");
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // a02: addChild(Object) — pre-instantiated resource.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest(path = "/root")
+       public static class A02_Parent extends BasicRestServletGroup {
+               private static final long serialVersionUID = 1L;
+       }
+
+       @Test
+       void a02_addChild_byInstance() throws Exception {
+               var parent = new A02_Parent();
+               var client = MockRestClient.build(parent);
+               var instance = new AlphaChild();
+
+               var ctx = parent.addChild(instance);
+
+               assertSame(instance, ctx.getResource());
+               client.get("/alpha/ping").run().assertContent("alpha-pong");
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // a03: addChild(String, Object) — explicit path override (mount same 
class at different path).
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest(path = "/root")
+       public static class A03_Parent extends BasicRestServletGroup {
+               private static final long serialVersionUID = 1L;
+       }
+
+       @Test
+       void a03_addChild_withPathOverride() throws Exception {
+               var parent = new A03_Parent();
+               var client = MockRestClient.build(parent);
+
+               parent.addChild(new AlphaChild());          // /alpha
+               parent.addChild("/gamma", new AlphaChild()); // /gamma 
(override of @Rest(path="/alpha"))
+
+               client.get("/alpha/ping").run().assertContent("alpha-pong");
+               client.get("/gamma/ping").run().assertContent("alpha-pong");
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // b01: removeChild(String) — 404 after removal; insertion order 
preserved for remaining children.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest(path = "/root")
+       public static class B_Parent extends BasicRestServletGroup {
+               private static final long serialVersionUID = 1L;
+       }
+
+       @Test
+       void b01_removeChild_byPath() throws Exception {
+               var parent = new B_Parent();
+               var client = MockRestClient.createLax(parent).build();
+
+               parent.addChild(AlphaChild.class);
+               parent.addChild(BetaChild.class);
+
+               client.get("/alpha/ping").run().assertStatus(200);
+               client.get("/beta/ping").run().assertStatus(200);
+
+               var removed = parent.removeChild("alpha");
+               assertNotNull(removed);
+               assertEquals("alpha", removed.getPath());
+
+               client.get("/alpha/ping").run().assertStatus(404);
+               client.get("/beta/ping").run().assertStatus(200);
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // b02: removeChild(Class<?>) — matches by resource class.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest(path = "/root")
+       public static class B02_Parent extends BasicRestServletGroup {
+               private static final long serialVersionUID = 1L;
+       }
+
+       @Test
+       void b02_removeChild_byClass() throws Exception {
+               var parent = new B02_Parent();
+               var client = MockRestClient.createLax(parent).build();
+
+               parent.addChild(AlphaChild.class);
+               parent.addChild(BetaChild.class);
+
+               var removed = parent.removeChild(BetaChild.class);
+               assertNotNull(removed);
+               assertEquals("beta", removed.getPath());
+
+               client.get("/alpha/ping").run().assertStatus(200);
+               client.get("/beta/ping").run().assertStatus(404);
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // b03: removeChild on non-existent path/class returns null.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest(path = "/root")
+       public static class B03_Parent extends BasicRestServletGroup {
+               private static final long serialVersionUID = 1L;
+       }
+
+       @Test
+       void b03_removeChild_missing_returnsNull() {
+               var parent = new B03_Parent();
+               MockRestClient.build(parent);
+
+               assertNull(parent.removeChild("doesnotexist"));
+               assertNull(parent.removeChild(AlphaChild.class));
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // c01: Duplicate-path add throws IllegalStateException; original child 
stays intact.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest(path = "/root")
+       public static class C_Parent extends BasicRestServletGroup {
+               private static final long serialVersionUID = 1L;
+       }
+
+       @Test
+       void c01_addChild_duplicatePath_throws() throws Exception {
+               var parent = new C_Parent();
+               var client = MockRestClient.build(parent);
+
+               parent.addChild(AlphaChild.class);
+               assertThrows(IllegalStateException.class, () -> 
parent.addChild(AlphaChild.class));
+
+               // Original child still routes correctly.
+               client.get("/alpha/ping").run().assertContent("alpha-pong");
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // c02: addChild(Object, replace=true) — evicts existing child at the 
same path.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest(path = "/root")
+       public static class C02_Parent extends BasicRestServletGroup {
+               private static final long serialVersionUID = 1L;
+       }
+
+       @Test
+       void c02_addChild_replace_evictsExisting() throws Exception {
+               var parent = new C02_Parent();
+               var client = MockRestClient.build(parent);
+               var rc = parent.getChildResources();
+
+               var original = new AlphaChild();
+               rc.addChild(original);
+
+               var replacement = new AlphaChild();
+               var newCtx = rc.addChild(replacement, true);
+
+               assertSame(replacement, newCtx.getResource());
+               assertNotSame(original, newCtx.getResource());
+               client.get("/alpha/ping").run().assertContent("alpha-pong");
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // d01: Insertion order preserved in asMap() snapshot.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest(path = "/root")
+       public static class D_Parent extends BasicRestServletGroup {
+               private static final long serialVersionUID = 1L;
+       }
+
+       @Test
+       void d01_insertionOrderPreserved() throws Exception {
+               var parent = new D_Parent();
+               MockRestClient.build(parent);
+
+               parent.addChild(BetaChild.class);
+               parent.addChild(AlphaChild.class);
+
+               var keys = new 
ArrayList<>(parent.getChildResources().asMap().keySet());
+               assertEquals(List.of("beta", "alpha"), keys);
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // e01: @RestInit / @RestPostInit fire on dynamically added child.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest(path = "/root")
+       public static class E_Parent extends BasicRestServletGroup {
+               private static final long serialVersionUID = 1L;
+       }
+
+       @Rest(path = "/lifecycle")
+       public static class LifecycleChild {
+               public final List<String> events = 
Collections.synchronizedList(new ArrayList<>());
+
+               @RestInit
+               public void init() {
+                       events.add("init");
+               }
+
+               @RestPostInit
+               public void postInit() {
+                       events.add("postInit");
+               }
+
+               @RestPostInit(childFirst = true)
+               public void postInitChildFirst() {
+                       events.add("postInitChildFirst");
+               }
+
+               @RestGet(path = "/events")
+               public List<String> events() {
+                       return events;
+               }
+       }
+
+       @Test
+       void e01_lifecycleHooks_fireOnDynamicAdd() throws Exception {
+               var parent = new E_Parent();
+               MockRestClient.build(parent);
+
+               var child = new LifecycleChild();
+               parent.addChild(child);
+
+               // @RestInit fires during construction; @RestPostInit hooks 
fire from addChild's postInit / postInitChildFirst calls.
+               assertTrue(child.events.contains("init"));
+               assertTrue(child.events.contains("postInit"));
+               assertTrue(child.events.contains("postInitChildFirst"));
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // e02: @RestDestroy fires on removeChild.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest(path = "/root")
+       public static class E02_Parent extends BasicRestServletGroup {
+               private static final long serialVersionUID = 1L;
+       }
+
+       @Rest(path = "/destroyable")
+       public static class DestroyableChild {
+               public final AtomicBoolean destroyed = new AtomicBoolean(false);
+
+               @RestDestroy
+               public void onDestroy() {
+                       destroyed.set(true);
+               }
+
+               @RestGet(path = "/ping")
+               public String ping() {
+                       return "ok";
+               }
+       }
+
+       @Test
+       void e02_destroyHook_firesOnRemove() throws Exception {
+               var parent = new E02_Parent();
+               MockRestClient.build(parent);
+
+               var child = new DestroyableChild();
+               parent.addChild(child);
+               assertFalse(child.destroyed.get());
+
+               parent.removeChild("destroyable");
+               assertTrue(child.destroyed.get(), "@RestDestroy hook should 
have fired on removeChild");
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // f01: Concurrent add/remove + routing — sanity smoke (no CME, final 
state correct).
+       //
+       // This is a lightweight smoke test, not a true stress test — the goal 
is to exercise the volatile snapshot read
+       // path under contention and assert that visible routes always resolve 
correctly.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest(path = "/root")
+       public static class F_Parent extends BasicRestServletGroup {
+               private static final long serialVersionUID = 1L;
+       }
+
+       @Test
+       void f01_concurrentAddRemoveAndRoute_smoke() throws Exception {
+               var parent = new F_Parent();
+               var client = MockRestClient.build(parent);
+
+               // Seed with a permanent child that should always be routable.
+               parent.addChild(AlphaChild.class);
+
+               var stop = new AtomicBoolean(false);
+               var errors = Collections.synchronizedList(new 
ArrayList<Throwable>());
+
+               // Reader threads — hammer findMatch through MockRestClient 
against the permanent child.
+               var readers = new ArrayList<Thread>();
+               for (var i = 0; i < 4; i++) {
+                       readers.add(new Thread(() -> {
+                               try {
+                                       while (! stop.get())
+                                               
client.get("/alpha/ping").run().assertStatus(200);
+                               } catch (Throwable t) {
+                                       errors.add(t);
+                               }
+                       }));
+               }
+
+               // Writer threads — repeatedly add and remove a transient child 
class.
+               var writers = new ArrayList<Thread>();
+               for (var i = 0; i < 2; i++) {
+                       writers.add(new Thread(() -> {
+                               try {
+                                       for (var j = 0; j < 50 && ! stop.get(); 
j++) {
+                                               try {
+                                                       
parent.addChild(BetaChild.class);
+                                               } catch (IllegalStateException 
dup) {
+                                                       // Two writers racing 
on the same class is expected — ignore.
+                                               }
+                                               
parent.removeChild(BetaChild.class);
+                                       }
+                               } catch (Throwable t) {
+                                       errors.add(t);
+                               }
+                       }));
+               }
+
+               readers.forEach(Thread::start);
+               writers.forEach(Thread::start);
+
+               for (var w : writers)
+                       w.join(TimeUnit.SECONDS.toMillis(15));
+               stop.set(true);
+               for (var r : readers)
+                       r.join(TimeUnit.SECONDS.toMillis(15));
+
+               if (! errors.isEmpty())
+                       throw new AssertionError("Concurrent operations 
produced errors: " + errors.get(0), errors.get(0));
+
+               // Final state: AlphaChild still mounted; BetaChild may or may 
not be present (last write wins).
+               client.get("/alpha/ping").run().assertStatus(200);
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // g01: addChild(Class<?>) where the class is pre-registered as a bean 
— buildChildContext resolves via bean store.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest(path = "/root")
+       public static class G_Parent extends BasicRestServletGroup {
+               private static final long serialVersionUID = 1L;
+
+               private final AlphaChild prebuiltAlpha = new AlphaChild();
+
+               @Bean
+               public AlphaChild prebuiltAlpha() {
+                       return prebuiltAlpha;
+               }
+       }
+
+       @Test
+       void g01_addChild_byClass_resolvesFromBeanStore() throws Exception {
+               var parent = new G_Parent();
+               var client = MockRestClient.build(parent);
+
+               var ctx = parent.addChild(AlphaChild.class);
+
+               assertSame(parent.prebuiltAlpha, ctx.getResource(), 
"Class-based addChild should resolve from bean store when a matching bean is 
registered.");
+               client.get("/alpha/ping").run().assertContent("alpha-pong");
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // h01: Servlet-typed child — setContext reflective invocation runs and 
Servlet.destroy() fires on remove.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest(path = "/servletchild")
+       public static class ServletChild extends BasicRestServlet {
+               private static final long serialVersionUID = 1L;
+               public final AtomicBoolean destroyed = new AtomicBoolean(false);
+
+               @RestGet(path = "/ping")
+               public String ping() {
+                       return "servlet-pong";
+               }
+
+               @Override
+               public void destroy() {
+                       destroyed.set(true);
+                       super.destroy();
+               }
+       }
+
+       @Rest(path = "/root")
+       public static class H_Parent extends BasicRestServletGroup {
+               private static final long serialVersionUID = 1L;
+       }
+
+       @Test
+       void h01_servletChild_setContextAndDestroyFire() throws Exception {
+               var parent = new H_Parent();
+               var client = MockRestClient.createLax(parent).build();
+
+               var child = new ServletChild();
+               parent.addChild(child);
+
+               // setContext on the Servlet child was invoked during addChild 
— verify by issuing a successful request.
+               // (BasicRestServlet defaults to text/html which wraps 
responses, so assert on a substring rather than equality.)
+               
client.get("/servletchild/ping").run().assertStatus(200).assertContent().asString().isContains("servlet-pong");
+
+               parent.removeChild("servletchild");
+               assertTrue(child.destroyed.get(), "Servlet.destroy() should 
fire when a Servlet-typed child is removed.");
+               client.get("/servletchild/ping").run().assertStatus(404);
+       }
+
+       
//------------------------------------------------------------------------------------------------------------------
+       // i01: addChild(String, Object, boolean replace=true) — explicit path 
with replace semantics.
+       
//------------------------------------------------------------------------------------------------------------------
+
+       @Rest(path = "/root")
+       public static class I_Parent extends BasicRestServletGroup {
+               private static final long serialVersionUID = 1L;
+       }
+
+       @Test
+       void i01_addChild_explicitPath_withReplace() throws Exception {
+               var parent = new I_Parent();
+               var client = MockRestClient.build(parent);
+               var rc = parent.getChildResources();
+
+               var original = new AlphaChild();
+               rc.addChild("/gamma", original);
+
+               var replacement = new BetaChild();
+               var newCtx = rc.addChild("/gamma", replacement, true);
+
+               assertSame(replacement, newCtx.getResource());
+               assertEquals("gamma", newCtx.getPath());
+               // /gamma now routes through BetaChild even though the 
@Rest(path) on BetaChild is "/beta".
+               client.get("/gamma/ping").run().assertContent("beta-pong");
+       }
+}
diff --git a/todo/TODO-11-restclient2-transport-abstraction.md 
b/todo/FINISHED-11a-restclient-ng-design-plan.md
similarity index 99%
rename from todo/TODO-11-restclient2-transport-abstraction.md
rename to todo/FINISHED-11a-restclient-ng-design-plan.md
index 388165ae90..4b607ad897 100644
--- a/todo/TODO-11-restclient2-transport-abstraction.md
+++ b/todo/FINISHED-11a-restclient-ng-design-plan.md
@@ -1,4 +1,8 @@
-# RestClient Next-Generation Transport Abstraction Plan
+# FINISHED-11a: RestClient Next-Generation Transport Abstraction — Design Plan
+
+> **Archived from `TODO-11-restclient2-transport-abstraction.md`.** This file 
preserves the original design plan that drove the next-generation REST client 
(`org.apache.juneau.ng.*`) implementation. The implementation itself — 
including all packages, modules, transport adapters, mock transport, and unit 
tests — has landed. The remaining cleanup (test-coverage closeout in 
`org.apache.juneau.ng.http`) is tracked in 
`todo/TODO-11-restclient-ng-coverage-closeout.md`. The text below is preser 
[...]
+
+---
 
 ## Problem Statement
 
diff --git a/todo/FINISHED-31-inject-aware-microservice.md 
b/todo/FINISHED-31-inject-aware-microservice.md
new file mode 100644
index 0000000000..c47f4a3a99
--- /dev/null
+++ b/todo/FINISHED-31-inject-aware-microservice.md
@@ -0,0 +1,159 @@
+# FINISHED-31: Inject-aware Microservice
+
+## Summary
+
+`juneau-microservice-core` and `juneau-microservice-jetty` are now first-class 
consumers of the
+inject framework (`BeanStore`, `@Configuration`, `@Bean`, `@PostConstruct`, 
`@PreDestroy`,
+`@Primary`, `@Order`, `@Conditional`). An application or test can bootstrap a 
microservice from
+one or more `@Configuration` classes whose `@Bean` methods produce the 
resources, servlets,
+listeners, and infrastructure the microservice needs.
+
+Existing builder/config-file workflow remains byte-for-byte unchanged.
+
+## What shipped
+
+### Phase 1 — `juneau-microservice-core`
+
+`Microservice` gains an internal `WritableBeanStore` (accessible via
+`Microservice.getBeanStore()`). Bootstrap order:
+
+1. `WritableBeanStore` constructed (or taken from builder via new 
`.beanStore(...)` method).
+2. `@Configuration` classes registered via new `.configurations(Class<?>...)` /
+   `.configurations(List<Class<?>>)` builder methods are processed first; 
`@Bean` methods are
+   invoked with parameter injection and their results land in the store.
+3. Each microservice field (`Args`, `ManifestFile`, `Config`, `VarResolver`, 
`MicroserviceListener`)
+   is resolved with priority **explicit builder > `@Bean` > built-in 
default**, then registered
+   into the store (overwriting any `@Bean` contribution under the same `(type, 
name)`).
+4. The microservice instance itself is self-registered as a bean.
+5. Console commands contributed via `@Bean ConsoleCommand` are merged into the 
console command map
+   alongside builder-supplied and `Console/commands` config-file entries.
+
+Lifecycle:
+
+- `Microservice.stop()` closes the bean store, which walks every **resolved** 
bean in LIFO order
+  and invokes `@PreDestroy` methods. Errors are logged at `WARNING` and do not 
abort the rest of
+  the shutdown sequence.
+- `@PostConstruct` callbacks fire automatically on beans instantiated through 
the inject framework
+  (e.g. when a `@Bean` factory method depends on another `@Bean`-supplied 
dependency, or via
+  `BeanStore.instantiate(X)`).
+
+New API surface on `Microservice.Builder`:
+
+```java
+public Builder configurations(Class<?>... configs)
+public Builder configurations(List<Class<?>> configs)
+public Builder beanStore(WritableBeanStore externalStore)
+```
+
+New API surface on `Microservice`:
+
+```java
+public WritableBeanStore getBeanStore()
+```
+
+### Phase 2 — `juneau-microservice-jetty`
+
+`JettyMicroservice.Builder` overrides the new builder methods for 
fluent-typing. The constructor
+self-registers `JettyMicroservice`, `JettyMicroserviceListener`, and 
`JettyServerFactory` into the
+store, and looks up `@Bean`-supplied values when the builder doesn't provide 
them.
+
+`createServer()`:
+
+- If a `@Bean Server` is present in the store (with the 
`"ServletContextHandler"` attribute set),
+  uses it directly and skips the `jetty.xml` factory step entirely. Otherwise, 
uses the resolved
+  `JettyServerFactory` to build the server from `jetty.xml` as before.
+- Publishes the created `Server` back to the bean store.
+- Iterates `store.getBeansOfType(Servlet.class)` and auto-mounts every servlet 
whose runtime class
+  carries `@Rest`, at the path declared by `@Rest(path = "...")` (defaulting 
to `/` when no path is
+  set).
+- Tracks every mounted pathspec with its declaring source 
(`Jetty/servlets[FQN]`,
+  `Builder.servlet(FQN)`, `@Bean FQN[name]`) and throws a `RuntimeException` 
with both
+  contributors on path collision. Previously silent collisions across the five 
servlet sources are
+  now hard startup failures.
+
+### Phase 3 — Docs + release notes
+
+- New topic page `pages/topics/14.09.InjectAwareMicroservice.md` (slug 
`MicroserviceCoreInject`)
+  with a worked example, the resolution-priority table, the auto-discovery 
semantics, lifecycle
+  notes, external-store integration, and back-compat guarantees.
+- Sidebar entry added at `14.9. Inject-Aware Microservice`.
+- Two new sections in `pages/release-notes/9.5.0.md` (under `### 
juneau-microservice-core` and
+  `### juneau-microservice-jetty`) summarizing the new builder methods, the 
`getBeanStore()`
+  accessor, lifecycle semantics, `@Rest` servlet auto-mount behavior, and the 
path-collision
+  hard-failure invariant.
+
+## Tests
+
+New test classes in `juneau-utest`:
+
+- `Microservice_Inject_Test` (12 tests): bean-store presence and 
self-registration, external bean
+  store, `@Configuration` bean registration, resolution priority (builder > 
`@Bean`),
+  configuration-contributed console commands, `@PreDestroy` lifecycle, legacy 
builder back-compat.
+- `JettyMicroservice_Inject_Test` (9 tests): Jetty-specific self-registration, 
explicit
+  `JettyServerFactory` / `JettyMicroserviceListener` precedence over `@Bean` 
values, end-to-end
+  `@Bean Server` + `@Bean Servlet` auto-mount at `@Rest(path = ...)`, servlets 
without `@Rest` are
+  not auto-mounted, path collisions fail hard.
+
+All 183 existing microservice-related tests (`Microservice_Builder_Test`,
+`BasicMicroserviceListener_Test`, `JettyMicroservice_Builder_Test`,
+`BasicJettyMicroserviceListener_Test`, `JettyLogger_Test`, `LogConfig_Test`,
+`ConsoleCommand_Test`) continue to pass unchanged. Full `./scripts/test.py 
--full` is green.
+
+## Notable design decisions
+
+1. **Builder values stay authoritative.** `Microservice` does not move its 
fields into the store
+   wholesale. Instead, the constructor resolves each field with `builder > 
store > default`
+   precedence and then writes the final value into the store. This preserves 
the existing
+   "explicit builder call always wins" invariant while still letting `@Bean` 
methods supply
+   defaults when the builder is silent.
+
+2. **`@Bean` return types must match the lookup type.** 
`BeanStore.getBeansOfType(Class)` is
+   exact-type (not assignable-to). The user-facing contract documented in the 
new topic page:
+   declare `@Bean` methods that produce a servlet with return type `Servlet`, 
not the concrete
+   subclass. Spring-compatible "assignable-to" semantics would require an 
additive change to the
+   `BeanStore` API and is intentionally out of scope here; can be revisited if 
a follow-on need
+   emerges.
+
+3. **`@Bean.name()` is not repurposed as a mount path.** `@Bean(name = ...)` 
already has a
+   well-defined meaning (disambiguate multiple beans of the same Java type). 
The mount path comes
+   solely from `@Rest(path = ...)` on the servlet class, with `/` as the 
fallback. If multi-mount
+   ever becomes a real requirement, a dedicated `@Mount("/path")` annotation 
is the right answer,
+   not overloading `@Bean.name()`.
+
+4. **`@PreDestroy` only fires for resolved beans.** This is the existing 
`BasicBeanStore.close()`
+   contract — beans registered but never fetched via `getBean` / 
`getBeansOfType` are not tracked
+   and won't receive a `@PreDestroy` callback. For a microservice this almost 
never matters in
+   practice because servlets, listeners, and console commands are all pulled 
from the store
+   during start-up.
+
+5. **Path-collision is a hard failure, not a warning.** Across all five 
servlet sources
+   (`Jetty/servlets`, `Jetty/servletMap`, `Jetty/servletAttributes` via 
config, `.servlet(...)`
+   builder calls, `@Bean` discovery), `createServer()` tracks the first 
contributor for each
+   normalized pathspec and throws `RuntimeException` with both names if a 
second contributor
+   claims the same path. Previously these silently chained additional 
`ServletHolder` instances
+   onto the same pathspec.
+
+## Files touched
+
+**Core code:**
+
+- 
`juneau-microservice/juneau-microservice-core/src/main/java/org/apache/juneau/microservice/Microservice.java`
+- 
`juneau-microservice/juneau-microservice-jetty/src/main/java/org/apache/juneau/microservice/jetty/JettyMicroservice.java`
+
+**Tests:**
+
+- 
`juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Inject_Test.java`
+- 
`juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/JettyMicroservice_Inject_Test.java`
+
+**Docs:**
+
+- `juneau-docs/pages/topics/14.09.InjectAwareMicroservice.md`
+- `juneau-docs/pages/release-notes/9.5.0.md`
+- `juneau-docs/sidebars.ts`
+
+## Downstream unblocked
+
+[TODO-11] — RestClient NG closeout — cross-transport remote-interface test 
suite. The
+`MicroserviceTestFixture` plan in 
`todo/TODO-11-restclient-ng-coverage-closeout.md` can now build
+on top of `Microservice.create().configurations(...)` with a `@Configuration` 
test class supplying
+the `@Rest` servlets.
diff --git a/todo/FINISHED-33-dynamic-rest-children.md 
b/todo/FINISHED-33-dynamic-rest-children.md
new file mode 100644
index 0000000000..e14236a802
--- /dev/null
+++ b/todo/FINISHED-33-dynamic-rest-children.md
@@ -0,0 +1,65 @@
+# FINISHED-33: Dynamic add/remove of child REST resources
+
+**Status:** Shipped in 9.5.0.
+
+## What landed
+
+Runtime add/remove of child REST resources on any parent `RestContext`, with 
ergonomic facades on `BasicRestServletGroup` / `BasicRestObjectGroup`.
+
+### API
+
+On `RestChildren`:
+
+```java
+public RestContext addChild(Class<?> resourceClass) throws ServletException;
+public RestContext addChild(Object resource) throws ServletException;
+public RestContext addChild(Object resource, boolean replace) throws 
ServletException;
+public RestContext addChild(String path, Object resource) throws 
ServletException;
+public RestContext addChild(String path, Object resource, boolean replace) 
throws ServletException;
+public RestContext removeChild(String path);
+public RestContext removeChild(Class<?> resourceClass);
+```
+
+Convenience pass-throughs on `BasicRestServletGroup` and 
`BasicRestObjectGroup`:
+
+```java
+public RestChildren getChildResources();
+public RestContext addChild(Class<?>);
+public RestContext addChild(Object);
+public RestContext addChild(String path, Object);
+public RestContext removeChild(String path);
+public RestContext removeChild(Class<?>);
+```
+
+### Concurrency model
+
+`RestChildren` now holds children in a `volatile` copy-on-write snapshot 
(`Map<String,RestContext>` wrapped via `Collections.unmodifiableMap`). Route 
matching (`findMatch(...)`) reads the snapshot once and iterates lock-free. 
Mutations are serialized through an internal `writeLock`, build a fresh 
`LinkedHashMap`, then atomically swap the snapshot. `asMap()` returns the 
snapshot directly — no defensive copy.
+
+### Lifecycle
+
+- `addChild(...)` builds the child via the shared 
`RestChildren.buildChildContext(...)` recipe (bean-store lookup or 
`BeanInstantiator`, optional reflective `setContext`), then invokes 
`postInit()` + `postInitChildFirst()` on the child before returning.
+- `removeChild(...)` calls `Servlet.destroy()` for Servlet-backed children 
(which transitively calls `RestContext.destroy()` exactly once) or 
`RestContext.destroy()` directly for non-Servlet children. 
`RestContext.destroy()` recursively destroys grandchildren before closing its 
own bean store.
+- Duplicate paths without `replace=true` throw `IllegalStateException`.
+
+## Files changed
+
+- 
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestChildren.java`
 — rewritten with volatile snapshot and the new public API; factored out 
`buildChildContext(...)` for reuse by the static-init memoizer.
+- 
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java`
+  - The `restChildren` memoizer now delegates to `RestChildren.create(this, 
bs, servletConfig)` and `RestChildren.buildChildContext(...)`.
+  - Path resolution prioritises `Args.path` when non-empty, then falls back to 
`@Rest(path)`.
+  - `postInit()` now uses `ClassInfo.getMethod(...)` (not `getPublicMethod`) 
so it finds protected `setContext` declarations on subclasses of `RestServlet`.
+  - `destroy()` invokes `@RestDestroy` hooks, then recursively destroys 
children (while the bean store is still open), then closes the bean store — 
fixing a latent ordering bug uncovered by the new dynamic remove path.
+- 
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestServletGroup.java`
 — added `getChildResources()` + `addChild`/`removeChild` convenience methods; 
Javadoc updated.
+- 
`juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/servlet/BasicRestObjectGroup.java`
 — identical API for parity.
+- 
`juneau-utest/src/test/java/org/apache/juneau/rest/BasicRestServletGroup_DynamicChildren_Test.java`
 — 15 tests covering by-class / by-instance / path-override adds, 
remove-by-path / remove-by-class, duplicate-path errors, replace semantics, 
insertion order, lifecycle hooks, Servlet-typed child setContext + 
Servlet.destroy, bean-store-backed instantiation, and a concurrent 
reader/writer smoke test.
+- `juneau-docs/pages/topics/10.03.03.ChildResources.md` — new "Dynamic Child 
Resources" section documenting the API and concurrency model.
+- `juneau-docs/pages/release-notes/9.5.0.md` — release-notes entry under 
`juneau-rest-server`.
+
+## Coverage
+
+`RestChildren.java`: 88% branches / 93% instructions. Remaining gaps are 
defensive paths (null-guards, exception wrappers) and the parent `destroy()` 
walk that is not exercised by `MockRestClient` tests.
+
+## Verification
+
+- `mvn -pl juneau-utest test` — 50,121 tests pass, 0 failures, 0 errors.
+- `BasicRestServletGroup_DynamicChildren_Test` — all 15 tests pass.
diff --git a/todo/TODO-11-restclient-ng-coverage-closeout.md 
b/todo/TODO-11-restclient-ng-coverage-closeout.md
new file mode 100644
index 0000000000..43e74f13b4
--- /dev/null
+++ b/todo/TODO-11-restclient-ng-coverage-closeout.md
@@ -0,0 +1,70 @@
+# TODO-11: RestClient NG — Coverage Closeout + Cross-Transport 
Remote-Interface Tests
+
+> **Status:** Implementation, docs, and release notes for the next-generation 
REST client and HTTP stack (`org.apache.juneau.ng.*`) have shipped. The 
original design plan is archived in 
`todo/FINISHED-11a-restclient-ng-design-plan.md`. This file tracks the 
**remaining** cleanup before `TODO-11` can be fully retired.
+
+## Remaining work
+
+### A. Cross-transport remote-interface tests  (blocked on `[TODO-31]`)
+
+Existing `org.apache.juneau.http.remote` tests cover remote-interface behavior 
end-to-end but only against the classic `MockRestClient` — they never go 
through any real wire transport. The new NG transports each have their own 
basic coverage against `com.sun.net.httpserver.HttpServer` with canned 
responses, but nothing exercises a Juneau remote proxy through a real Juneau 
REST pipeline on the server side.
+
+Once `[TODO-31]` lands, build:
+
+- `MicroserviceTestFixture` in `juneau-utest` — JUnit 5 extension that boots a 
`JettyMicroservice` on port 0 from one or more `@Configuration` classes and 
exposes `getRootUrl()`. Tears down in `afterAll`.
+- `NgRemoteInterfaceTransport_Test` in `juneau-utest` — parameterized over the 
five NG transports (`apache-hc45`, `apache-hc5`, `java-http`, `okhttp`, 
`jetty`) via `@ParameterizedTest` + `@MethodSource`. Server side is a single 
`@Rest` resource provided as a `@Bean` from a test `@Configuration`.
+
+Focused scenario set per transport (each scenario runs 5x — once per 
transport):
+
+- GET with `@Path` (single + multi-segment) and `@Query` (single + map + bean).
+- POST with `@Content` (string, bean, `Reader`, `InputStream`).
+- POST with `@FormData` (single, map, bean).
+- Header propagation (`@Header` on parameter, default headers on the client).
+- Response status: 200 with body, 204 no-content, 404 -> remote exception 
mapping.
+- One end-to-end RRPC scenario through `@Remote(rrpc=true)` to confirm 
bidirectional bean marshalling.
+
+5 transports x ~15 scenarios = ~75 executions. This is the primary lift for 
transport-module coverage.
+
+### B. `org.apache.juneau.ng.http` coverage closeout  (independent — can land 
before TODO-31)
+
+The bulk of the uncovered surface is in named response classes (`Ok`, 
`Created`, `NotFound`, ...) and RFC-named header classes (`Accept`, 
`ContentType`, ...). Cover with two parametric tests:
+
+- 
`juneau-utest/src/test/java/org/apache/juneau/ng/http/response/NgNamedResponses_Test.java`
 — reflectively enumerate every public subclass of the response base in 
`org.apache.juneau.ng.http.response`; for each, invoke every public constructor 
/ factory, call all public getters, run `writeTo(OutputStream)`, verify status 
code matches the RFC code.
+- 
`juneau-utest/src/test/java/org/apache/juneau/ng/http/header/NgNamedHeaders_Test.java`
 — same parametric pattern over `org.apache.juneau.ng.http.header.*`. Walks 
`of(...)` factories, value accessors, and `writeTo` / wire-format paths.
+- Fill targeted gaps in `HttpBody`, `HttpHeaders`, `HttpResource` discovered 
by `./scripts/coverage.py --branches` (small one-shot tests).
+
+### C. Transport-module residual gaps  (after A)
+
+After the parameterized suite from A has run, fill what remains via 
`./scripts/coverage.py --branches`:
+
+- `close()` / connection-release hooks on each transport's response type.
+- Header carry-through for multi-value and quoted-value cases.
+- Streaming-body request paths (each transport's `InputStream` consumption 
code).
+
+For hostile-server edge cases (truncated body, malformed status line), use 
`com.sun.net.httpserver.HttpServer` following the existing pattern in 
`ApacheHc45Transport_Test`.
+
+### D. Verify + archive
+
+- `./scripts/coverage.py --run` against 
`juneau-rest/juneau-rest-common/src/main/java/org/apache/juneau/ng/http/`; 
confirm >=95% instruction coverage.
+- `./scripts/coverage.py --branches` for every NG package; address remaining 
gaps.
+- `./scripts/test.py` full suite; confirm no regressions and runtime stays in 
the ~33s range.
+- Archive this file as `todo/FINISHED-11b-restclient-ng-coverage-closeout.md` 
and remove `[TODO-11]` from `todo/TODO.md`.
+
+## Latest coverage measurement (May 2026)
+
+Measured via `./scripts/coverage.py` against `juneau-utest/target/jacoco.exec`.
+
+| Package / Module | Branches | Instructions |
+|---|---|---|
+| `org.apache.juneau.ng.http` (in `juneau-rest-common`) | 20% | **38%** |
+| `org.apache.juneau.ng.rest.client` (in `juneau-rest-client`) | 89% | 94% |
+| `org.apache.juneau.ng.rest.mock` (in `juneau-rest-mock`) | 98% | 96% |
+| Apache HC 4.5 transport (`juneau-ng-rest-client-apache-httpclient-45`) | 71% 
| 81% |
+| Apache HC 5 transport (`juneau-ng-rest-client-apache-httpclient-50`) | 79% | 
85% |
+| JDK `HttpClient` transport (`juneau-ng-rest-client-java-httpclient`) | 100% 
| 78% |
+| OkHttp transport (`juneau-ng-rest-client-okhttp`) | 72% | 88% |
+| Jetty transport (`juneau-ng-rest-client-jetty`) | 75% | 77% |
+
+## Out of scope
+
+- Promoting the NG stack from beta to stable.
+- Deprecating or removing the classic `RestClient` / `juneau-rest-common`.
diff --git a/todo/TODO.md b/todo/TODO.md
index 750d8b1e7f..bab1163909 100644
--- a/todo/TODO.md
+++ b/todo/TODO.md
@@ -17,7 +17,7 @@
 
 - [TODO-10] Move `org.apache.juneau.http.annotation` from `juneau-marshall` 
into `juneau-rest-common` (already done for the annotation classes — plan 
tracks remaining follow-on cleanup). See 
`todo/TODO-10-move-http-annotation-to-rest-common.md`.
 
-- [TODO-11] Next-generation RestClient transport abstraction: decouple 
`RestClient` from Apache HttpClient 4.5 so any HTTP transport can be plugged 
in. See `todo/TODO-11-restclient2-transport-abstraction.md`.
+- [TODO-11] RestClient NG closeout: cross-transport remote-interface test 
suite (blocked on [TODO-31]) plus NG http package coverage (independent). 
Implementation has shipped (see 
`todo/FINISHED-11a-restclient-ng-design-plan.md`). Active checklist in 
`todo/TODO-11-restclient-ng-coverage-closeout.md`.
 
 - [TODO-12] Schema validation mode for parsers and serializers: wire `@Schema` 
validation into the bean property get/set lifecycle gated by a new 
`validateSchema` flag on `MarshallingContext`. See 
`todo/TODO-12-schema-validation.md`.
 
@@ -29,3 +29,5 @@
 
 - [TODO-30] Investigate moving `ClassMeta` and related non-marshalling type 
metadata from `juneau-marshall` into `juneau-commons` (analysis/feasibility 
pass). See `todo/TODO-30-classmeta-to-commons.md`.
 
+- [TODO-32] YAML support in juneau-config: add a YAML-format alternative to 
the existing INI-style Config. New `ConfigStore` implementation reading/writing 
`.yml` / `.yaml` files with parity for sections, keys, defaults, comments, and 
SVL interpolation; round-trips edits without losing comments where possible.
+

Reply via email to