I keep rebuilding the same orchestrator-worker pattern every time I tackle a code migration, so I finally wrote it down properly. If you're designing multi-agent systems for architecture work, this is the setup I trust most. Curious what you'd change.
This article is basically the setup I wish someone had handed me back then, built on Spring AI 2.0 since that's what we standardized on once it shipped alongside Spring Boot 4. If you're doing code migrations (framework upgrades, language ports, monolith-to-microservices splits) or architecture design work (ADRs, tradeoff analysis, system decomposition), the orchestrator-worker pattern is, in my opinion, the only sane way to do this with LLMs right now. Not because it's fancy because it fails less catastrophically. Why a single agent falls apart on this kind of work Migrations and architecture design share a trait that makes single-agent setups brittle: the task is wide, not deep. You need someone to understand the whole codebase's shape, someone to actually touch files, someone to check the output against real constraints (build passing, tests green, API contracts intact), and someone to decide what happens next based on all of that. Cram all of that into one prompt and one context window and you get one of two outcomes. Either the agent runs out of context and starts forgetting decisions it made twenty steps ago, or it quietly avoids the hard parts and hands you a plausible-looking summary instead of actual working code. I've seen both. The second one is sneakier, because it looks fine right up until you run the build. Spring Boot 4 specifically has a few landmines that make this worse than your average version bump. Jackson 3 renamed its base package from com.fasterxml.jackson to tools.jackson , so anything the model "knows" from training data about Jackson imports is now half wrong. RestTemplate isn't gone, but it's firmly in maintenance mode with RestClient as the preferred replacement, and a model trained on five years of Stack Overflow answers will happily keep reaching for the old API. Add in the deprecated methods that finally got removed after sitting flagged since 3.0, and a single agent working from a huge context window starts confidently generating code that compiles against an API surface that doesn't exist anymore. The pattern, roughly An orchestrator owns the plan. It doesn't write code (usually). It breaks the big goal into discrete tasks, hands each one to a worker scoped narrowly (a migration worker, a review worker, maybe a classpath-checking tool the worker can call), collects results, and decides whether to retry, escalate, or move on. This is close to how a real engineering team works too. A tech lead doesn't personally rewrite every file. They assign, review, and re-route. Before you reach for an LLM at all, it's worth saying: a decent chunk of a Boot 3-to-4 migration is mechanical, and OpenRewrite already has recipes that handle it. UpgradeSpringBoot_4_0 and friends will fix package renames, update deprecated config properties, and bump a lot of boilerplate without any model involved. I run that first, always. The agents come in for the parts that aren't mechanical: the fifteen-year-old controller where a deprecated method was doing something subtle and nobody remembers why. For the actual orchestration, I didn't reach for a graph framework like LangGraph or its Java port, LangGraph4j. In a Spring shop, it's simpler to keep the orchestrator as a plain Java class with an explicit state object, and let Spring AI 2.0's ChatClient handle the model calls for each worker. You get the retry and routing logic in code you can actually step through, not a DSL you have to learn on top of Spring. If your workflow grows past a handful of steps and you start wanting visual graphs and built-in checkpointing, LangGraph4j is worth a look. For most migration work, I haven't needed it yet. A minimal orchestrator-worker sample (Spring AI 2.0) Here's a stripped-down version of something close to what we run for module-by-module migration. It's not the whole thing, but it's enough to see the shape. First, the state. This is a plain record, not a chat transcript, and that distinction matters more than it looks like it should: public enum MigrationStatus { PENDING, MIGRATED, REVIEWED, FAILED, DONE } public record MigrationState( String filePath, String sourceCode, String migratedCode, String reviewNotes, MigrationStatus status, int attempts ) { public MigrationState withMigrated(String code) { return new MigrationState(filePath, sourceCode, code, reviewNotes, MigrationStatus.MIGRATED, attempts + 1); } public MigrationState withReview(String notes, boolean passed) { return new MigrationState(filePath, sourceCode, migratedCode, notes, passed ? MigrationStatus.DONE : MigrationStatus.FAILED, attempts); } } The migration worker calls the model, and I give it a tool so it can check whether a class it wants to use actually exists on the target classpath, instead of trusting its training data about Jackson 3 or RestClient : @Service public class MigrationWorker { private final ChatClient chatClient; public MigrationWorker(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem(""" You are migrating a single Java source file from Spring Boot 3.4 to Spring Boot 4. Only touch this file. Preserve public method signatures unless explicitly told otherwise. Before using any Jackson or Spring Web class, call classExistsOnClasspath to confirm it exists on the target classpath. Prefer RestClient over RestTemplate for new HTTP calls. """) .build(); } public MigrationState migrate(MigrationState state, ToolCallbackProvider classpathTools) { String migrated = chatClient.prompt() .user(u -> u.text("Source file:\n{sourceCode}") .param("sourceCode", state.sourceCode())) .toolCallbacks(classpathTools) .call() .content(); return state.withMigrated(migrated); } @Tool(description = "Checks whether a fully qualified class name exists on the target classpath") public boolean classExistsOnClasspath(String fullyQualifiedName) { try { Class.forName(fullyQualifiedName); return true; } catch (ClassNotFoundException e) { return false; } } } The review worker uses Spring AI's structured output support to get a typed result back instead of parsing free text out of a string: public record ReviewResult(boolean passed, String notes) {} @Service public class ReviewWorker { private final ChatClient chatClient; public ReviewWorker(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem("Review migrated Java code against the original for correctness. Be specific.") .build(); } public MigrationState review(MigrationState state) { ReviewResult result = chatClient.prompt() .user(u -> u.text(""" Compare the original and migrated versions of this file. Flag any changed behavior, missing null checks, or dropped annotations. Original: {original} Migrated: {migrated} """) .param("original", state.sourceCode()) .param("migrated", state.migratedCode())) .call() .entity(ReviewResult.class); return state.withReview(result.notes(), result.passed()); } } And the orchestrator, sitting one level above both workers, deciding order and handling retries: @Service public class MigrationOrchestrator { private static final int MAX_ATTEMPTS = 3; private final MigrationWorker migrationWorker; private final ReviewWorker reviewWorker; private final ToolCallbackProvider classpathTools; public MigrationOrchestrator(MigrationWorker migrationWorker, ReviewWorker reviewWorker, ToolCallbackP...