Why Large Vibe-Coded Apps Become Incoherent
Why AI-built apps lose architectural shape as they grow, and how to give agents a map they can follow.
Small vibe-coded apps often feel cleaner than expected. There are fewer files, fewer product concepts, and fewer ways for one change to contradict another. An agent can read the surrounding code, infer the local convention, and keep moving.
That stops working once the product gets large enough that "nearby code" is no longer a reliable map. The agent still makes locally reasonable edits, but the system starts to lose its shape. Routes contain business logic. One data shape exists in the importer, another in the dashboard, and a third in a cleanup script. New endpoints land in the file that already has endpoints. New scripts land in the directory that already has scripts.
This is what we mean by incoherence. It is not complexity—every nontrivial product is complex. It is what happens when a codebase outgrows its implicit architecture and nobody stops to draw the next set of lines.
The Easiest File Becomes the Architecture
In one reference app we reviewed, the dashboard server had grown past 4,000 lines. The exact domain does not matter, and the details are sanitized here. The important part is the shape: one server file handled static assets, CORS, authentication checks, project access filtering, SQL queries, analytics, pipeline routes, graph traversal, admin endpoints, usage and cost APIs, and page routes.
That file was not stupid. It was useful. It was also the easiest place for every future change to land.
The same repo had a flat top-level scripts directory with dozens of one-off files for imports, backfills, cleanup, deduplication, scoring, reports, migrations, sync, and production chores. Again, no single script was the problem. The problem was that the directory did not say which workflow owned which behavior, which scripts were safe to run, which were historical, and which were part of the production operating model.
A third smell came from the data pipeline. Structured data existed in two places: frontmatter and document body tables. The importer read frontmatter into the database, and the dashboard rendered the database shape. Body tables could look authoritative to a human or an agent, but they did not reliably reach the dashboard. That is how a codebase becomes incoherent without any dramatic failure: two sources look real, one source actually drives the product, and the repo does not make the distinction impossible to miss.
AI agents make this worse because they optimize for nearby evidence. A 4,000-line server file looks like the approved place for server logic. A flat scripts directory looks like where scripts belong. A stale planning doc that mentions a data shape looks like current architecture.
Humans eventually develop a gut sense that something is off—the file is too big,
the naming has gotten inconsistent, the scripts directory has become a junk
drawer. Agents do not get that feeling. They see framework-default folders like
routes/ and scripts/ and treat them as architecture, even when those folders
carry no ownership information. Prompt instructions and README notes can nudge
behavior, but they are not boundaries. The file tree is what actually shapes the
next edit.
What Incoherence Looks Like
If you can't answer three of these in 30 seconds, the app has already gone incoherent:
- Where does this feature live?
- Which module owns this data shape?
- May this route query the database directly?
- Is this script a migration, a report, a repair tool, or a production job?
- Which source is canonical when docs, database rows, and rendered UI disagree?
What makes this hard to catch is that the app keeps working. Manual testing passes. The demo looks fine. Individual PRs are reasonable. The decay is visible in one concrete place: the same logic living in several files and drifting apart. In the app we reviewed, user scoring was normalized once in the API route, patched a second way in a standalone repair script, and normalized a third way for display — and two of the three had already been fixed independently after the same customer complaint, with nobody noticing the third was still wrong.
The same escape drawn as a "before" tree. User scoring lives in the route, a repair script, and a document table that looks authoritative but is not the shape the product actually validates:
src/
app/api/users/[id]/route.ts # reads request, queries SQL, computes score
db.ts # exports raw client and loose UserRow
ui/user-score.tsx # normalizes "score" again for display
scripts/
import-users.ts # accepts CSV shape
fix-user-scores.ts # patches database rows directly
docs/
user-score-table.md # has another field listThe route, script, and docs all carry pieces of the same product concept. A new agent asked to fix user scoring has many plausible places to edit. It may add a route-level helper, patch the importer, create another script, or change the rendered shape. Without an explicit architecture, all of those edits can look consistent with the repo.
A healthier shape moves the product concept into domain ownership and leaves each entry point as an adapter:
src/
app/
api/
users/[id]/route.ts # validates HTTP, calls the domain
domains/
users/
score.ts # score policy and product language
schema.ts # UserInput and UserScore
repository.ts # persistence contract
service.ts # use cases routes and jobs call
platform/
db/
jobs/
user-import/
run.ts # parses CSV, calls @domains/users/service
user-score-repair/
run.ts # repair workflow, same domain service
docs/
adr/
0003-user-score-ownership.mdThe point is not to worship folders. The point is to make ownership visible. Routes adapt HTTP to the application. Domain services hold product behavior. Repositories own persistence. Jobs have named workflows. Shared platform code is boring and narrow. When an agent opens the repo, the file tree tells it what kind of change it is allowed to make.
Boundaries Need to Be Executable
Architecture that lives only in a prompt gets forgotten by the next session. Architecture embedded in types, import rules, and build checks sticks around.
TypeScript gives you two practical levers here. For larger monorepos,
project references
split the codebase into separate compilation units with explicit dependency edges
between them. For a typical Next.js app, paths in tsconfig.json is the
lighter option—named aliases so your imports read @domains/users/service
instead of ../../../domains/users/service. One caveat worth knowing: paths
only affects type-checking resolution. It does not rewrite emitted JavaScript, so
your bundler needs to understand the same aliases. Next.js handles this
automatically, but other setups may need additional configuration.
A modest alias setup looks like this:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@app/*": ["src/app/*"],
"@domains/*": ["src/domains/*"],
"@platform/*": ["src/platform/*"]
}
}
}That alone is not a boundary. It is a vocabulary. You still need rules that say
which vocabulary can be used from which layer. ESLint's
no-restricted-imports
can reject forbidden import paths, and
@typescript-eslint/no-restricted-imports
extends the rule for TypeScript import syntax.
export default [
{
files: ['src/domains/users/**/*.ts'],
rules: {
'no-restricted-imports': 'off',
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@app/*', '@jobs/*', '@platform/db/raw-client'],
message:
'User domain code owns scoring; routes, jobs, and raw DB clients must adapt to it.',
},
],
},
],
},
},
];For teams that want a more explicit architecture linter,
JS Boundaries provides a
boundaries/dependencies rule for defining allowed dependencies between element
types. The tool matters less than the behavior: a route should not silently
become a data layer, and a cleanup script should not become the canonical product
model.
Decisions Need a Place to Live
Code structure and lint rules cover a lot, but some decisions are not obvious from the code itself. Why does frontmatter own structured profile fields? Why are body tables narrative-only? Why are routes forbidden from calling the raw database client? Those are architectural decisions, and they need to live somewhere more durable than a comment or a Slack thread.
An architecture decision record is a lightweight way to preserve them. Google Cloud's ADR overview frames ADRs around context, requirements, options, the accepted decision, and the reasons behind it. That is enough for most product teams. ADRs pair naturally with the committed rulebook we describe in Your App Needs a Constitution: the constitution names the rules, and ADRs record why each one exists.
# 0003 Keep Structured Profile Data In Frontmatter
## Status
Accepted
## Context
The dashboard renders structured profile data from the database. The importer
reads frontmatter into that database shape. Body tables are useful for narrative
review but do not drive the product.
## Decision
Structured fields must be written to frontmatter first. Body tables may repeat
human-readable summaries, but they are not canonical.
## Consequences
- Importers validate frontmatter before writing.
- Agents may not add new structured fields only to the body.
- Dashboard changes must update the schema and importer together.This is the difference between "the agent should understand the architecture" and "the repo teaches the architecture every time the agent runs."
What Post Code Labs Looks For
In a diagnostic, we do not score architecture by asking whether the repo has pretty folders. We look for evidence that the codebase has boundaries a future change can obey. These checkpoints are part of the full Post Code Labs consultation, which scores architecture as one of its twelve categories.
Useful evidence includes:
- Entry points are thin and delegate to named services.
- Domain modules own product language and data shapes.
- Import direction is enforced by lint or project references.
- Database and API boundaries validate unknown input before it becomes product state.
- Scripts are grouped by workflow, documented, and either tested or clearly marked as one-off repair tools.
- ADRs explain non-obvious decisions that agents are likely to undo.
A "pass" does not mean the app is perfectly modular—it means the next change has an obvious home and checks catch boundary violations. "Partial" is the most common result we see: the team has the right folders and some naming conventions, but nothing enforces them, so agents slowly erode the structure. A "gap" means the repo has no visible architecture at all: human memory, prompt discipline, and a handful of overloaded files are all that hold it together.
The remediation plan should be narrow. Large incoherent apps are rarely fixed by asking an agent to "refactor the architecture." That produces a giant diff with unclear behavior changes — the failure mode we take apart in Stop Asking the Agent to Refactor Everything at Once. The better plan is to choose one boundary, make it real, and move the next few changes through it.
What to Do Next Monday
Start with an inventory, not a rewrite.
- List the five largest files and the five busiest scripts. For each one, write down the responsibilities it currently owns.
- Name three domains in the product. Do not use framework words like "routes" or "components"; use product words like "billing", "reports", "profiles", or "projects".
- Pick one painful dependency direction and enforce it with an import rule. For example: domain code cannot import route modules, UI code cannot import the raw database client, or scripts cannot import unpublished internals.
- Extract one route family behind typed inputs and outputs. Leave behavior the same; move the ownership boundary first.
- Write one ADR for the decision you just made. Include the context, alternatives, decision, and consequences so the next human or agent can follow it.
Every growing app goes through this. Incoherence is not a sign that the team did something wrong—it is a sign that the product outgrew its original assumptions. The fix is not a rewrite. It is giving the repo enough structure that the next change, whether it comes from a human or an agent, has a clear place to land.
References
TypeScript: project references
[typescriptlang.org]ESLint:
[eslint.org]no-restricted-importstypescript-eslint:
[typescript-eslint.io]no-restricted-importsJS Boundaries: dependencies rule
[jsboundaries.dev]dependency-cruiser: validate and visualize dependencies
[github.com]Google Cloud: Architecture decision records overview
[docs.cloud.google.com]