# Vibe Coding a Codebase You Can Still Change in Three Months

*Published 2026-09-23 · Vibe Coding, Maintainability, Clean Code, AI*

> Vibe coding makes every generation a fresh one-off, so the codebase drifts into inconsistent patterns, dead code, and no structure. How to keep it maintainable.

Source: https://www.yashthakur.com/blog/vibe-coding-maintainable-codebase

---

**The short answer:** the real risk in vibe coding isn't a broken feature — it's that every generation is a fresh one-off. The agent has no memory of yesterday's choices, so it solves the same problem five different ways, duplicates logic, and leaves dead code behind. The app works, but in three months nobody — including you — can change it safely. The fix is to impose the consistency the agent won't: hand it your conventions up front, enforce one way to do each thing, delete what you replace, and review for consistency, not just correctness.

A vibe-coded project rarely dies from a bug. It dies from entropy. Each prompt gets answered in a vacuum: the agent doesn't know how you fetched data in the last file, so it picks a new way here. Multiply that across a few hundred prompts and you get a codebase that passes its tests and demos beautifully but has no coherent shape — three state-management styles, four error-handling idioms, copies of the same helper under different names, and whole modules nobody dares touch. The thing still runs. That's the trap. "It works" is not the same as "I can safely change it," and vibe coding makes the gap between those two enormous.

## The drift is the real cost

When a human writes a codebase, consistency emerges for free: they remember the pattern they used an hour ago and reuse it. An agent remembers nothing between prompts. Left unguided, it generates locally optimal, globally incoherent code — each piece sensible on its own, the whole an archaeological dig of every style it has ever seen in training.

```TS
// Prompt 1: the agent used fetch + manual state
const [user, setUser] = useState(null);
useEffect(() => {
  fetch(`/api/users/${id}`).then((r) => r.json()).then(setUser);
}, [id]);

// Prompt 2, different screen: now it's axios in a try/catch
try {
  const { data } = await axios.get(`/api/users/${id}`);
  setUser(data);
} catch (e) { console.log(e); }

// Prompt 3, a third screen: now it's react-query
const { data: user } = useQuery(["user", id], () =>
  fetch(`/api/users/${id}`).then((r) => r.json())
);
```

Three screens, three data-fetching stacks, three error conventions (none, a swallowed `console.log`, and library defaults). None is wrong. Together they're unmaintainable: a change to how auth errors are handled now has to be made — and tested — in three unrelated shapes. The cure is to decide the shape once and make the agent conform to it.

```TS
// A single data-access convention the agent is told to always use.
const { data: user } = useQuery({
  queryKey: ["user", id],
  queryFn: () => api.get(`/users/${id}`), // shared client, shared error handling
});
```

## Give the agent your conventions up front

The highest-leverage thing you can do is write the conventions down and make the agent read them before every session — a `CONVENTIONS.md`, a steering file, a standards doc, whatever your tool calls it. Name the folder structure, the naming rules, the one approved way to fetch data, handle errors, and manage state. The agent is a brilliant pattern-matcher with amnesia; the doc is its memory. Without it you're negotiating the architecture from scratch on every prompt and losing a little each time.

## One way to do a thing, not five

Pick one data-fetching approach, one form library, one error pattern, one styling method — and reject generations that introduce a sixth. This is the single rule that most slows the drift. When the agent reaches for a new library "because it's good for this case," the cost isn't the library; it's that your codebase now has two ways to do the same thing and every future reader has to learn both. Constraints are what make a codebase legible.

## Delete dead generations — don't let them pile up

Vibe coding produces a lot of discarded attempts: the component you regenerated, the helper you replaced, the approach you abandoned two prompts ago. If you don't delete them, they stay — imported nowhere, or worse, imported somewhere you forgot. Dead code isn't neutral. It's a landmine for the next person, who can't tell what's live, and for the agent, which will happily copy the dead pattern because it's still in the file it was shown. After every "actually, do it this way," delete the version you walked away from.

## Keep real seams, and make deletion the bar

Maintainability isn't about perfect code; it's about **changeable** code. Two habits buy most of it. First, keep modules behind real seams — a module should expose a small interface and hide its guts, so you can swap or rewrite it without the rest of the app noticing. Agents love to reach across those seams and wire internals together directly; push back, because every such reach is a future change that now touches ten files instead of one.

Second, adopt the sharpest quality bar I know: **code that is easy to delete**. If a feature is a tangle you can't remove without archaeology, it was built wrong. Code you can cut cleanly is code with clear boundaries and no hidden dependents — exactly the properties that also make it easy to change. The same discipline I lay out in [writing clean, maintainable code](/blog/clean-code) applies doubly to generated code, because the agent optimizes for a working result, never for how painlessly the result comes back out.

## Review for consistency, not just correctness

Here's the review habit that catches what tests can't. A passing test proves the code works; it says nothing about whether it matches the rest of the codebase. So when you review a generation, don't only ask "is this correct?" — ask "is this the way we already do this here?" A correct-but-divergent change is how drift enters, one green diff at a time. This is a frontend-of-mind version of the point I make in [code review is culture, not a checklist](/blog/code-review-culture): the review's real job is keeping the codebase coherent, and with an agent that is the whole job.

## Use agents and skills deliberately

- **A coding-standards skill**, fed your `CONVENTIONS.md`, reviewing each generation specifically for divergence from the house style — the exact consistency check a generalist agent won't do for itself.
- **An api-and-interface-design skill** pointed at your module boundaries, to confirm the seams are real and the agent isn't quietly coupling internals that should stay hidden.
- A dedicated delete pass: an agent instructed to find unreferenced code, duplicate helpers, and abandoned generations — because the agent that made the mess is well suited to being told to go find and remove it.

A specialized agent reviewing a generalist agent's output catches the exact mistakes vibe coding introduces: the sixth way to fetch data, the leaked seam, the file nobody imports. The generalist optimizes for "it works"; point a specialist at "it's consistent and easy to delete" and you close the gap between a demo and a codebase you can live in.

This is one of the vibe-coding deep-dives — start from [the vibe-coding precautions overview](/blog/vibe-coding-precautions).
