← Writing

From Prompting to Architecture

Published
Reading
18 min
Contents · 15 sections
  1. Why AI tools are good at local code and weak at global structure
  2. It only sees what’s in the window
  3. It doesn’t own decisions
  4. It optimises for “works now”
  5. What “architecture” means for a small app
  6. Skill 1: Draw boundaries and modules
  7. Skill 2: Keep business logic away from frameworks
  8. Skill 3: Name things on purpose
  9. Skill 4: Write decisions down, then feed them to the AI
  10. A one-page architecture doc
  11. Architecture Decision Records
  12. Skill 5: Ask for options and trade-offs, not code
  13. How to build this skill while using AI
  14. A checklist
  15. Key ideas

In the summer of 2010, the football World Cup kept knocking Twitter over.

Every goal, every penalty and every red card sent a wave of tweets, and the site went down for short periods again and again. Twitter’s engineers worked through the nights to find speed-ups. In their own words, written up on the company’s engineering blog in 2013, those gains “were quickly swamped” by growth, and they had “started to run out of low-hanging fruit.”

So they stepped back and looked at the codebase. What they found is worth reading slowly:

  • It was one of the world’s largest Ruby on Rails apps, with about 200 engineers contributing to it.
  • Everything lived in one codebase, “from managing raw database and memcache connections through to rendering the site and presenting the public APIs.”
  • It was getting hard for any single engineer to understand how the whole thing fit together, and hard to split the work between teams.
  • They had been pushed into an “optimization corner,” trading “readability and flexibility of the codebase for performance.”

The result was a multi-year re-architecture. Part of it was about raw speed, moving to the JVM. But a big part was about structure. They wanted “cleaner boundaries with ‘related’ logic being in one place,” and small teams that could ship changes without waiting on everyone else.

It worked. In August 2013 the site took a one-second peak of 143,199 tweets per second during a TV screening in Japan, and stayed up.

Twitter’s early code was not vibe coded. It was a startup moving fast with the tools it had. But the shape of the problem is the same one I see in AI-built apps today: code written quickly, one feature at a time, with nobody owning the shape of the whole thing. The difference is that AI lets you reach that shape in weeks instead of years.

This is part 7 of After Vibe Coding. The skill is architecture: not the diagrams, but the habit of deciding where things go and why.


Why AI tools are good at local code and weak at global structure

Ask an AI coding tool for a function that parses a date, validates a form or calls an API, and you’ll usually get something decent. Ask it to keep a 40,000-line app coherent across six months of features, and things drift.

That isn’t because the model is “bad at architecture.” It’s because of how you use it.

It only sees what’s in the window

A model works on the text it has been given: your prompt, some open files, whatever the tool decided to pull in. Claude Code’s own documentation puts it plainly: “Each Claude Code session begins with a fresh context window.” Other tools work the same way. Unless you give it the bigger picture, it doesn’t have one.

So when you ask for “add a discount for weekly stays,” the AI finds the nearest place that seems to calculate a price and adds code there. If there are three places that calculate prices, it might find one of them. If there’s a helper that already does this, it might not see it and write a new one.

There’s some data on this. GitClear analysed 211 million changed lines of code from 2020 to 2024 and reported that 2024 was the first year copy-pasted lines outnumbered “moved” lines (their signal for refactoring), and that blocks of five or more duplicated lines rose about eightfold during 2024. GitClear is a code-analytics company and these are its own measures, so treat the exact numbers with care. Its explanation matches what I see in reviews: an assistant is less likely to suggest reusing a function elsewhere in the codebase, partly because of its limited context.

It doesn’t own decisions

A good engineer on a team remembers why things are the way they are. “We store money as integer cents because we got burned by floating point.” “Bookings never import from billing directly.” That memory is the architecture.

An AI tool has no stake in yesterday’s decision. If your prompt today pulls in the opposite direction, it will happily follow the prompt. It isn’t stubborn, which is great for speed and terrible for consistency.

It optimises for “works now”

Every prompt has a goal: make this feature work. The fastest way to make a feature work is usually to put the logic right where the request comes in, import whatever is needed, and move on. Do that fifty times and you have Twitter’s “one codebase” problem, just smaller and faster.

None of this means AI can’t help with architecture. It can be a very good thinking partner. But someone has to hold the map, and that someone is you.


What “architecture” means for a small app

Forget the big diagrams. For a team of one to five people, architecture is a handful of decisions:

  1. Where things go. Which folder, which module, which layer.
  2. Who may talk to whom. Which parts are allowed to import which.
  3. What things are called. One name per concept, used everywhere.
  4. Why we chose this. Written down, short, findable.

If you get those four right, the code inside each piece can be messy for a while and you’ll still be able to fix it. If you get them wrong, even clean functions won’t save you.

Here’s the picture I keep in my head.

TANGLED route page job webhook database Stripe email price() price() price() Everything imports everything. One rule, copied three times. LAYERED routes · pages · jobs · webhooks use cases: createBooking() domain rules quoteStay(), once database Stripe email Arrows point inward. Outside tools plug in behind interfaces the inner layers define.
The same features in two shapes: every entry point wired to every tool, or thin entry points calling use cases, with business rules defined once in the middle.

The left side is what you get by default when each prompt solves its own problem. The right side is what you get when someone decides the shape first and keeps the AI inside it.


Skill 1: Draw boundaries and modules

A boundary is a line in the code that says “things on this side don’t reach across, except through this door.”

I’ve written a whole post on starting with a modular monolith, so I won’t repeat it. The short version: one deployable app, split into a few modules named after business areas (bookings, billing, messaging), each with a public index.ts, its own tables, and no reaching into another module’s internals.

What matters for AI-assisted work is enforcement. A rule in your head doesn’t exist for the AI. A rule in a linter does. When the AI breaks it, CI fails, and the AI sees the error and fixes it. That’s one of the best ways to “talk” to an AI tool, the same idea as using tests in part 6.

A small ESLint rule that keeps the core of your app free of frameworks and vendors:

// eslint.config.js (flat config)
export default [
  {
    files: ["src/**/domain/**/*.ts"],
    rules: {
      "no-restricted-imports": [
        "error",
        { patterns: ["next", "next/*", "@prisma/client", "stripe"] },
      ],
    },
  },
];

Now if an AI session decides to “just quickly” query the database from inside a pricing rule, the lint step says no.


Skill 2: Keep business logic away from frameworks

Business logic is the part of your app that would still be true if you changed every tool: “weekly stays get 10% off,” “a cancellation within 48 hours is non-refundable,” “an invoice can’t be edited after it’s sent.”

Frameworks are how that logic reaches the world: Next.js routes, React components, Prisma, Stripe, email providers.

AI tools tend to mix the two, because the request arrives at the route handler and that’s where the code goes. Here’s a typical result:

// app/api/bookings/route.ts  (tangled)
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.json();
  const listing = await prisma.listing.findUnique({ where: { id: body.listingId } });
  if (!listing) return NextResponse.json({ error: "Not found" }, { status: 404 });

  const nights = Math.round(
    (new Date(body.checkOut).getTime() - new Date(body.checkIn).getTime()) / 86_400_000,
  );
  let total = nights * listing.nightlyRate;
  if (nights >= 7) total = total * 0.9; // weekly discount
  total += listing.cleaningFee;

  const intent = await stripe.paymentIntents.create({ amount: total, currency: "usd" });
  const booking = await prisma.booking.create({
    data: { listingId: listing.id, checkIn: body.checkIn, checkOut: body.checkOut, total, paymentIntentId: intent.id },
  });
  return NextResponse.json(booking);
}

It works. It also has problems you only notice later:

  • The pricing rule lives inside an HTTP handler. When you add a quote page, an admin tool or a background job that re-prices bookings, the AI will write the rule again, slightly differently.
  • total * 0.9 can produce fractions of a cent, and Stripe expects whole numbers in the smallest unit.
  • You can’t test the discount without a database and a Stripe key.

Pull the rule out into a plain function with no imports at all:

// src/bookings/domain/pricing.ts
export type Quote = {
  nights: number;
  subtotalCents: number;
  discountCents: number;
  cleaningFeeCents: number;
  totalCents: number;
};

export function nightsBetween(checkIn: string, checkOut: string): number {
  // Dates as "YYYY-MM-DD"; both parsed at UTC midnight so DST can't shift them.
  const ms = Date.parse(`${checkOut}T00:00:00Z`) - Date.parse(`${checkIn}T00:00:00Z`);
  return ms / 86_400_000;
}

export function quoteStay(input: {
  nightlyRateCents: number;
  cleaningFeeCents: number;
  nights: number;
}): Quote {
  if (!Number.isInteger(input.nights) || input.nights < 1) {
    throw new Error("A stay needs at least one whole night");
  }
  const subtotalCents = input.nightlyRateCents * input.nights;
  const discountCents = input.nights >= 7 ? Math.round(subtotalCents * 0.1) : 0;
  return {
    nights: input.nights,
    subtotalCents,
    discountCents,
    cleaningFeeCents: input.cleaningFeeCents,
    totalCents: subtotalCents - discountCents + input.cleaningFeeCents,
  };
}

Now the use case says what happens, and asks for its tools through small interfaces:

// src/bookings/create-booking.ts
import { nightsBetween, quoteStay } from "./domain/pricing";

export interface Listings {
  findById(id: string): Promise<{ id: string; nightlyRateCents: number; cleaningFeeCents: number } | null>;
}
export interface Payments {
  createIntent(amountCents: number, currency: "usd"): Promise<{ id: string }>;
}
export interface Bookings {
  create(b: { listingId: string; checkIn: string; checkOut: string; totalCents: number; paymentRef: string }): Promise<{ id: string }>;
}

export async function createBooking(
  deps: { listings: Listings; payments: Payments; bookings: Bookings },
  input: { listingId: string; checkIn: string; checkOut: string },
) {
  const listing = await deps.listings.findById(input.listingId);
  if (!listing) return { ok: false as const, error: "listing_not_found" };

  const quote = quoteStay({ ...listing, nights: nightsBetween(input.checkIn, input.checkOut) });
  const intent = await deps.payments.createIntent(quote.totalCents, "usd");
  const booking = await deps.bookings.create({ ...input, totalCents: quote.totalCents, paymentRef: intent.id });
  return { ok: true as const, bookingId: booking.id, quote };
}

The route handler shrinks to “parse the request, call createBooking, turn the result into a response.” Prisma and Stripe live in small adapter files that implement Listings, Payments and Bookings.

And the rule can be tested in milliseconds:

import { expect, it } from "vitest";
import { quoteStay } from "./pricing";

it("gives 10% off stays of 7 nights or more", () => {
  const q = quoteStay({ nightlyRateCents: 10_000, cleaningFeeCents: 2_500, nights: 7 });
  expect(q.totalCents).toBe(65_500); // 70,000 - 7,000 + 2,500
});

This is the core idea of Clean Architecture: dependencies point inward, toward the business rules. That post covers the layers in detail. For a small app you don’t need all four layers and a folder per pattern. You need one rule: business logic doesn’t import frameworks. Most of the benefit comes from that alone.

(In a real app you’d also handle the payment-then-insert problem properly. If the insert fails, you have a payment intent with no booking. That’s what the outbox pattern in Background Jobs Done Right is for.)


Skill 3: Name things on purpose

Names are the cheapest architecture you’ll ever do, and the one AI tools damage most quietly.

In an AI-built app I often see the same concept under four names: reservation, booking, stay and order. Each came from a different prompt on a different day. Then someone asks the AI to “update the booking status,” and it updates one of the four.

The fix is boring and works:

  • Keep a short glossary. One line per core concept. “Booking: a confirmed stay at a listing for specific dates. Not a quote, not an inquiry.”
  • Use the same word in the UI, the code, the database and the conversation. If customers say “stay,” consider calling it stay everywhere.
  • Name modules after business areas, not technical layers. billing/ tells you what lives there. utils/ and helpers/ tell you nothing and grow forever.
  • Rename early. A rename across a codebase is a five-minute job for an AI tool with good tests. A wrong name left for a year becomes three meanings.

When you review an AI diff, read the new names first. If it invented a new word for an existing concept, stop it there.


Skill 4: Write decisions down, then feed them to the AI

This is the habit that changes AI-assisted work the most.

Remember the problem: the AI doesn’t remember your decisions and doesn’t know the shape of your app. So write the shape down in a file the AI reads every session.

Most tools now support this. Claude Code loads CLAUDE.md (and can read an AGENTS.md instead) at the start of each session. Cursor, Copilot and others have their own rules files. Use whichever your tool reads, but keep the content in one place.

A one-page architecture doc

Keep it short enough that you’d actually read it. Something like:

# ARCHITECTURE.md

## Shape
- One Next.js app, one Postgres database. No microservices.
- Modules: bookings, billing, messaging, accounts. Each has src/<module>/index.ts as its only public API.
- Inside a module: domain/ (pure rules, no imports from next, prisma, stripe), use cases, infra/ (adapters).

## Rules
- Money is integer cents. Never floats.
- Dates for stays are "YYYY-MM-DD" strings in the listing's time zone.
- Modules never query each other's tables. Call the other module's index.ts.
- Route handlers only parse input, call a use case and format the response.

## Words
- Listing: a property a guest can book.
- Booking: a confirmed stay. Quote: a price before confirmation.

## Decisions
See docs/adr/. Read the relevant ADR before changing anything it covers.

Architecture Decision Records

An ADR is a short note about one decision. Michael Nygard proposed the format in 2011: a title, the context (the forces at play), the decision, the status (proposed, accepted, superseded) and the consequences, good and bad.

# ADR 004: Store money as integer cents

Status: Accepted (2026-09-02)

Context: Prices were stored as floats. A 10% discount produced 6,299.999 and
Stripe rejected the amount. Different parts of the app rounded differently.

Decision: All money is stored and passed as integer cents, with a currency code.
Formatting to "$62.99" happens only in the UI.

Consequences: Every money column is an integer. Division needs explicit rounding
(Math.round, in one helper). Old float columns need a migration.

That example is invented for illustration, but the shape is real. Five of these will save you more arguments with an AI tool (and with teammates) than any prompt trick.

The key move: point the AI at them. Your rules file says “read ARCHITECTURE.md and the ADRs in docs/adr before changing structure.” When the AI proposes something that contradicts ADR 004, you’ll notice, and often it will notice first.


Skill 5: Ask for options and trade-offs, not code

The biggest change I’d suggest in how you prompt: for anything structural, don’t ask for code first. Ask for choices.

A prompt like “add multi-currency support” gets you code, scattered wherever the model thinks it fits. A prompt like this gets you a decision:

Read ARCHITECTURE.md and docs/adr/.

We need to support EUR as well as USD for new listings.
Don't write code yet. Give me 2-3 ways to do this. For each:
- which modules and files change
- what happens to existing USD bookings and reports
- what it makes harder later
- how we'd test it
Then say which one you'd pick for a 2-person team and why.
Point out anything that conflicts with an existing ADR.

Then you read, push back, and choose. You write the ADR (or ask the AI to draft it, and you edit it). Only then do you ask for code, in small steps, inside the shape you just decided.

This is where AI tools are genuinely strong. They’ve seen many ways to solve common problems and can lay them out quickly. What they can’t do is own the choice, because they won’t be there in six months when it matters. You will.


How to build this skill while using AI

You don’t learn architecture from a book in one go. You learn it by making small structural decisions often and noticing which ones hurt later. Here’s a routine that works alongside AI tools.

Before a feature: spend five minutes writing where it goes. Which module? New table or existing one? Which use case? Write it in the prompt. If you can’t answer, ask the AI for options first.

During: keep sessions small and scoped to one module. “Work only in src/billing. If you think another module needs changes, stop and tell me.” Small scope is the best answer to small context.

After each feature, review the diff for shape, not just correctness:

  • Did it add imports that cross a module boundary?
  • Did it put business rules in a route handler, component or SQL string?
  • Did it create a new helper that duplicates an existing one? (Search for similar names.)
  • Did it invent a new word for an existing concept?

Once a week: ask the AI to map the codebase for you. “List every place we calculate a price. List every import from one module into another module’s internals.” Then fix the worst one. This is the refactoring that GitClear’s numbers suggest is quietly disappearing.

Once a month: reread your ARCHITECTURE.md. If the code and the doc disagree, decide which is right and update the other.

And for practice outside work: take a small app you built with AI and draw its dependency graph by hand. Boxes for modules, arrows for imports. Tools like dependency-cruiser can draw it for you, but doing it once by hand teaches you where the tangles are.


A checklist

  • The app has a few modules named after business areas, each with one public entry point.
  • Business rules live in plain functions with no framework, database or vendor imports.
  • Route handlers, pages and jobs are thin: parse, call a use case, respond.
  • Boundary rules are enforced by a linter in CI, not by memory.
  • There’s a glossary, and each concept has one name everywhere.
  • ARCHITECTURE.md fits on one page and the AI tool reads it every session.
  • Decisions that would be expensive to reverse have a short ADR.
  • For structural changes, you ask the AI for options and trade-offs before code.
  • You review AI diffs for shape (imports, names, duplication), not only for “does it run.”

Key ideas

  • AI is strong at the code in front of it and weak at the shape of the whole. Each session starts fresh, and it only sees what’s in its window.
  • Architecture for a small app is four things: where code goes, who may import whom, what things are called, and why you decided.
  • Keep business rules in plain functions that don’t import frameworks. That one rule gives you most of what Clean Architecture promises.
  • Enforce boundaries with tools, so the AI gets a failing check instead of a silent drift.
  • Write a one-page architecture doc and short ADRs, and feed them to your AI tool every session.
  • For structural work, ask for options and trade-offs first. You choose and own the decision. The AI writes the code inside it.

Sources: Raffi Krikorian, Twitter Engineering, “New Tweets per second record, and how!” (16 August 2013); GitClear, “AI Copilot Code Quality: 2025 Data” (2025), as summarised by DevClass (20 February 2025); Anthropic, Claude Code docs: “How Claude remembers your project” (checked October 2026); Michael Nygard, “Documenting Architecture Decisions” (15 November 2011); ESLint, no-restricted-imports.

Related: Tests Are How You Talk to the AI, Shipping Is Half the Job, and Start With a Modular Monolith.