← Architecture
Organize an application Split by feature

Vertical slices

One feature, one folder, every door.

You have probably wanted to delete a feature and found it in five folders. Or fixed one screen’s slow query and broken another screen that used it. Let’s build a small survey builder by layer and by feature, make the same edit to both, and try deleting something.

TypeScriptGoOne survey builder, two layouts, four recorded builds.

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.

Who owns each part of the survey builder
WhatOwnerWhy
Duplicate’s route, command, and queryfeatures/duplicate-survey/They change together, for duplicate’s reasons.
Export’s query and CSV formatfeatures/export-results/Export needs responses; nothing else should decide how it reads them.
Whether a survey can still changeshared/, namedTwo features need the same answer, so it has one name: isEditable.
The rows themselvesThe storeShared storage is fine. Shared queries are the coupling.
Which features existOne listThe 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.

Vertical slices

Where does one feature live?

Organized by feature

POST /surveys/s-1/duplicate

  • features/duplicate-survey/
  • features/export-results/
  • features/add-question/

Working…

01/ 05
Duplicate, from the editor

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.

TypeScriptReading
surveys.ts
// 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}`;
			}
		}
	};
}
GoAlongside
main.go
// 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:

go.mod
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.

The agent’s pull request

“Removed a duplicate query: export now reuses duplicate’s loader. All 23 tests pass.”

// features/export-results/export.ts
			(removed)import { loadResponses } from './load';
			(added)import { loadForCopy } from '../duplicate-survey/load'; // same query, why keep two
			
			export function exportCsv(db, id) {
			(removed)  const found = loadResponses(db, id);
			(added)  const found = loadForCopy(db, id);
			
You are reviewing this change. What do you do?

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.

Failure modes of the survey builder
What goes wrongWhat HR seesLayeredSliced
Wrong: an edit for one feature (case)An empty exportExport reads through duplicate’s edited query: 0 responsesOnly duplicate changes: export keeps 4
Slow: duplicate on a big survey (case)A slow copy8 rows before the edit, 4 afterThe same
Half-done: a feature deleted (case)A 404 and an unknown commandFour shared units to editOne folder and one line
Conflicting: two slices copy a rule (authored)A published survey edited through one doorOne service ruleDrift, unless the rule is named and shared
Junk drawer: a shared utils file (authored)Nothing, yetEvery 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.

The same four changes, made to each build
ChangeLayeredSliced
A second door: duplicate from a Slack commandA new controller calling the serviceA new file in duplicate’s folder
Replace a dependency: responses move to a warehouseThe shared repository changes, for every featureExport’s query changes; duplicate never reads responses
Change a rule: copies keep their original titleThe service’s duplicate methodDuplicate’s folder
A second team takes exportThey edit the shared service and repositoryThey 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.

What the checker found, run 2026-09-23
QuestionPlain promptArchitecture prompt
Where duplicate livesSpread across cli.ts, data.ts, server.tsfeatures/duplicate-survey/
The editor and the nightly job201 and s-2201 and s-2
Delete duplicateNo folder to delete; edits in 3 filesThe folder and 4 lines in 2 other files. Then: duplicate 404, export 4 responses
After the ticket: export4 responses4 responses
After the ticket: files it changeddata.ts (shared storage) and three test filesTwo files and a test, all in features/duplicate-survey/
After the ticket: tests with networking blocked15 of 2515 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.

ticket · plain build
@@ -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() : [];
 }
ticket · feature-folder build
+// 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.

  1. The language’s own door

    In Go, a folder named internal can be imported only from the tree that contains it, so features/export/internal/ is export’s alone. In TypeScript there is no such door; a feature’s index.ts can say what it offers, but nothing stops a deep import without the next check.

  2. An import rule an agent cannot argue with

    A feature may import shared/ and its own files, and another feature only through its index.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.

  3. 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.

ReactAlready in your code
features/duplicate-survey/DuplicateButton.tsx
// 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

Take the survey builder into your editor. Add a “close survey” feature as a slice with a route and a command, then delete it, and count the lines outside its folder you had to touch.

Back to architecture →