Atomic Design Is Not Enough: Rethinking Component Architecture for Production Systems
Atomic Design Is Not Enough: Rethinking Component Architecture for Production Systems
Atomic Design became the de facto vocabulary for frontend component organization. Atoms, molecules, organisms, templates, pages — a taxonomy borrowed from chemistry that promises to bring order to the chaos of component proliferation. Teams adopt it religiously, build design systems around it, and then discover — usually around the 200-component mark — that the metaphor creates as many problems as it solves.
The issue isn't that Atomic Design is wrong. It's that it solves the wrong problem. Component taxonomy (what to call things) is distinct from component architecture (how things compose, couple, and change over time). Atomic Design is excellent at the former and silent on the latter. This article examines where Atomic Design breaks down and presents architectural patterns that address the actual complexity of production component systems.
What Atomic Design Actually Gives You
Atomic Design, introduced by Brad Frost in 2013, provides a five-level hierarchy:
┌─────────────────────────────────────────────────────────────────────────┐
│ ATOMIC DESIGN HIERARCHY │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ATOMS The smallest, indivisible UI elements │
│ ───── Button, Input, Label, Icon, Badge │
│ Can't be broken down further without losing meaning │
│ │
│ MOLECULES Simple groups of atoms functioning together │
│ ───────── SearchInput (Input + Button), FormField (Label + Input) │
│ Do one thing well │
│ │
│ ORGANISMS Complex groups of molecules and/or atoms │
│ ───────── Header, ProductCard, CommentThread, NavigationBar │
│ Form distinct sections of an interface │
│ │
│ TEMPLATES Page-level layouts, content structure without data │
│ ───────── DashboardTemplate, ArticleTemplate, CheckoutTemplate │
│ Define where organisms go │
│ │
│ PAGES Specific instances of templates with real content │
│ ───── HomePage, ProductPage, UserProfilePage │
│ Connect to data sources │
│ │
└─────────────────────────────────────────────────────────────────────────┘
This gives teams a shared vocabulary and a simple rule: smaller things compose into larger things. The chemistry metaphor makes it memorable. The problem is that software components aren't atoms.
Where the Chemistry Metaphor Breaks Down
Problem 1: Components Have Multiple Valid Decompositions
A hydrogen atom is always a hydrogen atom. A ProductCard component is not so deterministic:
// Is this a molecule or organism?
function ProductCard({ product, onAddToCart }) {
return (
<div className="product-card">
<ProductImage src={product.image} /> {/* atom? molecule? */}
<ProductTitle>{product.name}</ProductTitle>
<PriceDisplay price={product.price} currency="USD" />
<AddToCartButton onClick={() => onAddToCart(product.id)} />
</div>
);
}
The same logical component can be implemented as:
- A single molecule-level component
- An organism composed of atomic children
- Multiple organisms (
ProductInfo+ProductActions) combined - A template slot filled by a page
There's no objectively correct decomposition. Unlike chemistry, where molecular structure is determined by physics, component structure is determined by requirements that change over time.
Problem 2: The Atom/Molecule Boundary Is Arbitrary
What makes something atomic? The naive answer is "can't be broken down further," but everything can be broken down further:
// Is this an atom?
function Button({ children, variant, size, disabled, loading, onClick }) {
return (
<button
className={getButtonClasses(variant, size, disabled)}
disabled={disabled || loading}
onClick={onClick}
>
{loading && <Spinner size="small" />} {/* Another component inside! */}
{children}
</button>
);
}
// Can be decomposed further:
function Button({ children, variant, size, disabled, loading, onClick }) {
return (
<ButtonContainer variant={variant} size={size}>
<ButtonStateLayer disabled={disabled} />
<ButtonContent>
{loading && <ButtonSpinner />}
<ButtonLabel>{children}</ButtonLabel>
</ButtonContent>
<ButtonRipple onClick={onClick} />
</ButtonContainer>
);
}
The Material Design 3 spec for a button has eight distinct visual layers. Is a button one atom or eight? The answer depends on what abstraction level your team operates at — a decision Atomic Design doesn't help you make.
Problem 3: The Molecule/Organism Boundary Is Worse
// FormField: molecule or organism?
function FormField({ label, error, children }) {
return (
<div className="form-field">
<Label>{label}</Label>
{children} {/* Input, Select, Textarea, or custom */}
{error && <ErrorMessage>{error}</ErrorMessage>}
</div>
);
}
// Arguments for molecule:
// - It's just Label + Input + ErrorMessage
// - Single responsibility: wrapping a form control
// Arguments for organism:
// - It has conditional rendering logic
// - It handles composition (children prop)
// - It could contain other molecules (Input + ClearButton)
Teams waste significant time debating these classifications. The debate rarely produces better software — it produces longer PR reviews.
Problem 4: Templates and Pages Don't Map to Modern Routing
Atomic Design assumed a world where "templates" and "pages" were meaningful distinctions. In modern frameworks:
// Next.js App Router — where's the "template"?
// app/products/[id]/page.tsx
export default async function ProductPage({ params }) {
const product = await getProduct(params.id);
return (
<main>
<ProductHeader product={product} />
<ProductGallery images={product.images} />
<ProductDetails product={product} />
<RelatedProducts categoryId={product.categoryId} />
</main>
);
}
// The "template" concept is absorbed into:
// - Layout components (app/layout.tsx)
// - Route segment layouts (app/products/layout.tsx)
// - Parallel routes (@modal, @sidebar)
// - Server components vs client components split
The template/page distinction assumes content injection into predefined slots. Modern routing with nested layouts, parallel routes, and server/client boundaries makes this model insufficient.
The Actual Problem: Coupling and Change Propagation
What Atomic Design doesn't address — and what matters for production systems — is how components couple to each other and how changes propagate through the system.
┌─────────────────────────────────────────────────────────────────────────┐
│ THE REAL ARCHITECTURE QUESTIONS │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. CHANGE COUPLING │
│ When requirement X changes, which components must change? │
│ Can we isolate change to one area? │
│ │
│ 2. DATA COUPLING │
│ What data does each component need? │
│ How does data flow through the component tree? │
│ Where do we fetch/cache/transform data? │
│ │
│ 3. BEHAVIORAL COUPLING │
│ Which components share behavior/logic? │
│ How do we share without creating hidden dependencies? │
│ │
│ 4. VISUAL COUPLING │
│ Which components share visual constraints (tokens, spacing)? │
│ How do we enforce consistency without preventing variance? │
│ │
│ 5. TEAM COUPLING (CONWAY'S LAW) │
│ Which team owns which components? │
│ How do ownership boundaries affect component boundaries? │
│ │
│ Atomic Design answers NONE of these questions. │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Architecture Pattern 1: Feature-First Organization
Instead of organizing by component size (atoms, molecules), organize by what the component is for:
src/
├── features/ # Feature modules
│ ├── auth/
│ │ ├── components/
│ │ │ ├── LoginForm.tsx
│ │ │ ├── SignupForm.tsx
│ │ │ ├── PasswordReset.tsx
│ │ │ └── AuthGuard.tsx
│ │ ├── hooks/
│ │ │ ├── useAuth.ts
│ │ │ └── useSession.ts
│ │ ├── api/
│ │ │ └── auth.ts
│ │ └── index.ts # Public exports only
│ │
│ ├── products/
│ │ ├── components/
│ │ │ ├── ProductCard.tsx
│ │ │ ├── ProductGrid.tsx
│ │ │ ├── ProductFilters.tsx
│ │ │ └── ProductQuickView.tsx
│ │ ├── hooks/
│ │ │ ├── useProducts.ts
│ │ │ └── useProductFilters.ts
│ │ └── index.ts
│ │
│ └── cart/
│ ├── components/
│ ├── hooks/
│ ├── context/
│ └── index.ts
│
├── components/ # Shared, design-system components
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.test.tsx
│ │ ├── Button.styles.ts
│ │ └── index.ts
│ ├── Input/
│ ├── Modal/
│ └── ...
│
└── app/ # Routes (Next.js App Router)
├── page.tsx
├── products/
│ ├── page.tsx
│ └── [id]/
│ └── page.tsx
└── cart/
└── page.tsx
The key insight: feature boundaries are change boundaries. When the product team changes how products are displayed, changes are isolated to features/products/. When the design system team updates Button styles, changes are isolated to components/Button/.
┌─────────────────────────────────────────────────────────────────────────┐
│ FEATURE-FIRST: DEPENDENCY DIRECTION │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ │
│ │ app/ │ Routes │
│ │ (pages) │ │
│ └──────┬──────┘ │
│ │ imports │
│ ┌──────────┼──────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────┐ ┌────────┐ │
│ │ features/ │ │features│ │features│ Feature modules │
│ │ auth │ │products│ │ cart │ │
│ └─────┬──────┘ └───┬────┘ └───┬────┘ │
│ │ │ │ │
│ │ ┌───────┴──────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ │
│ │ components/ │ Design system (shared) │
│ │ Button, Input │ │
│ │ Modal, Card... │ │
│ └─────────────────┘ │
│ │
│ RULES: │
│ • Features import from shared components/ │
│ • Features NEVER import from other features │
│ • If two features need to share, extract to components/ or a new │
│ shared feature module │
│ • Routes compose features │
│ • Features are deletable without breaking other features │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Enforcing Feature Boundaries
Use ESLint rules to prevent feature cross-imports:
// .eslintrc.js
module.exports = {
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
// features/X cannot import from features/Y
group: ['@/features/*/*'],
message: 'Import from feature index only: @/features/X',
},
],
},
],
},
overrides: [
{
// Within a feature, internal imports are allowed
files: ['src/features/*/'],
rules: {
'no-restricted-imports': 'off',
},
},
],
};
Or use TypeScript path mappings with module boundaries:
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/features/auth": ["src/features/auth/index.ts"],
"@/features/products": ["src/features/products/index.ts"],
"@/components/*": ["src/components/*"]
}
}
}
Each feature's index.ts is the public API:
// features/auth/index.ts
// Only export what other features/routes can use
export { LoginForm } from './components/LoginForm';
export { SignupForm } from './components/SignupForm';
export { AuthGuard } from './components/AuthGuard';
export { useAuth } from './hooks/useAuth';
export type { User, AuthState } from './types';
// Internal components NOT exported:
// - PasswordStrengthMeter (used only inside SignupForm)
// - AuthFormLayout (internal layout)
// - usePasswordValidation (internal hook)
Architecture Pattern 2: Component Compound Pattern
For complex components that have multiple related parts, use the compound pattern instead of categorizing each piece in the atomic hierarchy:
// Instead of:
// atoms/TabButton.tsx
// atoms/TabPanel.tsx
// molecules/TabList.tsx
// organisms/Tabs.tsx
// Use compound components:
// components/Tabs/
// ├── Tabs.tsx
// ├── TabList.tsx
// ├── Tab.tsx
// ├── TabPanels.tsx
// ├── TabPanel.tsx
// ├── context.ts
// └── index.ts
import { createContext, useContext, useState, useId } from 'react';
interface TabsContextValue {
selectedIndex: number;
setSelectedIndex: (index: number) => void;
baseId: string;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext() {
const context = useContext(TabsContext);
if (!context) {
throw new Error('Tabs compound components must be used within <Tabs>');
}
return context;
}
// Root component manages state
function Tabs({
defaultIndex = 0,
onChange,
children
}: TabsProps) {
const [selectedIndex, setSelectedIndex] = useState(defaultIndex);
const baseId = useId();
const handleChange = (index: number) => {
setSelectedIndex(index);
onChange?.(index);
};
return (
<TabsContext.Provider
value={{ selectedIndex, setSelectedIndex: handleChange, baseId }}
>
<div className="tabs" role="tablist">
{children}
</div>
</TabsContext.Provider>
);
}
// Child components read from context
function TabList({ children }: { children: React.ReactNode }) {
return <div className="tab-list" role="tablist">{children}</div>;
}
function Tab({ index, children }: TabProps) {
const { selectedIndex, setSelectedIndex, baseId } = useTabsContext();
const isSelected = selectedIndex === index;
return (
<button
role="tab"
id={`${baseId}-tab-${index}`}
aria-selected={isSelected}
aria-controls={`${baseId}-panel-${index}`}
tabIndex={isSelected ? 0 : -1}
onClick={() => setSelectedIndex(index)}
className={isSelected ? 'tab tab-selected' : 'tab'}
>
{children}
</button>
);
}
function TabPanels({ children }: { children: React.ReactNode }) {
return <div className="tab-panels">{children}</div>;
}
function TabPanel({ index, children }: TabPanelProps) {
const { selectedIndex, baseId } = useTabsContext();
const isSelected = selectedIndex === index;
if (!isSelected) return null;
return (
<div
role="tabpanel"
id={`${baseId}-panel-${index}`}
aria-labelledby={`${baseId}-tab-${index}`}
className="tab-panel"
>
{children}
</div>
);
}
// Attach sub-components for dot notation usage
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panels = TabPanels;
Tabs.Panel = TabPanel;
export { Tabs };
Usage is cohesive and self-documenting:
<Tabs defaultIndex={0} onChange={handleTabChange}>
<Tabs.List>
<Tabs.Tab index={0}>Account</Tabs.Tab>
<Tabs.Tab index={1}>Security</Tabs.Tab>
<Tabs.Tab index={2}>Notifications</Tabs.Tab>
</Tabs.List>
<Tabs.Panels>
<Tabs.Panel index={0}><AccountSettings /></Tabs.Panel>
<Tabs.Panel index={1}><SecuritySettings /></Tabs.Panel>
<Tabs.Panel index={2}><NotificationSettings /></Tabs.Panel>
</Tabs.Panels>
</Tabs>
┌─────────────────────────────────────────────────────────────────────────┐
│ COMPOUND PATTERN vs ATOMIC DESIGN │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ATOMIC APPROACH: │
│ ──────────────── │
│ atoms/ TabButton.tsx (standalone, needs context passed) │
│ atoms/ TabPanel.tsx (standalone, needs context passed) │
│ molecules/ TabList.tsx (composes TabButtons) │
│ molecules/ TabPanelGroup.tsx │
│ organisms/ Tabs.tsx (composes everything, owns state) │
│ │
│ Problems: │
│ • Files scattered across 3 directories │
│ • TabButton exists as a standalone atom — but is it ever used alone? │
│ • State management requires prop drilling or context anyway │
│ • Changing tab behavior requires changes in 3 directories │
│ │
│ COMPOUND APPROACH: │
│ ────────────────── │
│ components/Tabs/ │
│ ├── index.ts (exports Tabs with attached sub-components) │
│ ├── Tabs.tsx (root component, context provider) │
│ ├── Tab.tsx (child component, context consumer) │
│ ├── ... │
│ │
│ Benefits: │
│ • All related code in one directory │
│ • Clear public API: Tabs.Tab, Tabs.Panel (can't use Tab without Tabs) │
│ • State management encapsulated in the component boundary │
│ • Changing tabs is a change to one feature boundary │
│ • Tree-shakeable — unused sub-components not bundled │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Architecture Pattern 3: Variant-Driven Component Design
Instead of creating separate components for each visual variation (Button, PrimaryButton, SecondaryButton, IconButton...), use a single component with explicit variants:
// components/Button/Button.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { forwardRef } from 'react';
const buttonVariants = cva(
// Base styles applied to all variants
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
intent: {
primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
sm: 'h-8 px-3 text-xs',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
icon: 'h-10 w-10',
},
},
defaultVariants: {
intent: 'primary',
size: 'md',
},
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, intent, size, isLoading, children, disabled, ...props }, ref) => {
return (
<button
ref={ref}
className={buttonVariants({ intent, size, className })}
disabled={disabled || isLoading}
{...props}
>
{isLoading && <Spinner className="mr-2 h-4 w-4 animate-spin" />}
{children}
</button>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };
The variant pattern provides:
- Type-safe API:
intentandsizeare constrained to valid values - Single source of truth: All button variations in one file
- Composable:
buttonVariantscan be reused for button-like links - Discoverable: One component, one import, all variations accessible via props
// Usage
<Button intent="primary" size="lg">Submit</Button>
<Button intent="ghost" size="icon"><TrashIcon /></Button>
<Button intent="destructive" isLoading>Delete Account</Button>
// For links that look like buttons:
import { buttonVariants } from '@/components/Button';
<Link href="/signup" className={buttonVariants({ intent: 'primary', size: 'lg' })}>
Sign Up
</Link>
Architecture Pattern 4: Render Props and Headless Components
Separate behavior from presentation entirely. This is the ultimate solution to "is this an atom or molecule?" — the question becomes irrelevant:
// Headless component — no UI, pure behavior
function useToggle(initialState = false) {
const [isOpen, setIsOpen] = useState(initialState);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const toggle = useCallback(() => setIsOpen(prev => !prev), []);
return { isOpen, open, close, toggle, setIsOpen };
}
// Headless disclosure with accessibility built in
function useDisclosure({ id }: { id?: string } = {}) {
const generatedId = useId();
const buttonId = id ? `${id}-button` : `${generatedId}-button`;
const panelId = id ? `${id}-panel` : `${generatedId}-panel`;
const { isOpen, toggle, close } = useToggle();
const buttonProps = {
id: buttonId,
'aria-expanded': isOpen,
'aria-controls': panelId,
onClick: toggle,
};
const panelProps = {
id: panelId,
'aria-labelledby': buttonId,
hidden: !isOpen,
};
return {
isOpen,
close,
buttonProps,
panelProps,
};
}
Now any visual representation can use this behavior:
// Accordion implementation
function Accordion({ items }: { items: AccordionItem[] }) {
return (
<div className="accordion">
{items.map((item, index) => (
<AccordionItem key={index} title={item.title}>
{item.content}
</AccordionItem>
))}
</div>
);
}
function AccordionItem({ title, children }: AccordionItemProps) {
const { isOpen, buttonProps, panelProps } = useDisclosure();
return (
<div className="accordion-item">
<button {...buttonProps} className="accordion-trigger">
{title}
<ChevronIcon className={isOpen ? 'rotate-180' : ''} />
</button>
<div {...panelProps} className="accordion-content">
{children}
</div>
</div>
);
}
// Dropdown implementation — SAME BEHAVIOR, different UI
function Dropdown({ trigger, children }: DropdownProps) {
const { isOpen, close, buttonProps, panelProps } = useDisclosure();
const dropdownRef = useRef<HTMLDivElement>(null);
useClickOutside(dropdownRef, close);
useEscapeKey(close);
return (
<div ref={dropdownRef} className="dropdown">
<button {...buttonProps} className="dropdown-trigger">
{trigger}
</button>
<div {...panelProps} className="dropdown-menu">
{children}
</div>
</div>
);
}
// FAQ section — SAME BEHAVIOR, completely different context
function FAQ({ questions }: { questions: FAQItem[] }) {
return (
<section className="faq">
<h2>Frequently Asked Questions</h2>
{questions.map((q, i) => {
const { isOpen, buttonProps, panelProps } = useDisclosure();
return (
<article key={i} className="faq-item">
<h3>
<button {...buttonProps}>{q.question}</button>
</h3>
<div {...panelProps}>
<p>{q.answer}</p>
</div>
</article>
);
})}
</section>
);
}
┌─────────────────────────────────────────────────────────────────────────┐
│ HEADLESS PATTERN ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ BEHAVIOR LAYER (hooks) │ │
│ │ │ │
│ │ useToggle useDisclosure useCombobox useListbox │ │
│ │ useDialog useTooltip useMenu useSlider │ │
│ │ │ │
│ │ • No DOM │ │
│ │ • No styles │ │
│ │ • Returns: state + handlers + ARIA props │ │
│ │ • Can be tested without rendering │ │
│ └────────────────────────────┬──────────────────────────────────────┘ │
│ │ │
│ │ consumed by │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ COMPONENT LAYER │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Accordion │ │ Dropdown │ │ FAQ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ uses │ │ uses │ │ uses │ │ │
│ │ │ disclosure │ │ disclosure │ │ disclosure │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │
│ │ Same behavior, different: │ │
│ │ • Visual design │ │
│ │ • DOM structure │ │
│ │ • Class names │ │
│ │ • Animation │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ Benefits: │
│ • Behavior is tested once, reused everywhere │
│ • Visual designers can create any appearance │
│ • No "is this an atom?" debates — behavior hooks are just hooks │
│ • Accessibility is built into the behavior layer │
│ • Framework-agnostic behavior (hooks can theoretically be adapted) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
This is the pattern used by:
- Radix UI: Primitives provide behavior, you provide styles
- Headless UI: Completely unstyled components with full accessibility
- Downshift: Headless combobox/autocomplete
- React Aria: Hooks that provide behavior and ARIA attributes
Architecture Pattern 5: Domain-Driven Component Boundaries
Align component boundaries with domain boundaries, not visual boundaries:
┌─────────────────────────────────────────────────────────────────────────┐
│ DOMAIN-DRIVEN BOUNDARIES │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ E-COMMERCE EXAMPLE: │
│ │
│ Instead of: │
│ atoms/ Price.tsx, Badge.tsx, Rating.tsx │
│ molecules/ ProductTitle.tsx, ProductBadges.tsx │
│ organisms/ ProductCard.tsx, ProductDetails.tsx │
│ │
│ Use domain boundaries: │
│ │
│ domains/ │
│ ├── catalog/ # Product browsing │
│ │ ├── ProductCard.tsx │
│ │ ├── ProductGrid.tsx │
│ │ ├── ProductFilters.tsx │
│ │ ├── ProductSearch.tsx │
│ │ └── hooks/ │
│ │ ├── useProductSearch.ts │
│ │ └── useProductFilters.ts │
│ │ │
│ ├── product-detail/ # Single product view │
│ │ ├── ProductHero.tsx │
│ │ ├── ProductGallery.tsx │
│ │ ├── ProductSpecs.tsx │
│ │ ├── ProductReviews.tsx │
│ │ └── VariantSelector.tsx │
│ │ │
│ ├── cart/ # Shopping cart │
│ │ ├── CartDrawer.tsx │
│ │ ├── CartItem.tsx │
│ │ ├── CartSummary.tsx │
│ │ └── context/ │
│ │ └── CartProvider.tsx │
│ │ │
│ ├── checkout/ # Purchase flow │
│ │ ├── CheckoutForm.tsx │
│ │ ├── ShippingStep.tsx │
│ │ ├── PaymentStep.tsx │
│ │ └── OrderConfirmation.tsx │
│ │ │
│ └── inventory/ # Stock management (admin) │
│ ├── StockTable.tsx │
│ ├── RestockForm.tsx │
│ └── LowStockAlert.tsx │
│ │
│ Why this is better: │
│ • Change "how we display products" → only catalog/ changes │
│ • Change "checkout flow" → only checkout/ changes │
│ • Different teams can own different domains │
│ • Domain expert language in component names (not "organism") │
│ • Code navigation matches mental model of the business │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Cross-Domain Shared Components
What about components used across domains? Extract to a shared layer:
src/
├── domains/ # Business domains (feature boundaries)
│ ├── catalog/
│ ├── cart/
│ └── checkout/
│
├── components/ # Shared UI components (design system)
│ ├── primitives/ # Unstyled, behavior-only components
│ │ ├── Dialog/
│ │ ├── Popover/
│ │ └── ...
│ │
│ ├── ui/ # Styled, brand-specific components
│ │ ├── Button/
│ │ ├── Input/
│ │ ├── Card/
│ │ └── ...
│ │
│ └── layout/ # Layout components
│ ├── Container/
│ ├── Stack/
│ └── Grid/
│
└── shared/ # Cross-domain business components
├── Price.tsx # Price formatting used in catalog, cart, checkout
├── ProductImage.tsx
└── Rating.tsx
The Price component is business logic (currency formatting, sale price display), not a generic "atom." It belongs in shared/ not atoms/.
Measuring Component Architecture Quality
Instead of asking "is this the right atomic category?", measure architecture quality:
Metric 1: Change Locality
Change Locality = Files changed in feature / Total files changed
When adding "show sale badge on products," the change should touch:
domains/catalog/ProductCard.tsxdomains/product-detail/ProductHero.tsx- Maybe
shared/SaleBadge.tsx
Not:
atoms/Badge.tsx(breaks all badge usages)organisms/ProductCard.tsx+organisms/ProductDetails.tsx+organisms/CartItem.tsx(scattered changes)
Metric 2: Import Depth
Import Depth = Average import path length for a component's dependencies
Deep imports (../../../atoms/Button) indicate poor module boundaries. Feature-first organization with barrel exports keeps imports shallow:
// Good: shallow, from feature boundary
import { ProductCard } from '@/features/products';
import { Button } from '@/components/ui';
// Bad: deep, leaking internal structure
import { ProductCard } from '@/organisms/products/ProductCard/ProductCard';
import { Button } from '@/atoms/buttons/Button/Button';
Metric 3: Bundle Impact
When you change a component, how many routes/pages have their bundles invalidated?
Bundle Impact = Routes affected / Total routes
Atomic Design's atoms are imported everywhere → high bundle impact. Feature-organized components are scoped → low bundle impact.
Metric 4: Delete-ability
Can you delete a feature without breaking others?
# This should work for a well-architected feature
rm -rf src/features/abandoned-feature
npm run typecheck # Should pass
npm run test # Should pass (except feature's own tests)
If deleting a feature causes type errors in unrelated features, your boundaries are wrong.
The Actual Component Taxonomy That Matters
Instead of atoms/molecules/organisms, use these categories based on responsibility:
┌─────────────────────────────────────────────────────────────────────────┐
│ RESPONSIBILITY-BASED COMPONENT TAXONOMY │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ PRIMITIVES (Headless) │
│ ───────────────────── │
│ Responsibility: Behavior + accessibility, no styles │
│ Examples: Dialog, Popover, Tooltip, Combobox (from Radix/HeadlessUI) │
│ Ownership: Design system team │
│ Change frequency: Rarely (behavior is stable) │
│ │
│ UI COMPONENTS (Styled) │
│ ───────────────────── │
│ Responsibility: Visual presentation, composition of primitives │
│ Examples: Button, Input, Card, Badge, Avatar │
│ Ownership: Design system team │
│ Change frequency: When design tokens/brand changes │
│ │
│ LAYOUT COMPONENTS │
│ ───────────────── │
│ Responsibility: Spacing, positioning, responsive behavior │
│ Examples: Stack, Grid, Container, Flex, AspectRatio │
│ Ownership: Design system team │
│ Change frequency: Rarely │
│ │
│ DOMAIN COMPONENTS │
│ ───────────────── │
│ Responsibility: Business logic + UI for a specific domain │
│ Examples: ProductCard, CartSummary, UserAvatar, InvoiceTable │
│ Ownership: Feature teams │
│ Change frequency: Often (business requirements change) │
│ │
│ COMPOSITION COMPONENTS │
│ ───────────────────── │
│ Responsibility: Combining domain + UI components for a use case │
│ Examples: CheckoutPage, DashboardLayout, SettingsPanel │
│ Ownership: Feature teams │
│ Change frequency: Moderate (when features evolve) │
│ │
│ ROUTE COMPONENTS (Pages) │
│ ──────────────────────── │
│ Responsibility: Data fetching, route params, page-level concerns │
│ Examples: app/products/[id]/page.tsx │
│ Ownership: Feature teams │
│ Change frequency: When routes/data requirements change │
│ │
└─────────────────────────────────────────────────────────────────────────┘
This taxonomy answers practical questions:
- "Where does this code go?" → What's its responsibility?
- "Who owns this?" → Primitives/UI = design system, Domain = feature team
- "How often will this change?" → Affects how stable the API needs to be
- "Who needs to review changes?" → Follows from ownership
Practical Migration: From Atomic to Feature-First
If you have an existing Atomic Design codebase, migrate incrementally:
Phase 1: Create Feature Boundaries
# Create feature directories
mkdir -p src/features/{auth,products,cart,checkout}
# Keep existing atoms/molecules/organisms for now
# They become the "shared components" layer
Phase 2: Move Domain Logic to Features
// Before: organisms/ProductCard/ProductCard.tsx
export function ProductCard({ product, onAddToCart }) {
// Contains business logic: price formatting, sale detection, etc.
}
// After: features/products/ProductCard.tsx
import { Card, Button, Badge } from '@/components/ui';
import { formatPrice } from '../utils/formatPrice';
export function ProductCard({ product, onAddToCart }) {
// Same component, now in feature context
// Can use feature-specific utilities
}
Phase 3: Extract True Primitives
// Identify atoms that are ACTUALLY generic
// atoms/Button.tsx → components/ui/Button.tsx
// atoms/Input.tsx → components/ui/Input.tsx
// Identify "atoms" that are domain-specific
// atoms/ProductBadge.tsx → features/products/components/ProductBadge.tsx
// atoms/CartIcon.tsx → features/cart/components/CartIcon.tsx
Phase 4: Update Imports and Delete Empty Directories
// Update imports across codebase
// From: import { Button } from '@/atoms/Button';
// To: import { Button } from '@/components/ui';
// From: import { ProductCard } from '@/organisms/ProductCard';
// To: import { ProductCard } from '@/features/products';
Phase 5: Enforce Boundaries
Add ESLint rules to prevent regression:
// .eslintrc.js
module.exports = {
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@/atoms',
message: 'Migrate to @/components/ui or feature-specific components',
},
{
name: '@/molecules',
message: 'Migrate to @/components/ui or feature-specific components',
},
{
name: '@/organisms',
message: 'Migrate to @/features/* or @/components/*',
},
],
},
],
},
};
Conclusion: Architecture Over Taxonomy
Atomic Design gave frontend development a shared vocabulary, and that's valuable. But vocabulary isn't architecture. The questions that matter for production systems — how do components couple, how do changes propagate, who owns what, how do we scale teams — require architectural patterns that Atomic Design doesn't provide.
The alternative isn't "no taxonomy" — it's a taxonomy based on responsibility rather than size:
- Primitives: Behavior without style
- UI components: Styled, brand-consistent atoms
- Layout components: Spacing and positioning
- Domain components: Business logic in component form
- Composition components: Combining pieces for use cases
- Route components: Data fetching and page-level concerns
Organize code by feature (change boundary), not by visual granularity (atoms/molecules). Enforce boundaries with tooling. Measure architecture quality with change locality, import depth, and delete-ability.
The goal isn't to categorize components correctly. It's to build systems where changes are easy, ownership is clear, and the codebase scales with the team.
What did you think?