An ADK Java agent is easy to start with static methods: write a static function, wrap it with FunctionTool.create(MyTools.class, "lookup"), hand it to LlmAgent.builder(), done. The trouble starts on the second day. The lookup needs an HTTP client with a connection pool, a base URL that differs per environment, an API key from a secret store, and a fake in tests. A static method can only reach those through static fields, and static fields are global mutable state with a test problem attached.
Dependency injection is the fix. Build the objects that do work once, pass each one its collaborators through its constructor, and keep a single place, the composition root, that wires everything together. This article shows how that applies to ADK Java specifically. It covers instance tools, which are the hinge, a plain-Java composition root, lifetimes and where per-user state must live, the same wiring in Spring and in Guice, swapping the model in tests, and the failure modes that show up in production. API names were checked against the google/adk-java sources on 2026-10-04.
The object graph of an ADK Java service
List what a running ADK Java service is made of, and you get a small object graph. At the leaves are infrastructure collaborators: HTTP or database clients, a clock, a metrics registry, configuration values and secrets. Above them sit tool objects, whose methods the model can call. Next come the model, either a model id string or a BaseLlm instance (LlmAgent.Builder has both model(String) and model(BaseLlm)), and the agent built from model, instruction and tools. At the top is the Runner, built with Runner.builder() from the agent, an app name, a session service, an artifact service and optionally a memory service and plugins. The Runner's public constructors are deprecated in current sources, so use the builder.
Almost all of this is created once and shared by every request. Only the session and the per-turn message vary, and ADK already models those as data passed to runAsync(userId, sessionId, message). That split is what makes DI simple here: you are wiring a mostly-singleton graph and keeping per-user data out of it.
Instance tools are the hinge
The hinge is a factory overload that is easy to miss. Besides FunctionTool.create(Class<?> cls, String methodName) for static methods, FunctionTool has create(Object instance, String methodName) for instance methods, plus overloads that add requireConfirmation and isLongRunning flags. Pass an object, and the tool invokes the method on that object, so the method can use whatever was injected into its constructor:
import com.google.adk.tools.Annotations.Schema;
import com.google.adk.tools.ToolContext;
import java.util.Map;
public final class OrderTools {
private final OrderClient orders; // injected collaborators, final
private final RefundPolicy policy;
public OrderTools(OrderClient orders, RefundPolicy policy) {
this.orders = orders;
this.policy = policy;
}
@Schema(description = "Look up the status of one order by id.")
public Map<String, Object> getOrderStatus(
@Schema(name = "orderId", description = "Order id, e.g. A-1042") String orderId,
ToolContext ctx) {
Order o = orders.fetch(orderId);
ctx.state().put("last_order_id", orderId); // per-session, not a field
return Map.of("orderId", orderId, "status", o.status(), "total", o.totalCents());
}
@Schema(description = "Check whether an order is eligible for a refund.")
public Map<String, Object> checkRefund(
@Schema(name = "orderId", description = "Order id") String orderId) {
Order o = orders.fetch(orderId);
return Map.of("orderId", orderId, "eligible", policy.eligible(o));
}
}The framework builds the function declaration from the method's name, parameters and @Schema annotations. @Schema lives in com.google.adk.tools.Annotations and has name, description and optional attributes. A ToolContext parameter is supplied by the framework and hidden from the model. Setting name explicitly on parameters means the declaration does not depend on compiling with -parameters. If you need more depth on writing the methods themselves, see writing a function tool.
Mismatches fail at construction, not at the first model call. The sources reject a name that does not resolve with "Instance method %s not found in class %s" (or the static equivalent), and refuse an instance for a static method and the reverse. That is exactly what you want: a renamed method breaks startup, not a customer conversation.
A composition root in plain Java
Before reaching for a container, write the wiring by hand. A composition root is one method that constructs the graph bottom-up and returns the top object. Nothing else in the codebase calls new on a collaborator or reads configuration directly:
public final class AppWiring {
public static Runner build(AppConfig cfg) {
// leaves
HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2)).build();
OrderClient orders = new OrderClient(http, cfg.ordersBaseUrl(), cfg.ordersApiKey());
RefundPolicy policy = new RefundPolicy(Clock.systemUTC(), cfg.refundWindowDays());
// tools: instances, so they carry their collaborators
OrderTools tools = new OrderTools(orders, policy);
// agent
LlmAgent agent = LlmAgent.builder()
.name("order_support")
.description("Answers order status and refund questions")
.model(cfg.modelId())
.instruction("Use the tools to answer. Never guess an order status.")
.tools(
FunctionTool.create(tools, "getOrderStatus"),
FunctionTool.create(tools, "checkRefund"))
.build();
// runner
return Runner.builder()
.agent(agent)
.appName("order-support")
.sessionService(new InMemorySessionService())
.artifactService(new InMemoryArtifactService())
.build();
}
}This is dependency injection with no framework, and for a single agent it is often all you need. The graph is visible in one screen. Every dependency is a constructor argument, so a missing one is a compile error. Tests can call a second factory method that passes fakes. The in-memory session and artifact services are for development; production swaps in a durable session service at this one line, without touching any tool or agent. Configuration enters as a typed AppConfig object loaded and validated before wiring starts; environment and configuration management covers how to build that object and fail fast on bad values.
Lifetimes: singletons, sessions and turns
Every object in a DI graph has a lifetime, and getting lifetimes wrong is the main way DI goes bad in an agent service. In this design almost everything is a singleton: one OrderTools, one agent, one Runner per process, shared by every concurrent conversation. That has two consequences.
First, tool objects must be thread-safe. Concurrent turns call the same instance. Final fields that hold thread-safe clients are fine. A mutable field such as lastOrderId or a cached "current user" is a cross-tenant data leak waiting for load. One user's turn writes it and another user's turn reads it.
Second, per-conversation data belongs in session state, reached through the ToolContext parameter (ToolContext extends CallbackContext, which exposes state()), not in fields. Session state is scoped to the session and persisted by the session service, which is the lifetime you actually want. Per-request objects that are expensive or need the caller's identity (a downstream token for the end user, say) should be derived inside the tool call from session state or invocation context and discarded afterwards. They should not be injected at startup.
| Thing | Lifetime | Where it lives |
|---|---|---|
| HTTP / DB clients, policy, clock | Process singleton | Constructor-injected final fields |
| Tool objects, agent, Runner | Process singleton | Built once in the composition root |
| Conversation facts (last order, user choices) | Session | Session state via ToolContext |
| End-user credentials, request ids | One turn | Derived per call, never stored in fields |
The same graph in Spring and Guice
When the service grows (several agents, shared clients, profiles per environment), a container saves the bookkeeping. The shape does not change: each bean or provider method is one step of the hand-written root.
@Configuration
class AgentConfig {
@Bean OrderTools orderTools(OrderClient orders, RefundPolicy policy) {
return new OrderTools(orders, policy);
}
@Bean LlmAgent orderAgent(OrderTools tools, AppConfig cfg) {
return LlmAgent.builder()
.name("order_support")
.model(cfg.modelId())
.instruction("Use the tools to answer. Never guess an order status.")
.tools(FunctionTool.create(tools, "getOrderStatus"),
FunctionTool.create(tools, "checkRefund"))
.build();
}
@Bean Runner runner(LlmAgent agent, BaseSessionService sessions) {
return Runner.builder().agent(agent).appName("order-support")
.sessionService(sessions)
.artifactService(new InMemoryArtifactService())
.build();
}
}
// Guice: the same graph as a module
class AgentModule extends AbstractModule {
@Provides @Singleton
LlmAgent agent(OrderTools tools, AppConfig cfg) {
return LlmAgent.builder().name("order_support").model(cfg.modelId())
.tools(FunctionTool.create(tools, "getOrderStatus")).build();
}
}Notice what stays out of the container. The container builds singletons. It does not hold conversations. Do not make tools request-scoped or session-scoped beans to smuggle per-user state into fields. ADK already has a session model, and a second one in the container will disagree with it. The broader Spring architecture, with agents as beans, tools as services and controllers that call the Runner, is covered in ADK Java with Spring.
One Spring-specific check is worth a test. If a tool bean is wrapped in an AOP proxy (for @Transactional, metrics aspects and the like), a class-based proxy is a generated subclass. FunctionTool reads method and parameter metadata reflectively from the object you pass. Whether the proxy's overriding methods carry your parameter annotations and names is something to verify rather than assume. Build the tool from the bean in a test and assert that the generated declaration has the descriptions and parameter names you wrote. The simplest way to avoid the question is to keep tool classes free of aspects and have them call into proxied services.
Testing what you injected
The payoff of injection is testing. Because the model is a dependency too, a test can build the same agent with a scripted BaseLlm passed to model(BaseLlm), and with fake collaborators passed to the real OrderTools. The test then exercises the real tool code and the real agent wiring with no network and no model spend. Writing that scripted model is covered in testing with a MockLlm.
Three test layers fall out naturally. Tool unit tests call OrderTools methods directly with a fake OrderClient. This layer needs no ADK at all. Declaration tests build each FunctionTool from the real instance and assert on the generated declaration, which catches renamed methods and lost descriptions. Wiring tests call the composition root with a test config and a scripted model, run one turn through runAsync, and assert the expected tool was called with the expected arguments and that session state holds what the tool wrote.
Worked example: a regional refund rule
Work through a concrete change to see the design pay off. The order-support agent above runs in production. Product asks for refunds to use a 30-day window in one region and 14 days in another, and an audit asks that every refund check be recorded.
With static tools, the window would be a static field set at startup, and the audit hook would be another static, a global logger. Both would leak into every test that touches the class. With the injected design, the change has three parts. RefundPolicy gains a region-to-window map from AppConfig. An AuditSink interface is added to OrderTools's constructor. The composition root passes the production sink. The compiler finds every place that constructs OrderTools: exactly two, the root and the test factory. The tool test uses a fixed clock and an in-memory sink to assert the boundary case on day 14 and day 15 in both regions. The wiring test confirms that a scripted model call to checkRefund produces one audit record. No agent, instruction or Runner code changed. That is what DI is for: the change lands in the object that owns the rule, and only the root knows how objects connect.
Failure modes
- Mutable fields in a singleton tool. Cross-session leaks under concurrency. Rule: tool fields are final collaborators; conversation data goes in session state.
- Static tools reaching for singletons. Hidden global state that tests cannot replace. Convert to instance methods and
FunctionTool.create(instance, name). - Method-name strings drifting. A rename breaks
createat startup with "Instance method ... not found". That is good, but only if startup runs in CI. Have a test call the composition root. - Network calls in constructors. A tool that connects or fetches in its constructor makes startup fail when a dependency is down, and slows every test. Construct lazily-connecting clients and check health separately.
- Circular wiring. An agent-as-tool that needs the parent agent, or a tool that needs the Runner, is a cycle the container may resolve with a proxy you did not expect. Break it by passing the narrower thing the tool actually needs.
- Secrets injected as strings everywhere. Keys end up in logs and toString output. Inject a client that already holds the credential, not the credential itself. Rotate it at the client.
- Container sprawl. Dozens of beans for one agent hide the graph. Keep a hand-written root until it actually hurts.
Trade-offs
Hand-written roots are explicit, fast to start and impossible to misconfigure silently. They get tedious with many agents and environment profiles. Spring brings profiles, configuration binding and an ecosystem at the cost of startup time, reflection and proxies, which matter here because FunctionTool is reflective too. Guice is lighter and keeps wiring in code, but it is one more framework for the team to learn. Whatever you pick, keep the same rules: constructor injection, final fields, singletons for things that work, and session state for things users say. For how tool sets are organised once there are many, see tool registry design.
What to do next
- List every static tool method that touches I/O, configuration or time, and convert each to an instance method created with
FunctionTool.create(instance, "method"). - Write one composition root that returns the Runner, built with
Runner.builder(), and make it the only place that constructs collaborators. - Make tool fields final and audit them for per-user data; move any into session state through
ToolContext. - Put explicit
@Schema(name = ..., description = ...)on every tool parameter. - Add the three test layers: tool unit tests with fakes, declaration tests on generated schemas, and one wiring test with a scripted
BaseLlm. - If you adopt Spring or Guice, port the root method by method, and add a declaration test for any tool bean that sits behind a proxy.