Build Features Bottom-Up from Domain to Presentation
Impact: MEDIUM (Top-down development lets UI concerns leak into data models, creating tightly coupled systems)
When building a new feature, the order in which you create the layers matters. Building top-down -- starting with the UI and working backward to the database -- causes the page layout to dictate the shape of your data model. Fields get added to entities because a form needs them. API routes return whatever the component expects. The result is a system where changing the UI requires changing the database schema and vice versa.
Build bottom-up instead: Domain -> Interface -> Repository -> Service -> Controller/Route -> Presentation. Define the business entity first, then the contract for accessing it, then the implementation, and finally the UI that consumes it. Each layer depends only on the layer below it, and no layer dictates the shape of another.
Incorrect (top-down: page component drives everything, directly queries the database):
// src/app/scans/page.tsx// ❌ Building top-down: the page component IS the feature// UI concerns, data fetching, business logic, and presentation all in one fileimport { createServerClient } from '@supabase/ssr';import { cookies } from 'next/headers';export default async function ScansPage() { const cookieStore = await cookies(); const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll: () => cookieStore.getAll() } } ); // ❌ Raw database query inside a React component const { data: scans, error } = await supabase .from('scans') .select(` id, repo_url, status, created_at, scan_results ( id, rule_id, severity, line_number, file_path, message ) `) .eq('user_id', (await supabase.auth.getUser()).data.user?.id) .order('created_at', { ascending: false }); if (error) { return <div>Error loading scans</div>; // ❌ No proper error boundary } // ❌ Business logic computed inline in the component const scansWithStats = scans?.map((scan) => ({ ...scan, criticalCount: scan.scan_results.filter((r) => r.severity === 'critical').length, highCount: scan.scan_results.filter((r) => r.severity === 'high').length, totalFindings: scan.scan_results.length, // ❌ UI display concern mixed with data transformation statusLabel: scan.status === 'in_progress' ? 'Running...' : scan.status, statusColor: scan.status === 'completed' ? 'green' : scan.status === 'failed' ? 'red' : 'yellow', })); return ( <div className="space-y-4"> <h1>Your Scans</h1> {scansWithStats?.map((scan) => ( <div key={scan.id} className="border p-4 rounded"> <p>{scan.repo_url}</p> <span style={{ color: scan.statusColor }}>{scan.statusLabel}</span> <p>{scan.criticalCount} critical, {scan.highCount} high, {scan.totalFindings} total</p> </div> ))} </div> );}
Correct (bottom-up: domain first, then repository, service, and finally the page):