Grow in Vertical Slices
With the skeleton live, Sprout grows one feature at a time. Each feature is a slice: a thin cut through every layer (table, route, screen and test) that is merged and deployed on its own. Here is one whole slice from the plan, start to finish: watering a plant and keeping its history. Every step uses something from an earlier section.
The rule
Each feature cuts through every layer and ships on its own.
Data. A new migration adds the history table. The database enforces the basics:
CREATE TABLE waterings (
id serial PRIMARY KEY,
plant_id integer NOT NULL REFERENCES plants(id) ON DELETE CASCADE,
watered_on date NOT NULL DEFAULT CURRENT_DATE
);API. A guarded route that creates a watering, but only on the user's own plant:
app.post('/api/plants/:id/waterings', requireUser, async (req, res) => {
const { rows } = await pool.query(
'UPDATE plants SET last_watered = CURRENT_DATE WHERE id = $1 AND owner_id = $2 RETURNING *',
[req.params.id, req.user.id]
);
if (rows.length === 0) {
return res.status(404).json({ error: { code: 'not_found', message: 'Plant not found' } });
}
await pool.query('INSERT INTO waterings (plant_id) VALUES ($1)', [rows[0].id]);
res.status(201).json(rows[0]);
});The owner check in the UPDATE does double duty: it guards the row and finds the plant. In production code, run the two statements inside one transaction (BEGIN ... COMMIT) so they succeed or fail together.
Screen. The "Water now" button follows listen, send, check, update, and swaps in the server's version of the plant:
async function water(plant) {
const res = await api(`/api/plants/${plant.id}/waterings`, { method: 'POST' });
if (!res.ok) return setError('Could not record the watering.');
const updated = await res.json();
setPlants((list) => list.map((p) => (p.id === updated.id ? updated : p)));
}Tests. Three API tests describe the rules: the owner gets 201 and today's date; another user gets 404; a logged-out request gets 401.
Ship. A pull request runs CI. After the merge, the pipeline migrates first, so the table exists before the new code starts, then restarts and checks health. Finally, water a real plant on the live site.
Slices are small on purpose. Each one can be reviewed in a single sitting, and when something breaks after a deploy, you know exactly which change to look at. Twenty features shipped as twenty slices are far easier to trust than one big release that touched everything at once.
The shortcut
A slice is done only when it passes the whole checklist: migration, guarded route, screen with every state, tests for success and for someone else's data, and verified live. Missing any step means you built a layer, not a feature.
Data
The migration runs locally, and
wateringsexists with its foreign key toplants.
Drills
Answer fast — trust the rule.
What makes this a vertical slice rather than a layer?
The route answers 404 when Asha tries to water Ben's plant. Which part of the handler causes that?
Why does the client use the plant from the response instead of setting today's date itself?
During deployment, why must the migration run before the API restarts?
Remember
- Each feature cuts through every layer and ships on its own.
- Shortcut: done means migration, guarded route, full-state screen, tests and a live check.
- Trap: calling a feature done when only one layer is finished.
