I Planned a Migration and Shipped a Matrix Instead

I planned to move my system off one agent framework and onto a newer one. I never finished, on purpose. Running both engines behind one seam, with a test that proves they match on every commit, turned a portability claim into a property I can check.

A masterless samurai holds up a book titled AI Engineering, flanked by two retainers, composited after Akira Kurosawa's Yojimbo (1961)
After Yojimbo (1961), dir. Akira Kurosawa · Toho

I scheduled a framework migration. Move the system off one orchestration engine and onto a newer one, port the work, delete the old engine. A clean cutover.

I never finished it. I run both engines now, in production, and a test forces them to behave the same on every commit. The half-finished migration turned out to be the feature.

The plan

The system runs agentic work through an orchestration engine. Nodes, edges, a bit of state passed between them. I had been running on LangGraph for a while, and Google’s Agent Development Kit, ADK, looked like a better fit for where the work was going. So I did what you do. I planned the cutover. Port the nodes, move the state, run ADK, retire LangGraph.

The assumption underneath that plan is that you migrate once. You pay the cost, you flip the switch, you move on.

Why I stopped

ADK has a resume-after-pause mechanism I needed. It is also flagged experimental upstream, and it prints a warning at startup that says so. That warning is the whole story.

If I finished the migration, the only engine my system runs on would be sitting on top of an API that the people who wrote it are telling me might move. My stability would be married to their churn. The next breaking change in an experimental module would be my outage, on a client’s account, not a line in someone’s changelog.

There was a second cost, the quieter one. Finishing the cutover would not have added one thing a customer could see. It is ceremony: days of porting and re-testing to land exactly where I already was, on one engine instead of two. Those same days spent on the parts a client actually feels, the correctness work and the guardrails, compound instead.

Finishing was the default. The reflex is to treat a half-done migration as a mess to clean up later. Stopping on purpose was the decision. So I did not stop adopting ADK. I stopped making it the only one.

I kept both engines

LangGraph and ADK both run behind a single seam. The same end-to-end scenario runs through each of them, and a test fails the build if their results diverge. “Swap it out” became “run them in parallel and let the test enforce that they match.”

This is the part I did not expect. A document that says your architecture is portable is marketing. Two engines passing the identical test from the same commit is proof. I used to assert portability in an ADR and hope it stayed true. Now a test re-checks it every time I push. The claim became a property.

Why the port was almost free

Here is the uncomfortable truth. The port cost almost nothing, and that had nothing to do with ADK being nice to work with. It was because I had already paid the bill, months earlier, without knowing I was paying it for this.

The nodes hold no business logic. They are thin. They call into a core that does the actual work.

Every external write goes through a port, not a direct SDK call. The engine does not reach out to a CRM or a database itself. It asks a port to.

Every human approval is a row in a database, not a framework primitive. The gate does not live inside one engine’s pause feature. It lives in a table both engines read.

And the state that flows between nodes is a plain model in the framework-free core. Not the engine’s own state object. My type.

That last one is the quiet hero, so I want to be specific about it.

The wall: one typed boundary

The thing that lets two engines share one core is a boundary. The domain speaks only its own types. Nothing in the core imports either engine’s SDK. Loosely-typed payloads get validated and coerced into my types on the way in, and serialized back out on the way to the engine. The model class is the contract, and both engines have to meet it.

I build that wall with Pydantic v2. The skeleton looks like this, with the names made generic:

from pydantic import BaseModel

# The domain speaks its own types.
# No engine SDK is imported in this file. That is the point.
class WriteRequest(BaseModel):
    run_id: str
    tool: str
    args: dict

class WriteResult(BaseModel):
    ok: bool
    detail: str | None = None

def handle(payload: dict) -> dict:
    request = WriteRequest.model_validate(payload)  # validate + coerce at the seam
    result = do_the_work(request)                   # framework-free domain logic
    return result.model_dump()                      # serialize back to the engine

A few reasons I reach for Pydantic v2 here specifically, since the boundary is the whole game:

  • It validates at runtime, not just in the type checker. A malformed payload fails at the seam with a precise error, before any business logic runs. Static hints do not catch a bad value an engine actually handed you. This does.
  • The validation core is written in Rust, so checking every payload that crosses the boundary is cheap enough that I never think about the cost.
  • model_validate on the way in and model_dump on the way out are the entire in-and-out story. The same two calls in every adapter.
  • Discriminated unions let me model “this is a tool call, or a node result, or a refusal” as real, separate types the validator enforces, instead of a dict I hope has the right keys.
  • It generates JSON Schema for free. The same model that guards the boundary emits the schema I hand to the LLM for structured output. The wall and the model’s contract cannot drift, because they are the same object.

Plenty of other tools can hold a shape. Vanilla dataclasses with hand-rolled validation, attrs and cattrs, msgspec if you want raw speed, marshmallow, TypedDict with typeguard, or Protobuf and betterproto if you need a cross-language contract. I am not claiming Pydantic is the only answer. For an anti-corruption layer, though, validate-at-the-boundary plus free JSON Schema plus the fact that most agent frameworks already speak it is exactly the combination the job needs.

What it taught

I set out to replace one framework with another and never finished, on purpose. The unfinished migration is the strongest architecture test I own. Two engines, one core, a test that re-proves portability on every commit.

The lesson is not “always run two of everything.” That would be its own kind of waste. The lesson is where the work goes. Pay the discipline up front. Thin nodes, ports for every external call, human gates as database rows, your own types at the seam. Do that, and the framework stops being a decision you cannot take back. It becomes a driver you can swap, double up, or pin in place while you wait for it to grow up.