Skip to content
AgentEnsemble AgentEnsemble
Get Started

Wiring Agent Ensembles into Spring Boot, Micronaut, and Quarkus

One question that comes up early when evaluating an agent orchestration library is how it fits into an existing backend stack. If your services run on Spring Boot, Micronaut, or Quarkus, you want agents to live inside the same dependency injection container, use the same configuration system, and expose metrics through the same actuator endpoints.

The interesting design decision in AgentEnsemble is that it has no framework dependencies at all. It is a plain Java 21+ library with a builder API. Framework integration is just a matter of wrapping those builder calls in whatever DI mechanism your framework uses. Nothing in the library changes.

This keeps the core small and testable, but it also means the integration patterns are worth spelling out explicitly.

The Builder API as the Integration Surface

Section titled “The Builder API as the Integration Surface”

Every AgentEnsemble component — agents, tasks, ensembles, memory stores, listeners — is created through builders. The framework never scans for annotations, never registers beans automatically, and never assumes a particular lifecycle model.

This is deliberate. The builder API is the integration surface. In a DI container, you turn builder calls into bean definitions. In a plain main() method, you call the same builders directly.

Agent researcher = Agent.builder()
.role("Research Analyst")
.goal("Find accurate, up-to-date information")
.backstory("You are a meticulous researcher.")
.build();

That same code works identically inside a Spring @Configuration, a Micronaut @Factory, a Quarkus CDI producer, or a static main method.

Spring Boot is the most common case. The LangChain4j Spring Boot starters handle ChatLanguageModel bean creation from application.properties automatically — AgentEnsemble does not duplicate that responsibility.

dependencies {
implementation("net.agentensemble:agentensemble-core:2.10.0")
implementation("dev.langchain4j:langchain4j-spring-boot-starter:1.11.0")
implementation("dev.langchain4j:langchain4j-open-ai-spring-boot-starter:1.11.0")
// Optional: metrics via Spring Boot Actuator
implementation("net.agentensemble:agentensemble-metrics-micrometer:2.10.0")
}

Spring injects the ChatLanguageModel bean (created by the LangChain4j starter) and any EnsembleListener beans you have declared elsewhere.

@Configuration
public class AgentEnsembleConfig {
@Bean
public Agent researcher() {
return Agent.builder()
.role("Research Analyst")
.goal("Find accurate, up-to-date information on the given topic")
.backstory("You are a meticulous researcher with a talent for "
+ "finding relevant information quickly.")
.build();
}
@Bean
public Ensemble ensemble(
ChatLanguageModel chatModel,
Agent researcher,
List<EnsembleListener> listeners,
Optional<ToolMetrics> toolMetrics) {
Ensemble.Builder builder = Ensemble.builder()
.chatModel(chatModel)
.agents(researcher);
listeners.forEach(builder::listener);
toolMetrics.ifPresent(builder::toolMetrics);
return builder.build();
}
}

The pattern here is standard Spring: declare beans, let Spring wire them. Any @Component implementing EnsembleListener is automatically collected via the List<EnsembleListener> injection.

If you use Micrometer with Spring Boot Actuator, declare a ToolMetrics bean and agent metrics appear at /actuator/metrics automatically:

@Bean
public ToolMetrics toolMetrics(MeterRegistry registry) {
return new MicrometerToolMetrics(registry);
}

Inject the Ensemble bean wherever you need it. Build tasks at the call site where you have the runtime inputs:

@Service
public class ResearchService {
private final Ensemble ensemble;
private final Agent researcher;
public ResearchService(Ensemble ensemble, Agent researcher) {
this.ensemble = ensemble;
this.researcher = researcher;
}
public String research(String topic) {
Task task = Task.builder()
.description("Research and summarise: " + topic)
.expectedOutput("A concise summary with key findings")
.agent(researcher)
.build();
return ensemble.run(task).finalOutput();
}
}

Micronaut does not have a LangChain4j integration module, so you create the ChatLanguageModel bean directly. The rest of the pattern is the same — a @Factory class with @Singleton methods.

@Factory
public class AgentEnsembleFactory {
@Singleton
public ChatLanguageModel chatModel(
@Value("${agentensemble.openai.api-key}") String apiKey,
@Value("${agentensemble.openai.model-name}") String modelName) {
return OpenAiChatModel.builder()
.apiKey(apiKey)
.modelName(modelName)
.build();
}
@Singleton
public Ensemble ensemble(
ChatLanguageModel chatModel,
Agent researcher,
List<EnsembleListener> listeners) {
Ensemble.Builder builder = Ensemble.builder()
.chatModel(chatModel)
.agents(researcher);
listeners.forEach(builder::listener);
return builder.build();
}
}

Micronaut injects all EnsembleListener beans automatically via the List<EnsembleListener> parameter. Micrometer metrics work out of the box since Micronaut ships with native Micrometer support.

Quarkus has its own quarkus-langchain4j extension with a different programming model. The example below uses the standard LangChain4j library directly with Quarkus CDI:

@ApplicationScoped
public class AgentEnsembleProducer {
@ConfigProperty(name = "agentensemble.openai.api-key")
String apiKey;
@Produces @ApplicationScoped
public ChatLanguageModel chatModel() {
return OpenAiChatModel.builder()
.apiKey(apiKey)
.modelName("gpt-4o")
.build();
}
@Produces @ApplicationScoped
public Ensemble ensemble(
ChatLanguageModel chatModel,
Agent researcher,
Instance<EnsembleListener> listeners) {
Ensemble.Builder builder = Ensemble.builder()
.chatModel(chatModel)
.agents(researcher);
listeners.forEach(builder::listener);
return builder.build();
}
}

The only Quarkus-specific detail is Instance<EnsembleListener> instead of List<EnsembleListener> — CDI’s lazy injection mechanism.

The choice to keep AgentEnsemble framework-agnostic means there is no auto-configuration, no classpath scanning, and no starter module that wires everything with a single dependency. You write the configuration class yourself.

The upside is that the integration is completely transparent. There is no hidden magic, no classpath-sensitive behavior, and no risk of version conflicts between the library’s framework assumptions and your application’s framework version. The builder API is the same everywhere, so moving between frameworks (or running without one) requires changing only the DI wiring.

For teams that already have a preferred framework and know how to write configuration classes, this is usually the right tradeoff. The wiring code is small, readable, and lives in one place.

A few integration points are worth calling out:

  • Listeners integrate naturally as DI beans. Declare any EnsembleListener implementation as a bean, and the ensemble configuration collects them.
  • Memory components (MemoryStore, EnsembleMemory) are created via builders and passed to the ensemble. In a DI framework, declare them as beans.
  • Tools are configured per-agent. Declare tool instances as beans and inject them into agent factory methods.
  • Metrics via MicrometerToolMetrics plug into whatever MeterRegistry your framework provides.

The general rule: if a component is created via a builder, it can be a bean. If it is passed to the ensemble builder, it can be injected.


The framework integration guide and full code examples are in the documentation.

I’d be interested in whether this level of framework-agnosticism feels right, or whether starter modules that auto-configure common setups would be more useful for your team.