The service already worked when I joined it. A small research team had proved the core experience, and they could still change model-driven behavior quickly. That was what the PoC needed to do: turn a hypothesis into something people could use.

It was not yet ready for product operation. The database had migration files but not one trustworthy history. Calls to external APIs and the logic around them had grown together. Provider-authored content moved from planning documents and data sheets toward the database through steps that relied too much on human attention.

As another AI product moved into maintenance, this PoC became the next service to productize. Another engineer took the client and application surface. I took responsibility for the backend and infrastructure. We had to add identity, payments, deployment, and quality gates without freezing the research work that was still shaping the service.

The first boundary I could not trust was the database. A schema existed and the application could run against it, but the migration graph had split into branches that had not been reconciled correctly. Reaching the current state once was not the same as being able to reproduce how we got there.

That distinction became the theme of the work. Productizing a PoC was not a matter of rewriting it. It was a matter of expanding the part of the system that could change safely. Those changes had to be repeatable, and each repetition had to lower the cost of the next one.

01 / A DIFFERENT KIND OF SPEED

The PoC had succeeded at a different job.

It is easy to describe inherited research code as technical debt and make a rewrite sound inevitable. That would misrepresent what the code had achieved. The researchers had optimized for learning: connect an external capability, test a behavior, change the prompt or flow, and learn again. Strong abstraction was not the first acceptance criterion.

Product operation introduced a different acceptance criterion. A user account and a payment could not be treated as a temporary experiment. A content update could not depend on one person remembering an editing sequence. A database change had to behave the same way in a new environment as it did on the machine where it was first written.

I treated maintainability as a moving boundary. New work on the stable side had to meet the stronger rules. Existing work crossed that boundary when a product change gave us a reason and enough evidence to move it safely.

02 / MAKE CHANGE REPRODUCIBLE

A schema was not enough. We needed a history we could replay.

The migration tool was already present, but using a migration tool and having a reliable migration history are not the same thing. The revision topology had diverged, and the branches had not been merged into one coherent path. Automatic generation had also produced index and foreign-key operations that did not fully describe the schema we actually had.

Autogeneration was useful as a draft, not as an authority. I traced the existing schema and revision history, reconciled the branches, and reviewed the generated operations against what the database needed to become. For the history I inherited, I made the round trip executable against disposable databases: upgrade from base to head, downgrade from head to base, and upgrade again.

The database invariants I wanted before treating migrations as release artifacts
InvariantWhat it establishedWhat it did not claim
Base → headA clean environment could reproduce the current schema from versioned history.It did not prove that every production dataset would migrate without a rollout plan.
Head → base → headThe inherited revision graph and declared downgrade operations were internally coherent on disposable data.It did not promise lossless recovery of production data after a destructive change.
Generated operation reviewIndexes, foreign keys, and ordering were checked against the intended schema instead of accepted automatically.It did not make autogeneration a substitute for engineering judgment.
Versioned production changeDatabase changes could travel with a reviewed release and be exercised before production.It did not mean that a successful migration alone proved the application was healthy.

Once the history was coherent, database changes stopped being a sequence of local repairs. They became part of the same reviewed delivery path as the application. I also left a team guide for writing and reviewing later migrations, because repairing the graph once would not prevent the same practice from returning.

03 / CONTENT IS ALSO A RELEASE

Planning data had to become a build input.

The service included provider-authored scenarios and progression rules. Planners and content creators worked in documents and data sheets, but the running service needed consistent database records and build artifacts. Moving information between those forms by hand made attention part of the deployment system.

We had seen this shape before in game development. Static content is code-like even when its authors do not write application code. It has a source format, relationships, validation rules, a build result, and a release lifecycle. Treating it as a collection of manual SQL edits makes the content creator's intent difficult to reproduce.

I organized the path from authored source through validation to generated SQL and build output. The purpose was not automation for its own sake. It was to make the same source produce the same result and to move avoidable mistakes out of the release window.

What I carried over from game delivery was not a particular tool. It was the expectation that authored content should have a source of truth and a deterministic path into the product. That reduced operational mistakes and let content changes pass through the same release checks as code changes.

04 / MOVE THE BOUNDARY

We refactored while the research code kept moving.

The hardest boundary was the integration code around external APIs. Calls, response handling, and product behavior had accumulated together because that was the fastest way to test a hypothesis. Meanwhile, researchers still needed to add new behavior. Freezing one side until the other was clean was not an available plan.

I separated the calls and dependent logic a slice at a time. The cleaned side gained reliable type hints, lint rules, and tests. The experimental side could still change, but the interface between the two became more explicit. As new work crossed that interface, the tested and typed region expanded.

How I now choose between local refactoring and replacement
SituationDefault moveRequired confidence
New business logic can live behind a new boundaryImplement the new path without extending the old coupling.The boundary and data contract are clearer than the legacy internals.
New logic collides with a partial abstractionRefactor the seam, then replace the affected slice.Characterization tests preserve the behavior that still matters.
Coverage and contracts have reached a useful baselineWiden the replacement only within the area the evidence can protect.Tests, data compatibility, rollout, and failure detection support the larger change.
The safety net is still weakKeep the rewrite small and first capture the behavior that must survive.A clean design on paper is not enough to predict regression risk.

This was also a team-design problem. A style guide by itself would not change how code was added under deadline. We needed examples, tests, and interfaces that made the maintained path easier to follow. Researchers gradually contributed through the same boundaries instead of handing every new block of code to an engineer for cleanup.

Around that work we added the product obligations the PoC had not needed: identity, payments, repeatable Kubernetes delivery, CI/CD, and quality checks. The soft launch then tested more than technical readiness.

05 / CHANGE THE OPERATOR

The transfer dataset had to express the user's decision.

A later reorganization moved me to another product area. The service was then sold to a new operator, and I returned to the platform from a different direction: not to add another feature, but to separate what could move from what had to end on our side.

The transfer covered the operational code and assets, but the database could not be treated as one indivisible package. Users were asked whether their data could move. Before producing the transfer dataset, we removed the records of users who had not consented together with the dependent records tied to them.

The transfer was a sequence of different ownership decisions
SurfaceDecision
Operational code and assetsTransferred so the new operator received the service implementation and content it needed.
Consenting user dataIncluded in the transfer dataset after the consent boundary had been applied.
Non-consenting user dataRemoved with its dependent records before the transfer dataset was produced.
Existing service entry pointsChanged to explain the sunset and direct users toward the new operation.
Former operator infrastructureRetired after the transition instead of being left as an ambiguous second operation.

This distinction matters. The product did not simply disappear. A new operator continued from the transferred implementation and eligible data, while the service surface and infrastructure under the former operator were shut down. Calling both events a product sunset would hide the actual ownership change.

We had not designed the PoC around a future sale. Few teams reasonably begin there. But the boundaries built for ordinary product work—versioned schema changes, deterministic content builds, clearer integrations, tests, and repeatable delivery—also made the system easier to explain and move. Transferability was a side effect of reducing tacit knowledge before a transfer was imaginable.

06 / WHAT I WOULD START WITH NOW

The second time, I already knew which boundaries came first.

Later, a small group of us built a related consumer AI service as an independent weekend project. We began with a typed full-stack application and an automated generation pipeline. We reached a launchable state much faster than I had in the earlier product.

That is not proof that one framework or architecture won. The scope, team, and market were different, development tools had improved, and AI agents were accelerating more of the implementation work. The comparison I trust is simpler: I no longer had to discover which boundaries to create first.

I also had clearer limits. New business logic belonged behind a new boundary where possible. If it collided with a partial abstraction, I would refactor the seam and replace only the affected slice. A larger rewrite became reasonable only when tests, contracts, data compatibility, rollout, and failure detection could protect it.

I would not require every migration to downgrade perfectly, and I would not automate every repeated task. I would require an irreversible database transition to say so explicitly. I would automate content work when errors became real, manual effort grew materially, or a known sequence kept returning—and sooner when one mistake had a large user-facing cost.

That last answer matters more as AI agents take on larger development and operational tasks. Context that once lived in a person's memory now has to be available as versioned decisions, executable checks, and explicit stopping conditions. Those artifacts help the current team first. They also make the system more legible to the next engineer, the next operator, or an agent working under the same constraints.

I would not start the next PoC by building every production mechanism in advance. I would start by deciding where repeatability begins. Each time a product responsibility crosses that line, I would give it an explicit artifact and a testable path. The goal is not to predict every future owner. It is to make the next safe change cheaper than the last one.