01 / The prompt
“Build the API for our survey builder.”
An HR team runs pulse surveys. Three features: duplicate last quarter’s survey (from the
editor, and from a nightly job that sets up the next round), export the responses as CSV,
and add a question to a draft. Ask an agent for it and you get working endpoints, often in controllers/, services/, and repositories/.
Then a ticket: duplicating a big survey is slow, because it loads every response and never copies them. The fix is one query. The question the first prompt never answered is who else reads through that query. Jimmy Bogard put the problem with layers this way: “When adding or changing a feature in an application, I’m typically touching many different ‘layers’ in an application” (Vertical Slice Architecture).
02 / Name the shape
One feature, one folder, every door.
Vertical slices organize code by feature: each feature’s route, command, rules, and data access sit together in one folder, and every way into that feature calls the same code there. Code shared between features is shared on purpose, with a name for what it promises.
A change to one feature stays in its folder. A query two features share is a promise to both; keep it only if you mean it.
| What | Owner | Why |
|---|---|---|
| Duplicate’s route, command, and query | features/duplicate-survey/ | They change together, for duplicate’s reasons. |
| Export’s query and CSV format | features/export-results/ | Export needs responses; nothing else should decide how it reads them. |
| Whether a survey can still change | shared/, named | Two features need the same answer, so it has one name: isEditable. |
| The rows themselves | The store | Shared storage is fine. Shared queries are the coupling. |
| Which features exist | One list | The only place that knows them all; deleting a feature is one line here. |
Words to put in a prompt or a review
- Vertical slice
- One feature’s code from its doors to its data, kept together.
- Feature folder
- Where a slice lives, named for what the feature does.
- Entry point
- A way into a feature: an HTTP route, a command, a scheduled job.
- Shared kernel
- The little code several slices depend on, each piece named for what it promises.
- Registry
- The one list of features the app is built from.
- Change footprint
- The files a change touches; one folder is the goal.
Slices and layers are not rivalsLayers can live inside a slice
A slice can keep a route, rules, and a query in separate files, which is layering inside the folder. Fowler’s advice for a layered app that grows is exactly that: “split your top level into domain oriented modules which are internally layered” (Presentation Domain Data Layering). What slices give up is the shared repository or service every feature reads through, and that is the part this lesson measures.
03 / Follow one feature
Watch one edit land in two layouts.
Duplicate from the editor, then from the nightly job. Then the ticket’s edit, making duplicate’s query skip responses, in each layout, and an export afterwards. Last, delete the feature. Open Try it to make the edit and the deletion yourself.
Where does one feature live?
Organized by feature
POST /surveys/s-1/duplicate
features/duplicate-survey/features/export-results/features/add-question/
Working…
Duplicate, from the editor
The request goes in.
Reduced motion: choose a scene to see its completed state.
Read this scene
The request goes in.
Organized by feature. The request goes in.
Watch restarts the story when you come back. Step through keeps your step. Try it builds both apps fresh for every run.
04 / Read the shape
A slice, a list of slices, and the layered build beside them.
Basic form is one slice with two doors. In the wild is the other slices, the one rule they share by name, and the app built from the list. At the call site is the same app organized by layer, for comparison.
Notice loadForCopy. It looks exactly like export’s query today. It belongs to
duplicate, so it can change for duplicate’s reasons.
One slice: the duplicate feature’s route, its command, and its own query, with both doors calling one handle function.
// A slice: one feature's routes, its command, its rule, and its own query,
// in one folder. Both doors call the same handle function.
export type Slice = {
name: string;
routes: Record<string, (db: Db, id: string, body: string) => Reply>;
commands: Record<string, (db: Db, args: string[]) => string>;
};
export function duplicateSurvey(edit: Edit = {}): Slice {
// The query this feature needs. Only duplicate uses it, so only duplicate changes with it.
const loadForCopy = (db: Db, id: string) => {
const survey = db.survey(id);
if (!survey) return null;
return {
survey,
questions: db.questionsOf(id),
responses: edit.duplicateSkipsResponses ? [] : db.responsesOf(id)
};
};
const handle = (db: Db, id: string) => {
const found = loadForCopy(db, id);
if (!found) return null;
const copy: Survey = {
id: db.newSurveyId(),
title: `Copy of ${found.survey.title}`,
status: 'draft'
};
db.surveys.push(copy);
db.questions.push(...found.questions.map((q) => ({ ...q, survey: copy.id })));
return { copy, questions: found.questions.length };
};
return {
name: 'duplicate-survey',
routes: {
'POST /surveys/:id/duplicate': (db, id) => {
const done = handle(db, id);
return done
? {
status: 201,
body: JSON.stringify({
id: done.copy.id,
title: done.copy.title,
questions: done.questions
})
}
: { status: 404, body: 'no such survey' };
}
},
commands: {
duplicate: (db, [id]) => {
const done = handle(db, id);
return done
? `created ${done.copy.id} from ${id} (${done.questions} questions)`
: `no survey ${id}`;
}
}
};
} // Slice is one feature's routes, its command, its rule, and its own query, in
// one folder. Both doors call the same handle function.
type Slice struct {
Name string
Routes map[string]func(db *Db, id, body string) Reply
Commands map[string]func(db *Db, args []string) string
}
func DuplicateSurvey(edit Edit) Slice {
// The query this feature needs. Only duplicate uses it, so only duplicate changes with it.
loadForCopy := func(db *Db, id string) (*Survey, []Question) {
survey := db.Survey(id)
if survey == nil {
return nil, nil
}
questions := db.QuestionsOf(id)
if !edit.DuplicateSkipsResponses {
db.ResponsesOf(id)
}
return survey, questions
}
handle := func(db *Db, id string) *copied {
survey, questions := loadForCopy(db, id)
if survey == nil {
return nil
}
c := copySurvey(db, survey, questions)
return &c
}
return Slice{
Name: "duplicate-survey",
Routes: map[string]func(*Db, string, string) Reply{
"POST /surveys/:id/duplicate": func(db *Db, id, _ string) Reply {
if c := handle(db, id); c != nil {
return created(*c)
}
return Reply{404, "no such survey"}
},
},
Commands: map[string]func(*Db, []string) string{
"duplicate": func(db *Db, args []string) string {
if c := handle(db, args[0]); c != nil {
return fmt.Sprintf("created %s from %s (%d questions)", c.id, args[0], c.questions)
}
return "no survey " + args[0]
},
},
}
} The behavior these examples promiseChecked by 20 shared scenarios
- Survey s-1 has three questions and four responses. Duplicate makes a draft copy with the questions and no responses, from the route or the command.
- Export writes a header of questions and a row per response.
- Adding a question works on a draft and is refused on a published survey.
- The edit makes duplicate’s query skip responses. In the layered build that query is shared.
- Leaving a slice out of the list removes its routes and its command.
- Every row a request reads is counted.
Every expectation in the shared cases, including rows read, was produced by a separate model written from these rules and kept beside the examples, not copied from either implementation.
Reading the TypeScriptA slice is a value
Slice is a plain object of routes and commands, so the app is a filter over a list, and deleting a feature is leaving it out. The edit is a flag
passed to each build; in an app it would be a change to one function.
Reading the GoMaps of closures
Each slice is a struct of maps from a route pattern or command name to a closure, and each closure captures its slice’s own query. Go maps have no order, which is fine here: no two slices claim the same route.
Run it yourselfNo dependencies
Copy the complete TypeScript file and run node --experimental-strip-types surveys.ts with Node 22.18 or later. For Go, save main.go next to this go.mod and run go run .. Both print:
module heyrian.dev/lessons/vertical-slices
go 1.23
layered: 201 s-2, 8 rows read · created s-3 from s-1 (3 questions), 8 rows read · 200 csv with 4 responses, 8 rows read layered, after the edit: 201 s-2, 4 rows read · created s-3 from s-1 (3 questions), 4 rows read · 200 csv with 0 responses, 4 rows read sliced: 201 s-2, 8 rows read · created s-3 from s-1 (3 questions), 8 rows read · 200 csv with 4 responses, 8 rows read sliced, after the edit: 201 s-2, 4 rows read · created s-3 from s-1 (3 questions), 4 rows read · 200 csv with 4 responses, 8 rows read sliced, duplicate deleted: 404, 0 rows read · unknown command duplicate, 0 rows read · 200 csv with 4 responses, 8 rows read
05 / Review the agent’s diff
“Removed a duplicate query.”
Two functions with the same body is what a reviewer is trained to flag. Read which feature the surviving one belongs to.
06 / How it fails
Layers fail through what they share. Slices fail through what they copy.
Rows backed by a shared case say so; the rest are marked as authored.
| What goes wrong | What HR sees | Layered | Sliced |
|---|---|---|---|
| Wrong: an edit for one feature (case) | An empty export | Export reads through duplicate’s edited query: 0 responses | Only duplicate changes: export keeps 4 |
| Slow: duplicate on a big survey (case) | A slow copy | 8 rows before the edit, 4 after | The same |
| Half-done: a feature deleted (case) | A 404 and an unknown command | Four shared units to edit | One folder and one line |
| Conflicting: two slices copy a rule (authored) | A published survey edited through one door | One service rule | Drift, unless the rule is named and shared |
| Junk drawer: a shared utils file (authored) | Nothing, yet | Every feature depends on it, and no one can change it safely | |
The first and fourth rows are the trade: sharing couples features, copying lets them drift. Coupling and cohesion and Module boundaries cover both in general.
07 / Is it worth it?
Folders per feature cost some repetition. Here is what they buy.
The layered build has less code and one obvious place for every query. Hold both against the changes.
| Change | Layered | Sliced |
|---|---|---|
| A second door: duplicate from a Slack command | A new controller calling the service | A new file in duplicate’s folder |
| Replace a dependency: responses move to a warehouse | The shared repository changes, for every feature | Export’s query changes; duplicate never reads responses |
| Change a rule: copies keep their original title | The service’s duplicate method | Duplicate’s folder |
| A second team takes export | They edit the shared service and repository | They own a folder |
Before reorganizing, decide what you will measure and the result you would accept:
- Folders touched per change, from the last few months of merged pull requests. If most changes already touch one area, reorganizing buys little.
- Changes that broke a feature they were not about, from incident notes or reverts.
- Lines outside a feature’s folder that name it, as the checker counts in section 08.
This page did not reorganize a real codebase, so it gives no production numbers.
08 / Ask for it
Two prompts, one ticket, four builds.
We sent two agents the same request at the same time, both running Claude Sonnet. One prompt
described the features. The other added an Architecture block: a folder per
feature under features/, both of duplicate’s doors calling one function there,
deletion as one folder and one line, and shared code named for what it is. Then each
finished build went to a fresh agent with the same ticket: duplicating a large survey is
slow because it loads every response. A script tested every build, then deleted the
duplicate feature.
| Question | Plain prompt | Architecture prompt |
|---|---|---|
| Where duplicate lives | Spread across cli.ts, data.ts, server.ts | features/duplicate-survey/ |
| The editor and the nightly job | 201 and s-2 | 201 and s-2 |
| Delete duplicate | No folder to delete; edits in 3 files | The folder and 4 lines in 2 other files. Then: duplicate 404, export 4 responses |
| After the ticket: export | 4 responses | 4 responses |
| After the ticket: files it changed | data.ts (shared storage) and three test files | Two files and a test, all in features/duplicate-survey/ |
| After the ticket: tests with networking blocked | 15 of 25 | 15 of 26 |
The ticket was wrong for both builds. Neither build’s duplicate had ever read a response: both agents stored responses apart from surveys from the start. Both ticket agents read the code first and said so, instead of inventing a fix. So the failure this lesson’s example is built on, one query that duplicate and export both depend on, did not happen in the runs, and export kept all four responses in every build.
What the runs did show is where each change landed. Asked about duplicate, the plain build’s
agent reindexed the shared response storage, in the functions export reads. It worked, and
it is the kind of change that breaks a feature nobody was asked about. The feature-folder
build’s agent changed nothing outside features/duplicate-survey/, and deleting
that folder took four lines elsewhere.
@@ -69,20 +79,28 @@ export function getSurvey(store: Store, id: string): Survey | undefined {
}
export function responseCount(store: Store, surveyId: string): number {
- return store.responses.filter((r) => r.surveyId === surveyId).length;
+ return store.responsesBySurvey.get(surveyId)?.length ?? 0;
}
export function responsesFor(store: Store, surveyId: string): ResponseRecord[] {
- return store.responses.filter((r) => r.surveyId === surveyId);
+ const bucket = store.responsesBySurvey.get(surveyId);
+ return bucket ? bucket.slice() : [];
} +// Fetches only the survey record (id, title, status, questions). This is
+// what keeps duplicating a large survey fast: it never touches
+// store.responses, no matter how many responses the survey has.
export function getSurveyById(store: Store, id: string): Survey | undefined {
return store.surveys.get(id);
} The line the architecture block had and the plain prompt lacked is the one that kept the ticket local: each feature has its own data access; a change for one feature does not edit another feature’s queries. Neither prompt asked how an agent should treat a ticket whose premise is false. Both handled it well, and a prompt can ask for it anyway: check the claim in the code before changing anything.
How the runs were made and checkedTwo rounds, recorded as written
- Both round-one agents received the prompts word for word, in fresh contexts, in the same message. Each finished build was stored, copied, and given to a fresh agent with the same ticket; the two ticket agents also started together.
- Every file is kept byte for byte, with checksums, and each ticket is kept as a diff. The checker restores a build, calls both doors, and deletes the duplicate feature by removing its folder and every line outside it that names it.
- The ticket was written from this lesson’s example, where duplicate does read responses; the recorded builds did not, which is why both ticket agents found no bug.
- Every agent stopped its test server by process id. All four wrote a log or a response to the system’s temporary folder, against the prompt. None read another run’s folder.
- This is one sample of each prompt and ticket, not a measurement of a model.
09 / Hold it there
Make reaching into another feature fail a check.
Slices erode through convenience: one feature importing another’s query because it was
already written, a utils.ts that every folder imports. Three checks keep the folders
honest.
The language’s own door
In Go, a folder named
internalcan be imported only from the tree that contains it, sofeatures/export/internal/is export’s alone. In TypeScript there is no such door; a feature’sindex.tscan say what it offers, but nothing stops a deep import without the next check.An import rule an agent cannot argue with
A feature may import
shared/and its own files, and another feature only through itsindex.ts. Enforcement layer runs rules like this against real code, and Architecture as rules writes them from one declaration. The rule was not run against this lesson’s files, which are one file per language.A check on what actually happens
The honest test of a slice is deleting it. The checker in section 08 removes a feature’s folder and every line outside it that names it, starts the app, and asks the other features to work.
check-runs.mjs // Try it: remove the folder and those lines, and see what still works. rmSync(folder, { recursive: true, force: true }); for (const { file, lines } of touched) { const path = join(dir, file); writeFileSync(path, readFileSync(path, 'utf8').split('\n').filter((l) => !lines.includes(l.trim())).join('\n')); } const server = await start(dir);
Your routes folder is already slicedA route folder with its page, loader, and components is a feature folder. A command palette is a second door.
Where it already is in your components
A SvelteKit route folder, with +page.svelte, +page.server.ts,
and the components only that page uses, is a vertical slice. So is a Next.js app-router
folder with its page, its server actions, and its local components. Delete the folder and
the page is gone.
When you have to own it
The day a feature gets a second door in the UI, such as a command palette entry beside its
button, or a second screen wants one of its queries. Give the feature a folder with an index.ts that exports what others may use, and have both doors call it. Deleting
the folder then removes the button and the command together.
The duplicate button in its feature folder, calling the folder’s own duplicate function.
// features/duplicate-survey/DuplicateButton.tsx. The button, its request, and
// its state live in the feature's folder. The editor page imports the button
// and nothing else from here.
import { useState } from 'react';
import { duplicate, type Duplicated } from './duplicate-survey';
export function DuplicateButton({ surveyId }: { surveyId: string }) {
const [copy, setCopy] = useState<Duplicated | null>(null);
return (
<>
<button onClick={async () => setCopy(await duplicate(surveyId))}>Duplicate</button>
{copy && <p role="status">Created {copy.title}</p>}
</>
);
}
10 / Make the call
Slice when features change on their own. Share only what has a name.
Keep one set of layers while the app is small, the features are few, and most changes touch them all together: a CRUD admin panel, a single-purpose service. One repository is easier to read than five near-identical queries.
Slice by feature when features change for different reasons, when different people own them, or when an edit for one keeps breaking another. Share storage freely; share a query or a rule only when it has a name for what it promises and you want every caller to change with it. Reopen the decision when most changes touch several folders at once: the slices are drawn in the wrong place.
Take it with you
Explain it without saying “vertical slices”: “Everything duplicate needs, its route, its job, its query, sits in one folder, so changing duplicate cannot quietly change export, and deleting it is deleting a folder.” Then pick a feature in your code and count the folders you would edit to delete it.
Paste into your next prompt, and fill in the blanks
Organize the code by feature: one folder per feature under <features/>, holding its route, its command if it has one, its rules, its own queries, and its tests. Every way into a feature calls the same function in its folder. Deleting a feature means deleting its folder and one line in <the registry>. Code shared by more than one feature lives in <shared/>, in files named for what they promise, never a general utils file. A feature never imports another feature's files; it uses that feature's index.ts or asks shared/.
Connections to follow nextRelated lessons
- Layered architecture is the shape this lesson compares against, and can live inside each slice.
- Modular monolith is slices grown into business capabilities with front doors.
- Module boundaries covers what a folder may expose.
- Enforcement layer runs the import rule in section 09.
- Functional core, imperative shell is the next way to organize code inside one feature.