DEV Community

Cover image for Forking a SaaS Codebase: What to Reuse, What to Delete, and What Not to Abstract
Jamie Cole
Jamie Cole

Posted on

Forking a SaaS Codebase: What to Reuse, What to Delete, and What Not to Abstract

Forking a production SaaS is deceptively easy. The repository copies cleanly, the application starts, and the existing infrastructure still works.

Then the inherited assumptions begin to show up.

A route still describes the old product. An email contains the old identity. A reusable-looking component imports page-specific data. A new feature needs an exception because the original workflow was designed around a different user task.

I ran into this while using a working AI generation application as the foundation for PicVane, a second, image-first product. The original application had real infrastructure worth keeping: authentication, uploads, credits, asynchronous tasks, provider integrations, polling, result storage, and account history.

It also had an entire product layered on top of that infrastructure.

The useful question was not, "How much code can I reuse?" It was:

Which decisions are stable capabilities, and which decisions only made sense for the first product?

That question led to a process based more on deletion and boundaries than extraction.

Classify the code before trying to reuse it

I found it useful to divide the inherited code into three buckets.

Bucket Examples Default action
Engine capability Authentication, uploads, credits, task lifecycle, provider submission, polling, results Preserve and verify
Product surface Routes, navigation, copy, examples, SEO structure, public assets Redesign for the new product
Accidental coupling Brand names inside services, page data imported by shared components, workflow-specific helpers in generic modules Remove or move behind a boundary

This classification prevented two opposite mistakes.

The first mistake is rewriting everything because the new product needs a different identity. That throws away expensive operational knowledge.

The second is treating every finished component as reusable. A page can be well-built and still be the wrong page for the next product.

The distinction is not about code quality. It is about ownership.

Delete product consumers before extracting abstractions

My first instinct was to make more things configurable. That sounds safe: add a brand option, introduce variants, move strings into a registry, and keep both behaviors available.

In practice, configuration can preserve the wrong boundary.

I got a clearer result by deleting the new product's irrelevant consumers first. That included public routes, model pages, effect pages, page-specific sections, datasets, tests, and workflows that belonged to the original product's story.

A building under renovation with its structure exposed

Deletion became a dependency-mapping tool. Each removal answered a useful question:

  1. Does this file represent a capability or a presentation decision?
  2. What imports it?
  3. Does a supposedly shared module depend on it?
  4. Does removing it break infrastructure, or only the old public surface?
  5. Is the remaining abstraction still useful with only one real consumer?

This order matters. If I had extracted abstractions before removing the old surface, I would have generalized requirements that no longer existed.

Tests were especially useful here, but not because a green suite proved the new product was correct. Tests exposed hidden ownership. A failing test often showed that a product-specific assumption had leaked into a shared module, or that a "global" component was only global inside the old route tree.

Share lifecycle contracts, not page trees

The strongest reusable part of the application was the generation lifecycle.

A generation request still needed to move through authentication, access checks, task creation, provider submission, status updates, result persistence, and failure handling. That sequence remained valuable even when the public pages changed completely.

The page tree did not deserve the same treatment.

The new product started with three clear public entrances:

  • an image editor for changing an existing image;
  • an image generator for creating from text or a reference;
  • a video generator for starting from text or an image.

Those pages needed different inputs and defaults. They did not need different implementations of uploads, credits, task state, history, and results.

This became the working rule:

Let interfaces vary by user task. Keep operational invariants behind one contract.

That rule is more precise than "make the component reusable." It identifies what must remain consistent and allows the visible product to diverge.

Symmetry is useful when it prevents drift

The original video path already had a runner and provider adapters. The image path was less uniform, so I brought it behind a similar boundary before adding more image models.

The simplified shape looked like this:

interface GenerationAdapter<Request, ProviderTask, Result> {
  submit(request: Request): Promise<ProviderTask>;
  getStatus(task: ProviderTask): Promise<"pending" | "complete" | "failed">;
  getResult(task: ProviderTask): Promise<Result>;
}

interface GenerationRunner<Request, Result> {
  run(request: Request): Promise<Result>;
}
Enter fullscreen mode Exit fullscreen mode

This is illustrative pseudocode, not a copy of the production implementation. The important part is the direction of dependency:

  • public routes know public model IDs and supported controls;
  • the runner owns the task lifecycle;
  • adapters translate between the product contract and provider-specific requests;
  • provider identifiers and secrets do not leak into client routes;
  • the same effective parameters drive the interface, credit calculation, and server request.

The image and video branches still use separate controllers. Their inputs and capabilities are different enough to deserve clear types. I did not put both into one large workspace state object just to reduce the file count.

Symmetry helped at the lifecycle boundary. It would have hurt if it erased the media-specific behavior above it.

I deliberately avoided a multi-brand runtime

Once two products share an ancestry, a multi-brand platform can feel inevitable.

I chose separate repositories with a thin site configuration instead.

A runtime brand switch would have introduced questions that the products did not need to answer:

  • Which routes exist for each brand?
  • Which message keys are shared?
  • Which model is visible where?
  • Which navigation and SEO sections belong to which deployment?
  • What happens when one product needs a breaking change?
  • How many conditionals can appear before every test needs a brand matrix?

Those are reasonable questions for a real multi-tenant platform. They are overhead for two products that need to diverge.

I would reconsider that decision if the products shared the same deployment, database, release cadence, and operational ownership, or if changes repeatedly had to be ported in both directions. Without that evidence, a thin configuration plus explicit forks is easier to understand.

This is an important limit on reuse: sharing source code is not always cheaper than sharing a well-defined capability.

A small public surface is an architecture test

The new product's initial route tree was intentionally small. That was not only a product decision. It was a test of the boundary.

If the image editor, image generator, and video generator could all consume the same task infrastructure without restoring old page logic, the engine had been separated successfully.

If a new page required copying billing code, adding another upload protocol, or teaching the server about a route-specific model name, the boundary was incomplete.

This made the first routes useful integration tests. They forced the system to answer practical questions:

  • Can each page select only models that support its input mode?
  • Do displayed controls match the request accepted by the server?
  • Does the credit estimate use the same effective parameters as submission?
  • Does every route produce the same durable task history?
  • Can failures be handled without page-specific recovery logic?

The goal was not identical pages. It was consistent consequences.

A practical checklist for the next fork

If I repeat this process, I will start with the following sequence.

  1. Write down the new product's primary user tasks. Do this before deciding what to preserve.
  2. Inventory the public routes and product-owned data. Routes often reveal product coupling faster than utility folders do.
  3. Mark stable operational capabilities. Authentication, billing, tasks, storage, and provider boundaries are common candidates, but verify each one.
  4. Delete irrelevant leaf consumers. Remove routes, blocks, data, media, and tests that only describe the old product.
  5. Follow the failures inward. Use compile errors and focused tests to find accidental dependencies on the removed surface.
  6. Extract only repeated capabilities. Do not create a registry or factory for a second hypothetical consumer.
  7. Keep domain types clear. Share lifecycle behavior without collapsing different media or workflows into one giant state model.
  8. Build the smallest complete public surface. Use it to verify that user-facing differences can sit on top of one operational contract.
  9. Separate source verification from runtime proof. A clean typecheck does not prove provider calls, billing, email delivery, or the user experience.

The last point is easy to lose during a large cleanup. Static checks can prove that the new boundary compiles. They cannot prove that the product works end to end.

Reuse the knowledge, not every artifact

The biggest benefit of the first product was not its collection of finished pages. It was the operational knowledge encoded underneath them: how tasks survive asynchronous providers, how access and credits are decided, how results are stored, and how failures remain recoverable.

That knowledge deserved to move.

The old navigation, page catalog, examples, and product story did not.

Forking became much easier once I stopped measuring reuse by the percentage of files retained. The more useful measure was whether the second product could inherit reliable behavior without inheriting the first product's identity.

Sometimes reuse is extraction.

Sometimes it is a stable contract.

And sometimes the most important reuse decision is knowing what to delete.


Disclosure: This article is based on my own implementation experience and was reviewed against the system's actual architecture. AI was used to help organize the structure and edit the English draft.

Top comments (0)