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 because that is what a half-grown system tells you to do. Move the work off the old orchestration engine. Port it to the newer one. Delete the old engine. Make the architecture clean again.

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 had a resume-after-pause mechanism I needed. It also printed a warning at startup telling me that the part I wanted was experimental upstream.

That warning changed the decision.

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 was 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

The port cost almost nothing, which felt good for about five seconds.

Then I had to admit the uncomfortable part. It had nothing to do with ADK being especially 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.

That sounds like architecture-speak, but it is very practical. Loose payloads get validated and turned into my types on the way in. Results get 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:

  • 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.
  • model_validate on the way in and model_dump on the way out are the whole 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, instead of a dict I hope has the right keys.
  • It generates JSON Schema from the same model. The object that guards the boundary is also the object I hand to the LLM as the structured-output contract.

I am not claiming Pydantic is the only answer. Plenty of tools can hold a shape. For this job, though, runtime validation plus JSON Schema plus the fact that most agent frameworks already understand it was the boring fit. Boring mattered here.

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.