Why it matters
Most tool articles assume you are writing the tool. This one assumes you are
not. The method already exists: an OrderService.findOrder(long) that
five other callers depend on, a payments SDK you did not write, a repository
generated by Spring Data. The job is to make that surface addressable by a model
without forking it, and the friction is not schema syntax - it is that the method
was designed for a caller that has a request thread, a transaction, a security
principal, an exception handler and a compiler checking its argument types. A
model has none of those.
The sibling article ADK Java + Spring makes the optimistic case: tools are the application's services, so there is no adapter layer and no drift. That is the right target and it is true for perhaps half of the methods in a typical service class. This page is about the other half - the methods where "just annotate it" produces a tool that compiles, registers, appears in the schema the model sees, and then fails in production for reasons that have nothing to do with the model.
The architecture
Define a tool: annotate a Java method with @Tool, provide description, define parameter schema (name, type, description, required).
ADK auto-generates a JSON schema the LLM sees; when LLM outputs a tool call, ADK dispatches to your method. The schema is generated from whatever signature you point it at - which is why an inherited signature, written for a different kind of caller, is the thing that decides how well the tool works.
What this page covers, and where to go instead
Scope, so you can skip ahead. This page is about adapting an interface you did not design: granularity, overloads and builders, persistence-shaped return values, thread and context propagation, storage exceptions, and testing that the adapter still means what the service means.
It deliberately does not re-cover ground that is already developed elsewhere in this corpus. Schema generation from a Java signature, erasure and boxing traps, argument validation at the deserialization boundary, idempotency keys and compensating actions belong to Java agent tools architecture. General tool-design principles - naming, single-purpose tools, result shaping - belong to ADK tool design patterns and ADK tool architecture. Pulling in third-party catalogs over MCP or an OpenAPI document is toolset territory. Deadlines and cancellation are timeout handling. Who is allowed to call what is authorization at the agent boundary. Wiring beans, config and Micrometer is the Spring page.
Granularity: the methods are the wrong size
Service APIs are shaped by their existing callers. A REST controller calls three repository methods and assembles a response; a batch job calls one method with a flag that switches its behaviour entirely. Neither shape is a tool.
Too coarse
A method like processOrder(OrderRequest req, boolean dryRun, boolean
notify, Channel ch) does four things depending on its flags. Exposed
directly, every flag becomes a schema property the model must guess, and the
worst guess - dryRun=false when the user asked "what would happen
if" - is silent and irreversible. Adapt by fixing the flags in the adapter and
exposing the branches as separate tools: simulate_order pins
dryRun=true, place_order pins it false. The flag stops
being a decision the model makes and becomes part of the tool's identity.
Too fine
The opposite is worse for latency. If answering "is my order late?" means
findOrder, then findShipment, then
findCarrierEvents, exposing all three forces three model turns to
retrieve one fact. Each turn re-sends the transcript, so a three-hop chain costs
three full prompt evaluations before the model can answer. Write one adapter
method that performs the join server-side and returns the answer shape. You are
not adding business logic - you are adding a composition the application already
performs somewhere in a controller, moved to where the model can reach it in one
call.
The test for granularity is not "is this method single-purpose" but "does one call answer one question a user would actually ask".
Overloads, builders, and signatures a model cannot address
The JVM resolves overloads by static argument types. A tool namespace is flat strings. Those two facts do not compose.
Overloads collapse
Given findOrder(long id) and findOrder(String
externalRef), a naive registration produces two entries called
findOrder. Depending on the registry that is a startup failure, a
silent last-wins, or - worst - two schemas the model picks between at random. The
adapter has to break the tie by hand, and the useful move is to encode the
discriminator in the name rather than in an argument:
find_order_by_id and find_order_by_customer_reference.
A single tool with a lookupType enum looks tidier and performs
worse, because the model now has to get two fields consistent instead of one.
Builders have no arity
Fluent APIs - ReportQuery.builder().from(d1).to(d2).groupBy(...)
.build() - have no signature to reflect over. There is no parameter list,
only a chain, and the terminal build() may throw if a required step
was skipped. Adapting one means writing the flat facade yourself and choosing
which knobs to expose. Expose the three the model will plausibly vary; hard-code
the rest at values your team already considers correct. A builder with fourteen
optional steps flattened into a fourteen-property schema is a tool the model will
mis-populate, and every unset property is a decision you pushed onto a system
that cannot read your defaults.
// Inherited surface: overloaded, and a builder with no arity.
Order findOrder(long id);
Order findOrder(String externalRef);
Report run(ReportQuery q); // built via a 14-step fluent chain
// Adapter: distinct names, flat arguments, defaults decided here.
@Tool(description = "Look up an order by its numeric internal id.")
OrderView findOrderById(long orderId) { ... }
@Tool(description = "Look up an order by the reference printed on the customer's receipt.")
OrderView findOrderByCustomerReference(String reference) { ... }
@Tool(description = "Revenue by product line for a closed date range.")
ReportView revenueByProductLine(String startDate, String endDate) {
return view(reports.run(ReportQuery.builder()
.from(LocalDate.parse(startDate)).to(LocalDate.parse(endDate))
.groupBy(PRODUCT_LINE).currency(BASE) // pinned, not exposed
.includeVoided(false).build()));
}Return types: handing the model a persistence object
This is where "the tool is the service" fails hardest, and the failure is usually a 500 rather than a bad answer. Repository methods return entities, and entities are not values.
Lazy proxies outside the transaction
A JPA entity returned from a @Transactional service method is
detached the moment the transaction commits. Its lazy associations are proxies
backed by a closed persistence context. The tool layer then serializes the return
value to JSON for the model, the serializer walks
order.getLineItems(), and the proxy raises
LazyInitializationException - inside the serializer, after the
business logic already succeeded. The stack trace points at Jackson and the
actual cause is a transaction boundary two layers up.
Cycles and accidental exfiltration
Bidirectional mappings make it worse. Order.customer points at
Customer.orders points back at Order; a naive
serializer recurses until it runs out of stack or emits megabytes. And an entity
carries every mapped column, including the ones nobody meant to publish -
internal cost basis, a soft-delete flag, a password reset token on the customer
row. Serialized into the tool result, those land in the transcript, get re-sent
on every subsequent turn, and are one paraphrase away from the user.
The adapter's actual job
Project to a record at the boundary, inside the transaction, and make the projection explicit rather than annotation-driven. Explicit projection fixes all three problems at once: it forces the lazy load while the session is open, it cannot cycle because a record has no back-reference, and it is an allowlist, so a new column added to the entity next quarter does not silently appear in a model's context. It also caps result size - a repository method that happily returns 40,000 rows to a batch job needs a limit before it returns to a model.
// Value shape the model sees. No entity, no proxy, no back-reference.
record OrderView(String orderId, String status, String placedOn,
int itemCount, String total) {}
@Transactional(readOnly = true) // projection happens in-session
OrderView findOrderById(long orderId) {
Order o = orders.findById(orderId).orElse(null);
if (o == null) return null;
return new OrderView(
o.getPublicRef(), // not the primary key
o.getStatus().name().toLowerCase(),
o.getPlacedOn().toString(),
o.getLineItems().size(), // forces the load, here
Money.format(o.getTotal())); // not a BigDecimal scale surprise
}Two smaller notes that fall out of the same rule. Expose a public reference
rather than the primary key, because whatever identifier you return is the one
the model will echo back as an argument and quote to the user. And format money
and timestamps once, at the boundary, rather than shipping a raw
BigDecimal and hoping the model does not render
1.2E+2.
The tool does not run on the caller's thread
An inherited service method frequently depends on state it never names.
Spring's SecurityContextHolder, RequestContextHolder,
MDC logging keys and TransactionSynchronizationManager are all
ThreadLocal-backed. They work because the servlet container put them
there on the thread handling the request, and every call in the chain stayed on
that thread.
An agent turn does not preserve that. The Runner may dispatch a
tool on an executor, several tools in parallel, or on a virtual thread that was
never touched by the inbound filter chain. The service method compiles, runs, and
then does one of two things: throws a null-principal
NullPointerException, or - much worse - finds an empty context and
takes the unauthenticated branch, returning data scoped to nobody. That second
outcome is a data leak that no test catches, because in a unit test there was
never a principal to lose.
The fix is to stop relying on ambient state and pass identity explicitly.
Capture what the tool needs when the invocation starts, on the thread that still
has it, then hand it to the adapter as a parameter. ToolContext is
the right carrier for per-invocation state the model never sees, and a
beforeTool callback is the right place to populate it, because it
runs once per call on the dispatch path.
// WRONG: reads a ThreadLocal that the tool thread never had.
OrderView findOrderById(long orderId) {
var who = SecurityContextHolder.getContext().getAuthentication(); // may be null
return view(orders.findForPrincipal(orderId, who.getName()));
}
// RIGHT: identity captured at invocation, carried, asserted.
OrderView findOrderById(long orderId, ToolContext ctx) {
String tenant = (String) ctx.state().get("tenant_id"); // set in beforeTool
if (tenant == null) throw new IllegalStateException("no tenant on invocation");
return view(orders.findForTenant(orderId, tenant));
}The same reasoning applies to transactions. @Transactional
propagation is thread-bound, so a tool that calls two service methods expecting
to share a transaction gets two separate ones on a different thread, and a
failure between them leaves half the work committed. If two calls must be atomic,
that atomicity has to live in one adapter method, not in the sequence the model
happens to emit. Which identity should apply, and how to enforce it as
policy, is
a separate question; the
point here is purely mechanical - the ambient copy is not there.
Storage exceptions that mean nothing to a model
Inherited persistence code throws in the vocabulary of its storage layer.
OptimisticLockingFailureException,
DataIntegrityViolationException,
EmptyResultDataAccessException and their JDBC cousins carry SQL
state codes, constraint names and sometimes fragments of the failing statement.
Letting one reach the boundary unmapped produces two bad outcomes: the model sees
uq_customer_email_lower and has no idea whether to retry, and the
constraint name is now a schema detail sitting in a transcript.
The mapping worth doing is not "make it human-readable" - it is to answer, for each storage failure, the one question the model actually has to decide: call again, call something else, or stop. An optimistic lock failure is a retry with fresh state. A unique-constraint violation on the natural key means the row is already there, which is frequently a success in disguise. An empty result is not an error at all; it is an answer, and turning it into an exception makes the model apologize for a lookup that worked correctly. Anything referencing a connection pool, a deadlock or a timeout is an infrastructure condition the model cannot act on and should not be asked to reason about - surface it as a non-retryable failure and let the operational tooling see the real cause. The shape of that boundary result is covered in detail elsewhere; what is specific here is that the translation table belongs in the adapter, because the underlying service is entitled to keep throwing what it always threw.
Testing that the adapter preserved the semantics
Generic tool testing - snapshotting the schema, replaying model-shaped arguments, scripted in-agent runs - is already documented. Integration adds three checks that only make sense when the tool wraps something you did not write.
Assert the projection is complete inside the transaction. The
lazy-load bug is invisible in a test that runs the whole method inside one open
session, which is the default for a naive
@DataJpaTest. Assert on the serialized output, not the returned
object, and assert it after the transaction has closed - that is the only
arrangement that reproduces what the runtime actually does.
Assert the projection is a closed allowlist. A test that compares the serialized field set against an expected set fails when someone adds a column to the entity. That failure is the point: it forces a human decision about whether the new field belongs in a model's context, instead of letting it appear by default.
Pin the generated schema against downstream drift. When the tool delegates to a library or another team's client, an upgrade can change a parameter type or drop an overload, and the generated schema changes with it silently. The tool still compiles; the descriptions the model was tuned against have moved. A committed snapshot of the emitted schema turns that into a failing build. Run the same suite against a real downstream in a container as well as against a stub - stubs agree with your assumptions by construction, which is exactly the assumption an inherited API is most likely to violate.
When to adapt, and when to write a new method
The adapter is not free. It is a second surface to keep in step with the first, and every one you add weakens the argument for reusing the service at all. Some rules that hold up in practice.
Expose the service method directly when it takes two or three scalar arguments, returns a value type or something trivially projectable, does not consult ambient thread state, and already means one thing. Plenty of query methods qualify, and wrapping those adds drift risk for nothing.
Write a thin adapter - the common case - when the shape is wrong but the semantics are right: an entity return, a flag to pin, an overload to disambiguate, a principal to pass explicitly. Keep it mechanical. The moment an adapter contains a business rule, it has become a second implementation of something, and it will diverge from the first.
Add a method to the service itself when the model needs a composition the application does not have - the joined "is my order late" answer, or an operation that must be atomic across calls the model would otherwise emit separately. That method belongs with the other business logic, tested with it, and the agent becomes one more caller. This is the outcome the Spring integration argument is actually pointing at, and it is worth the pull request.
Do not expose it at all when the operation is unbounded, irreversible without a compensating path, or scoped by an identity the invocation cannot prove it holds.