System Design & Architecture
Part 0 of 9Atomic Design Is Not Enough — Rethinking Component Architecture for Large Teams
Atomic Design Is Not Enough — Rethinking Component Architecture for Large Teams
Where atomic design breaks down at scale, feature-sliced design as an alternative, domain-driven folder structures, and how component ownership affects team autonomy.
The Atomic Design Promise
Brad Frost's atomic design gave us a shared vocabulary: atoms, molecules, organisms, templates, pages. It mapped chemistry to UI composition — small units combine into larger structures.
components/
├── atoms/
│ ├── Button.tsx
│ ├── Input.tsx
│ └── Label.tsx
├── molecules/
│ ├── FormField.tsx
│ └── SearchBox.tsx
├── organisms/
│ ├── Header.tsx
│ └── ProductCard.tsx
├── templates/
│ └── ProductListTemplate.tsx
└── pages/
└── ProductListPage.tsx
For small teams building design systems, this works. Components flow upward in complexity. The hierarchy is intuitive.
Then your organization scales to 50+ engineers across 8 squads. And the model collapses under its own assumptions.
Where Atomic Design Breaks Down
Problem 1: Classification Ambiguity Creates Coordination Overhead
The boundaries between layers are subjective and context-dependent.
┌─────────────────────────────────────────────────────────────────────────────┐
│ THE CLASSIFICATION DEBATE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Component: <UserAvatar /> │
│ │
│ Engineer A: "It's an atom — renders an <img> with fallback" │
│ │
│ Engineer B: "It's a molecule — has tooltip, loading state, error boundary" │
│ │
│ Engineer C: "It's an organism — fetches user data, handles caching" │
│ │
│ Engineer D: "Depends on which variant — we have 3 implementations" │
│ │
│ Result: 45-minute PR review thread, zero business value, lingering tension │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The deeper issue: atomic design classifies by visual/structural complexity, but engineers reason about behavioral complexity. A component that "looks" simple (an avatar) may have complex behavior (fetching, caching, error states, analytics events). The taxonomy doesn't capture this.
Each team develops local interpretations. Cross-team PRs become classification arguments. New engineers learn different rules depending on which squad onboards them.
Problem 2: Ownership Fragmentation
When Squad A owns checkout and Squad B owns user profiles, who owns <AddressForm />?
molecules/
└── AddressForm/
├── AddressForm.tsx # Used by checkout AND user-profile AND order-history
├── AddressForm.test.tsx
└── index.ts
Questions with no clear answers:
- Who reviews changes to this component?
- Who's paged when it breaks in production at 3am?
- Whose quarterly roadmap includes "improve address validation UX"?
- Who decides if we add international address support?
- Who pays down the tech debt accumulated over 2 years?
Atomic design organizes by visual complexity, not by business domain. At scale, this creates orphaned components — artifacts that multiple teams depend on but nobody owns. They accumulate tech debt because ownership is diffuse.
Problem 3: The Shared Layer Becomes a Tragedy of the Commons
Without clear ownership, shared folders accumulate cruft through rational individual decisions:
atoms/
├── Button.tsx # Original
├── ButtonV2.tsx # "Needed different padding for mobile"
├── ButtonNew.tsx # "V2 broke our dark mode, forked it"
├── ButtonPrimary.tsx # "Wanted semantic naming"
├── ButtonWithIcon.tsx # "Icon support bolted on"
├── ButtonAsync.tsx # "Loading state variant"
├── Button.deprecated.tsx # "Please migrate to ButtonV2"
├── _Button.old.tsx # "Keeping for reference"
└── README.md # "TODO: consolidate buttons" (18 months old)
Each fork is locally rational:
- Forking is faster than cross-team coordination
- Nobody wants to own the migration
- "My deadline is next week, I'll clean it up later"
- Later never comes because there's always a next deadline
The shared layer grows monotonically. Deletion requires proving nothing depends on a component — high effort, zero business value visible to stakeholders.
Problem 4: Import Paths Encode No Useful Information
import { Button } from '@/components/atoms/Button';
import { ProductCard } from '@/components/organisms/ProductCard';
import { CheckoutForm } from '@/components/organisms/CheckoutForm';
From these imports, an engineer cannot determine:
- Which team owns
CheckoutForm? - Can I modify
ProductCardwithout breaking another squad's release? - Will changing
Buttonrequire coordinating with 6 other teams? - What's the blast radius if I introduce a regression?
- Who should review my PR?
The import path tells you visual complexity (organism). It tells you nothing about organizational complexity (ownership, dependencies, change coordination).
Problem 5: No Layering Enforcement
Atomic design describes a hierarchy but doesn't enforce it. Nothing prevents:
// atoms/Input.tsx importing from organisms — inversion
import { FormContext } from '@/components/organisms/Form';
// molecules/SearchBox.tsx depending on a specific page's state
import { useSearchPageFilters } from '@/pages/Search/hooks';
// organisms/Header.tsx reaching into another organism's internals
import { CartBadge } from '@/components/organisms/Cart/CartBadge';
Over time, the dependency graph becomes a hairball. "Atom" and "molecule" become meaningless labels attached to components with arbitrary dependencies. Refactoring becomes impossible without understanding the entire graph.
Feature-Sliced Design: A Structured Alternative
Feature-Sliced Design (FSD) reorganizes around business capability with enforced layering.
src/
├── app/ # App composition: routing, providers, global config
│ ├── providers/
│ ├── routes/
│ └── index.tsx
│
├── pages/ # Route-level compositions
│ ├── checkout/
│ ├── product-list/
│ └── user-profile/
│
├── widgets/ # Large self-contained UI blocks (cross-feature)
│ ├── header/
│ ├── sidebar/
│ └── footer/
│
├── features/ # User-facing capabilities with business logic
│ ├── add-to-cart/
│ │ ├── ui/
│ │ │ └── AddToCartButton.tsx
│ │ ├── model/
│ │ │ ├── store.ts
│ │ │ └── types.ts
│ │ ├── api/
│ │ │ └── addToCart.ts
│ │ └── index.ts # Public API
│ ├── checkout/
│ ├── user-auth/
│ └── product-search/
│
├── entities/ # Business domain objects (data + minimal UI)
│ ├── user/
│ │ ├── ui/
│ │ │ └── UserAvatar.tsx
│ │ ├── model/
│ │ │ └── types.ts
│ │ ├── api/
│ │ │ └── userApi.ts
│ │ └── index.ts
│ ├── product/
│ ├── order/
│ └── cart/
│
└── shared/ # Zero-business-logic infrastructure
├── ui/ # Design system primitives
│ ├── Button/
│ ├── Input/
│ └── Modal/
├── lib/ # Utilities (dates, formatting, validation)
├── api/ # API client, interceptors, error handling
└── config/ # Environment, feature flags
The Core Constraint: Unidirectional Dependencies
FSD enforces a strict import rule: layers can only import from layers below them.
┌─────────────────────────────────────────────────────────────────────────────┐
│ LAYER DEPENDENCY RULES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ app ──────► can import: pages, widgets, features, entities, shared │
│ │ │
│ ▼ │
│ pages ──────► can import: widgets, features, entities, shared │
│ │ │
│ ▼ │
│ widgets ──────► can import: features, entities, shared │
│ │ │
│ ▼ │
│ features ──────► can import: entities, shared │
│ │ CANNOT import: other features (critical constraint) │
│ ▼ │
│ entities ──────► can import: shared only │
│ │ CANNOT import: other entities │
│ ▼ │
│ shared ──────► can import: nothing from above (leaf layer) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Why features can't import other features:
This prevents horizontal coupling. If add-to-cart imports user-auth, changes to auth can break cart. If both features need to interact, the composition happens in a higher layer (widgets or pages) that owns the integration.
// BAD: feature importing feature (horizontal coupling)
// features/add-to-cart/ui/AddToCartButton.tsx
import { useCurrentUser } from '@/features/user-auth'; // FORBIDDEN
// GOOD: composition at page level
// pages/product/ProductPage.tsx
import { useCurrentUser } from '@/features/user-auth';
import { AddToCartButton } from '@/features/add-to-cart';
export function ProductPage() {
const { user, isAuthenticated } = useCurrentUser();
return (
<AddToCartButton
disabled={!isAuthenticated}
userId={user?.id} // Data passed down, not fetched inside
/>
);
}
Enforcement via ESLint
// eslint.config.js
module.exports = {
rules: {
'import/no-restricted-paths': ['error', {
zones: [
// features cannot import from other features
{
target: './src/features/',
from: './src/features/',
except: ['.'], // allow importing from own feature
message: 'Features cannot import other features. Compose at page/widget level.'
},
// entities cannot import from features
{
target: './src/entities/',
from: './src/features/',
message: 'Entities cannot depend on features.'
},
// shared cannot import from anything above
{
target: './src/shared/',
from: ['./src/entities/', './src/features/', './src/widgets/', './src/pages/'],
message: 'Shared layer must have no dependencies on upper layers.'
},
// entities cannot import other entities
{
target: './src/entities/user/',
from: './src/entities/!(user)/**',
message: 'Entities cannot import other entities. Use composition.'
}
]
}]
}
};
Slice-Level Organization
Within each layer, organize by slice (business subdomain), not by technical concern:
# BAD: organized by technical concern
features/
├── components/
│ ├── AddToCartButton.tsx
│ ├── CheckoutForm.tsx
│ └── AuthModal.tsx
├── hooks/
│ ├── useCart.ts
│ ├── useCheckout.ts
│ └── useAuth.ts
└── api/
├── cartApi.ts
├── checkoutApi.ts
└── authApi.ts
# GOOD: organized by slice (business capability)
features/
├── add-to-cart/
│ ├── ui/
│ ├── model/
│ ├── api/
│ └── index.ts
├── checkout/
│ ├── ui/
│ ├── model/
│ ├── api/
│ └── index.ts
└── user-auth/
├── ui/
├── model/
├── api/
└── index.ts
Why this matters for teams: A squad can own an entire slice. All code for "checkout" lives in features/checkout/. PR reviews stay within the squad. On-call ownership is unambiguous. Roadmap items map to code locations.
Domain-Driven Folder Structures
For organizations with clear team boundaries (typically 50+ engineers), consider a domain-driven structure that makes ownership explicit at the top level:
src/
├── domains/
│ ├── payments/ # Squad: Payments
│ │ ├── features/
│ │ │ ├── checkout/
│ │ │ │ ├── ui/
│ │ │ │ │ ├── CheckoutForm.tsx
│ │ │ │ │ ├── PaymentMethodSelector.tsx
│ │ │ │ │ └── OrderSummary.tsx
│ │ │ │ ├── model/
│ │ │ │ │ ├── checkoutMachine.ts # XState machine
│ │ │ │ │ ├── validators.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── api/
│ │ │ │ │ ├── createOrder.ts
│ │ │ │ │ └── validatePayment.ts
│ │ │ │ └── index.ts
│ │ │ └── refunds/
│ │ ├── entities/
│ │ │ ├── order/
│ │ │ └── payment-method/
│ │ ├── OWNERS.yaml # Ownership metadata
│ │ └── index.ts # Domain public API
│ │
│ ├── catalog/ # Squad: Discovery
│ │ ├── features/
│ │ │ ├── product-search/
│ │ │ ├── product-detail/
│ │ │ └── category-browse/
│ │ ├── entities/
│ │ │ ├── product/
│ │ │ └── category/
│ │ ├── OWNERS.yaml
│ │ └── index.ts
│ │
│ ├── identity/ # Squad: Identity
│ │ ├── features/
│ │ │ ├── authentication/
│ │ │ ├── profile-management/
│ │ │ └── session/
│ │ ├── entities/
│ │ │ └── user/
│ │ ├── OWNERS.yaml
│ │ └── index.ts
│ │
│ └── fulfillment/ # Squad: Logistics
│ ├── features/
│ ├── entities/
│ ├── OWNERS.yaml
│ └── index.ts
│
├── platform/ # Squad: Platform (shared infrastructure)
│ ├── design-system/
│ │ ├── primitives/ # Unstyled, headless components
│ │ │ ├── Button/
│ │ │ ├── Dialog/
│ │ │ └── Popover/
│ │ ├── components/ # Styled, opinionated components
│ │ │ ├── PrimaryButton/
│ │ │ └── ConfirmDialog/
│ │ ├── tokens/ # Design tokens
│ │ │ ├── colors.ts
│ │ │ ├── spacing.ts
│ │ │ └── typography.ts
│ │ └── index.ts
│ ├── analytics/
│ ├── error-handling/
│ ├── feature-flags/
│ └── api-client/
│
└── app/ # App shell (thin)
├── routes/
├── providers/
└── index.tsx
Domain Public API Contract
Each domain exposes a public API through its index.ts. Internal implementation details are private.
// domains/payments/index.ts
// ─── PUBLIC COMPONENTS ─────────────────────────────────────────────────────
export { CheckoutForm } from './features/checkout/ui/CheckoutForm';
export { OrderSummary } from './features/checkout/ui/OrderSummary';
export { RefundRequestForm } from './features/refunds/ui/RefundRequestForm';
// ─── PUBLIC HOOKS ──────────────────────────────────────────────────────────
export { useCheckout } from './features/checkout/model/useCheckout';
export { usePaymentMethods } from './features/checkout/model/usePaymentMethods';
export { useOrderHistory } from './features/order-history/model/useOrderHistory';
// ─── PUBLIC TYPES ──────────────────────────────────────────────────────────
export type { Order, OrderStatus, LineItem } from './entities/order/types';
export type { PaymentMethod, PaymentIntent } from './entities/payment-method/types';
export type { CheckoutState, CheckoutError } from './features/checkout/model/types';
// ─── NOT EXPORTED (PRIVATE) ────────────────────────────────────────────────
// - PaymentMethodSelector (internal composition detail)
// - checkoutMachine (internal state machine)
// - validatePayment (internal API call)
// - usePaymentValidation (internal hook)
Enforcement via path restrictions:
// eslint.config.js
module.exports = {
rules: {
'no-restricted-imports': ['error', {
patterns: [
{
group: ['@/domains/*/features/*', '@/domains/*/entities/*'],
message: 'Import from domain public API: @/domains/payments, not internals'
}
]
}]
}
};
// tsconfig.json paths (optional: prevent deep imports at compile time)
{
"compilerOptions": {
"paths": {
"@/domains/payments": ["src/domains/payments/index.ts"],
"@/domains/catalog": ["src/domains/catalog/index.ts"],
// No paths for internals — imports will fail
}
}
}
Ownership Metadata: OWNERS.yaml
# domains/payments/OWNERS.yaml
domain: payments
squad: payments-platform
slack_channel: '#payments-eng'
pagerduty_service: payments-oncall
jira_board: PAY
maintainers:
primary:
- alice@company.com # Tech lead
- bob@company.com # Senior engineer
secondary:
- carol@company.com # Recently onboarded
# Domains that depend on this domain's public API
consumers:
- domains/fulfillment # Uses Order types
- domains/catalog # Uses price calculation
# Required reviewers for public API changes
api_change_reviewers:
- alice@company.com
- platform-api-review@company.com
# SLOs for this domain's components
slos:
checkout_form_render_p99_ms: 200
payment_api_success_rate: 0.999
# Deployment constraints
deployment:
requires_flag: true # All changes behind feature flag
canary_percentage: 5 # Initial rollout percentage
bake_time_hours: 24 # Time before full rollout
This metadata enables:
- Automated PR reviewer assignment
- On-call routing in monitoring tools
- CODEOWNERS file generation
- Dependency impact analysis ("which teams are affected if we change X?")
- SLO dashboards per domain
Cross-Domain Communication Patterns
When domains need to interact, avoid direct coupling. Use explicit integration patterns.
Pattern 1: Event-Based Decoupling
// domains/payments/features/checkout/model/useCheckout.ts
import { domainEvents } from '@/platform/events';
export function useCheckout() {
const completeCheckout = async (cart: Cart) => {
const order = await createOrder(cart);
// Emit event — don't call other domains directly
domainEvents.emit('payments:order_completed', {
orderId: order.id,
userId: order.userId,
items: order.items,
total: order.total,
timestamp: Date.now(),
});
return order;
};
return { completeCheckout };
}
// domains/fulfillment/features/shipping/model/useShippingTrigger.ts
import { domainEvents } from '@/platform/events';
// Fulfillment listens, doesn't import from payments
domainEvents.on('payments:order_completed', async (event) => {
await initiateShipment({
orderId: event.orderId,
items: event.items,
});
});
Pattern 2: Shared Kernel for Common Types
When multiple domains genuinely share a concept, extract to a shared kernel owned by platform:
platform/
└── shared-kernel/
├── money/
│ ├── Money.ts # Value object
│ ├── Currency.ts
│ └── index.ts
├── address/
│ ├── Address.ts
│ ├── validators.ts
│ └── index.ts
└── temporal/
├── DateRange.ts
└── index.ts
Rules for shared kernel:
- Owned by platform team with clear RFC process
- Must be domain-agnostic (no business logic)
- Versioned with semantic versioning
- Changes require sign-off from all consuming domains
Pattern 3: Anti-Corruption Layer for External Domains
When integrating with a domain that has a different model:
// domains/fulfillment/acl/paymentsAdapter.ts
import type { Order as PaymentsOrder } from '@/domains/payments';
import type { ShipmentRequest } from '../entities/shipment/types';
/**
* Anti-corruption layer: translates Payments domain model
* to Fulfillment domain model, isolating us from their changes.
*/
export function toShipmentRequest(order: PaymentsOrder): ShipmentRequest {
return {
shipmentId: generateShipmentId(),
externalOrderRef: order.id, // We don't own this ID
// Transform their line items to our shipment items
items: order.items.map(item => ({
sku: item.productId, // Different naming in our domain
quantity: item.quantity,
weight: lookupWeight(item.productId), // We enrich with our data
})),
// Their address model → our address model
destination: normalizeAddress(order.shippingAddress),
// We add our own fields
carrier: selectCarrier(order.shippingAddress),
priority: derivePriority(order.total, order.items),
};
}
Component Ownership and Team Autonomy
The Ownership Spectrum
┌─────────────────────────────────────────────────────────────────────────────┐
│ OWNERSHIP × AUTONOMY MATRIX │
├──────────────────┬──────────────────┬───────────────────┬───────────────────┤
│ Component Type │ Ownership │ Change Process │ Autonomy Level │
├──────────────────┼──────────────────┼───────────────────┼───────────────────┤
│ Domain-private │ Single squad │ Squad ships │ FULL │
│ component │ (exclusive) │ independently │ │
│ │ │ │ │
│ Domain-public │ Single squad │ Squad ships, │ HIGH │
│ API │ (producer) │ notifies │ (with contract) │
│ │ │ consumers │ │
│ │ │ │ │
│ Cross-domain │ Designated │ RFC + review │ MEDIUM │
│ shared │ owner │ from consumers │ │
│ │ │ │ │
│ Platform/ │ Platform team │ RFC + staged │ LOW │
│ design system │ (centralized) │ rollout + flag │ (intentionally) │
│ │ │ │ │
│ Orphaned/ │ NOBODY │ Blocked on │ ZERO │
│ legacy │ │ finding owner │ (organizational │
│ │ │ │ failure mode) │
└──────────────────┴──────────────────┴───────────────────┴───────────────────┘
Goal: maximize components in "Domain-private" and "Domain-public API" rows.
Every component requiring cross-team coordination is a tax on velocity. Coordination has costs:
- Meeting overhead (syncs, reviews, RFCs)
- Queueing delays (waiting on other team's sprint)
- Context-switching costs (explaining your use case)
- Negotiation overhead (whose requirements take priority?)
Measuring Ownership Health
// scripts/analyze-ownership.ts
interface OwnershipMetrics {
// Goal: 100%
componentsWithOwner: number;
// Goal: <5% (minimize shared surface area)
componentsRequiringCrossTeamReview: number;
// Goal: 0 (orphans are tech debt)
orphanedComponents: number;
// Goal: >80% (changes within single team's scope)
prsWithSingleTeamScope: number;
// Goal: <2 days (cross-team coordination speed)
avgCrossTeamPrTimeToMerge: number;
// Goal: minimize (coupling indicator)
avgConsumersPerPublicApi: number;
}
function analyzeOwnership(codebase: Codebase): OwnershipMetrics {
const components = getAllComponents(codebase);
return {
componentsWithOwner: components.filter(c =>
c.ownerFile !== null
).length / components.length,
componentsRequiringCrossTeamReview: components.filter(c =>
c.consumers.some(consumer => consumer.team !== c.owner)
).length / components.length,
orphanedComponents: components.filter(c =>
c.ownerFile === null && c.lastModified < monthsAgo(6)
).length,
// ... computed from git history
};
}
Autonomy-Preserving Patterns
Pattern: Component Forking with Convergence SLA
When a team needs a variant that doesn't fit the shared component:
# domains/payments/features/checkout/FORK_REGISTRY.yaml
forks:
- component: "@/platform/design-system/Button"
fork_location: "./ui/CheckoutButton.tsx"
reason: "Needs Stripe Elements integration, loading states specific to payment processing"
created: 2024-01-15
convergence_deadline: 2024-04-15
convergence_plan: |
Platform team RFC-2024-12 will add async loading states.
Once shipped, we migrate to shared component.
owner: alice@company.com
Forks are allowed, but tracked:
- Must document reason
- Must have convergence plan
- Audited quarterly (script checks for expired deadlines)
- Metrics tracked: fork count, fork age, convergence rate
Pattern: Consumer-Driven Contract Tests
When a public API has multiple consumers:
// domains/catalog/contracts/product-api.contract.ts
import { Contract } from '@/platform/testing/contracts';
/**
* Consumer-driven contracts: consumers define their expectations,
* producer (catalog) runs these tests on every change.
*/
export const productApiContracts = Contract.define({
producer: 'domains/catalog',
consumers: ['domains/payments', 'domains/fulfillment'],
contracts: [
{
consumer: 'domains/payments',
name: 'product_has_price_field',
assertion: (product: Product) => {
expect(product.price).toBeDefined();
expect(product.price.currency).toMatch(/^[A-Z]{3}$/);
expect(product.price.amount).toBeGreaterThanOrEqual(0);
},
},
{
consumer: 'domains/fulfillment',
name: 'product_has_dimensions',
assertion: (product: Product) => {
expect(product.dimensions).toBeDefined();
expect(product.dimensions.weight).toBeGreaterThan(0);
},
},
],
});
If catalog changes Product type, contract tests fail before merge. Consumers are protected. Producer knows exactly what's depended upon.
Migration Strategy: Atomic → Domain-Driven
Phase 1: Dependency Analysis (Week 1-2)
Map the actual dependency graph, not the folder structure:
# Generate import graph
npx madge --image dependency-graph.svg src/
# Find clusters (components that import each other heavily)
npx madge --circular src/ # Find circular dependencies
// scripts/analyze-clusters.ts
interface ComponentCluster {
components: string[];
internalEdges: number; // imports within cluster
externalEdges: number; // imports from outside
cohesion: number; // internal / (internal + external)
suggestedDomain: string; // based on naming patterns
}
// High cohesion clusters → good domain candidates
// Low cohesion with many external edges → needs decomposition
Phase 2: Identify Natural Boundaries (Week 2-3)
Before: scattered by visual complexity
atoms/UserAvatar.tsx
atoms/UserBadge.tsx
molecules/UserCard.tsx
molecules/UserTooltip.tsx
organisms/UserProfile.tsx
organisms/UserSettings.tsx
pages/UserDashboard.tsx
After: cohesive by business capability
domains/identity/
├── entities/user/
│ └── ui/
│ ├── Avatar.tsx
│ ├── Badge.tsx
│ └── Card.tsx
├── features/profile/
│ └── ui/
│ ├── Profile.tsx
│ ├── Settings.tsx
│ └── Tooltip.tsx
└── pages/
└── Dashboard.tsx
Phase 3: Extract Platform Layer (Week 3-5)
Move truly generic components to platform. Criteria:
- Zero business logic (no domain concepts)
- Used by 3+ domains
- Stable interface (not frequently changing)
- Worth the coordination cost to centralize
platform/design-system/
├── Button/
│ ├── Button.tsx
│ ├── Button.test.tsx
│ ├── Button.stories.tsx
│ ├── Button.variants.ts # All variants in one place
│ ├── CHANGELOG.md # Versioned changes
│ └── index.ts
├── Input/
├── Modal/
└── index.ts
Platform components have stricter requirements:
- 100% Storybook coverage
- Visual regression tests
- Accessibility audit (WCAG 2.1 AA)
- Performance budget (render time <16ms)
- Semantic versioning with migration guides for breaking changes
Phase 4: Establish Ownership (Week 5-6)
For each remaining component, answer:
- Which squad's domain is this closest to?
- Who has context to maintain it?
- Who's affected when it breaks in production?
- Whose roadmap includes work on this area?
If no clear owner: escalate to engineering leadership. Orphaned components are organizational debt.
Phase 5: Enforce Boundaries (Week 6-8)
// eslint.config.js — final enforcement
module.exports = {
rules: {
'import/no-restricted-paths': ['error', {
zones: [
// Domain isolation
{
target: './src/domains/payments/',
from: './src/domains/!(payments)/**',
message: 'Cross-domain import. Use public API: @/domains/[name]'
},
// Enforce public API
{
target: './src/',
from: './src/domains/*/features/**',
message: 'Import from domain index, not internals'
},
// Layer enforcement (FSD rules)
{
target: './src/domains/*/entities/',
from: './src/domains/*/features/',
message: 'Entities cannot import features'
},
]
}]
}
};
Migration Rollout Strategy
┌─────────────────────────────────────────────────────────────────────────────┐
│ MIGRATION PHASES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Phase 1: New code only │
│ - New features use domain structure │
│ - Legacy code untouched │
│ - ESLint rules in "warn" mode │
│ │
│ Phase 2: High-value migrations │
│ - Migrate 1 domain fully (payments) │
│ - Document patterns, gotchas │
│ - Prove model works │
│ │
│ Phase 3: Parallel structure │
│ - Legacy /components exists alongside /domains │
│ - Gradual migration during feature work │
│ - "Boy scout rule": leave code better than you found it │
│ │
│ Phase 4: Legacy sunset │
│ - ESLint rules in "error" mode │
│ - Dedicated migration sprints for remaining components │
│ - Delete /components folder │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Architecture Decision Framework
When to Use Which Structure
| Criteria | Atomic Design | Feature-Sliced | Domain-Driven |
|---|---|---|---|
| Team size | 1-10 engineers | 10-30 engineers | 30+ engineers |
| Number of squads | 1-2 | 2-5 | 5+ |
| Primary goal | Visual consistency | Feature isolation | Team autonomy |
| Domain complexity | Low | Medium | High |
| Change coordination | Low (everyone knows everything) | Medium | High (explicit contracts needed) |
| Design system maturity | Building it | Have one | Mature, versioned |
| Organizational structure | Single team | Feature teams | Domain/product teams |
Decision Tree
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ How many engineers work on the frontend? │
│ │ │
│ ├── <10: Atomic design is fine │
│ │ Simple, everyone knows the whole codebase │
│ │ │
│ ├── 10-30: Consider Feature-Sliced Design │
│ │ │ │
│ │ └── Do you have distinct product areas? │
│ │ │ │
│ │ ├── No: FSD is sufficient │
│ │ └── Yes: Consider domain-driven │
│ │ │
│ └── 30+: Domain-driven is likely necessary │
│ │ │
│ └── Do you have clear team ownership areas? │
│ │ │
│ ├── No: Define domains first (org design problem) │
│ └── Yes: Align folder structure to team boundaries │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Real Metric: Time to Change
Atomic design optimizes for visual consistency — a valid goal for design systems.
Domain-driven architecture optimizes for time to change — the dominant cost at scale.
┌─────────────────────────────────────────────────────────────────────────────┐
│ COST MODEL COMPARISON │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Scenario: "Add postal code validation to checkout address form" │
│ │
│ ATOMIC DESIGN PATH: │
│ 1. Check atoms/Input — need to add validation prop (30 min) │
│ 2. Input is owned by platform team │
│ 3. Open RFC for validation pattern (2 hours) │
│ 4. Platform review meeting (1 hour) │
│ 5. Wait for platform sprint allocation (1-2 weeks) │
│ 6. Platform implements generic validation (3 days) │
│ 7. Update molecules/FormField to use new validation (1 day) │
│ 8. Update organisms/AddressForm (2 hours) │
│ 9. Coordinate release with platform (1 day) │
│ ───────────────────────────────────────────────── │
│ Total: 2-3 weeks, 3 teams involved │
│ │
│ DOMAIN-DRIVEN PATH: │
│ 1. Open domains/payments/features/checkout/ui/AddressForm.tsx │
│ 2. Add postal code validation (2 hours) │
│ 3. Write tests (1 hour) │
│ 4. PR review from squad member (30 min) │
│ 5. Ship (immediate) │
│ ───────────────────────────────────────────────── │
│ Total: 4 hours, 1 team involved │
│ │
│ Difference: 10-20x faster with domain ownership │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The difference compounds. Over hundreds of changes per year across dozens of engineers, architecture that enables autonomy delivers dramatically more throughput.
Final Thought: Architecture Encodes Organizational Assumptions
Atomic design encodes: "We have a small team building a cohesive design system."
Feature-sliced design encodes: "We have multiple teams that need clear boundaries but share infrastructure."
Domain-driven architecture encodes: "We have many teams that need to ship independently with minimal coordination."
The best architecture for your codebase depends on your organization, not on what's trending on Twitter. Conway's Law is undefeated: your system will reflect your communication structures.
The goal isn't to eliminate shared components. It's to be intentional about which components require cross-team coordination, minimize that set ruthlessly, and make ownership explicit for everything else.
Every component should have exactly one team that can modify it without asking permission. Everything else is coordination overhead. And coordination overhead is where velocity goes to die.
Choose your architecture based on your organization's scale and structure. Then enforce it relentlessly. The code will fight entropy only as long as you make the right path the easy path.
What did you think?