Scaling Frontend Codebases Beyond 5 Engineers
Scaling Frontend Codebases Beyond 5 Engineers
The patterns that work for a 3-person frontend team fail catastrophically at 10 people. Not slowly—catastrophically.
I've seen it happen: a clean codebase turns into spaghetti in 6 months. Pull requests balloon to 2000 lines. Merge conflicts become daily fights. Shipping velocity drops 50%.
The problem isn't technical. It's architectural decisions that don't account for Conway's Law: your system will mirror your communication structure, whether you plan for it or not.
Context: When "It Works" Stops Being Enough
At 3 engineers:
- Everyone knows the whole codebase
- Coordination happens at lunch
- No process needed
- Shared mental model
At 10 engineers:
- Nobody knows everything
- Coordination requires meetings
- Process becomes critical
- Mental models diverge
The codebase structure that worked for 3 people actively sabotages 10 people.
Why This Matters in Production
When your frontend team scales:
- Feature velocity stalls (too many conflicts)
- Bug rate increases (unclear ownership)
- Onboarding takes months (overwhelming complexity)
- Tech debt compounds (nobody feels responsible)
- Senior engineers quit (too much chaos)
In a previous role, we scaled from 4 to 12 frontend engineers in a year. Shipping velocity dropped 40% before we restructured. The restructure took 2 sprints but 10x'd our velocity.
Where Codebases Break Under Team Scale
1. Monolithic Component Directory
// ❌ Doesn't scale beyond 3 people
src/
├── components/ (87 files)
│ ├── Button.tsx
│ ├── Header.tsx
│ ├── CheckoutForm.tsx
│ ├── UserProfile.tsx
│ └── ...
├── hooks/ (31 files)
├── utils/ (24 files)
└── contexts/ (12 files)
What breaks:
- Every PR touches same directories
- Merge conflicts daily
- No ownership boundaries
- Hard to understand dependencies
- New engineers overwhelmed
2. Shared Global State
// ❌ Single global store
const globalStore = {
user: userSlice,
cart: cartSlice,
products: productsSlice,
checkout: checkoutSlice,
notifications: notificationsSlice,
search: searchSlice,
// 30 more slices...
}
What breaks:
- Everyone needs to understand entire state shape
- Changes ripple across unrelated features
- Testing requires mocking entire store
- Circular dependencies emerge
- Refactoring becomes risky
3. Implicit Dependencies
// ❌ Hidden coupling through utilities
// utils/api.ts
export const api = {
user: { /* ... */ },
products: { /* ... */ },
orders: { /* ... */ },
}
// Now every feature imports this
// Changes to api shape break everything
What breaks:
- Can't change API without coordinating with everyone
- No clear ownership
- Testing requires extensive mocking
- Hard to version or deprecate
Architectural Patterns That Scale
1. Domain-Driven Directory Structure
// ✅ Clear ownership boundaries
src/
├── domains/
│ ├── checkout/
│ │ ├── components/
│ │ │ ├── CheckoutForm.tsx
│ │ │ ├── PaymentMethod.tsx
│ │ │ └── OrderSummary.tsx
│ │ ├── hooks/
│ │ │ ├── useCheckout.ts
│ │ │ └── usePayment.ts
│ │ ├── api/
│ │ │ └── checkoutApi.ts
│ │ ├── state/
│ │ │ └── checkoutStore.ts
│ │ ├── types/
│ │ │ └── checkout.types.ts
│ │ └── index.ts (public API)
│ │
│ ├── products/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── api/
│ │ └── index.ts
│ │
│ └── user/
│ ├── components/
│ ├── hooks/
│ └── index.ts
│
├── shared/
│ ├── components/ (truly shared UI)
│ │ ├── Button/
│ │ ├── Input/
│ │ └── Modal/
│ └── utils/ (generic utilities)
│
└── app/
├── routes/
└── layouts/
Benefits:
- Clear ownership: checkout team owns
domains/checkout/ - Reduced conflicts: teams work in different directories
- Explicit boundaries: must import through
index.ts - Easy onboarding: new engineers start in one domain
- Safe refactoring: changes contained within domain
Rule: Never import directly from domain internals. Only through public API.
// ❌ Don't do this
import { CheckoutForm } from '@/domains/checkout/components/CheckoutForm'
// ✅ Do this
import { CheckoutForm } from '@/domains/checkout'
2. Domain-Scoped State
// ✅ Each domain manages own state
// domains/checkout/state/checkoutStore.ts
export const useCheckoutStore = create<CheckoutState>((set) => ({
items: [],
subtotal: 0,
addItem: (item) => set((state) => ({
items: [...state.items, item],
subtotal: state.subtotal + item.price,
})),
}))
// domains/user/state/userStore.ts
export const useUserStore = create<UserState>((set) => ({
user: null,
login: async (credentials) => { /* ... */ },
}))
// No shared global store
// Domains are independent
Benefits:
- Teams work in parallel
- No cross-domain coordination for state changes
- Testing is isolated
- Can version/migrate independently
- Clearer boundaries
Cross-domain communication:
// ✅ Explicit cross-domain dependencies
// domains/checkout/hooks/useCheckoutWithUser.ts
import { useUserStore } from '@/domains/user'
import { useCheckoutStore } from '../state/checkoutStore'
export function useCheckoutWithUser() {
const user = useUserStore((s) => s.user)
const checkout = useCheckoutStore()
// Composed logic here
}
3. Layered Architecture
┌─────────────────────────────────────────┐
│ App Layer (Routes) │
│ Composes domains into features │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Domain Layer (Features) │
│ checkout, products, user, orders │
│ Self-contained business logic │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Shared Layer (UI & Utilities) │
│ Design system, common utilities │
│ No business logic here │
└─────────────────────────────────────────┘
Rules:
- App layer can import from domains
- Domains can import from shared
- Domains cannot import from other domains (use composition)
- Shared cannot import from domains
Enforce with linting:
// .eslintrc.js
module.exports = {
rules: {
'no-restricted-imports': ['error', {
patterns: [
// Domains can't import from each other
{
group: ['**/domains/*/'],
message: 'Domains should not directly import from other domains. Use composition at app layer.',
},
// Shared can't import from domains
{
group: ['**/shared/**'],
importNamePattern: '.*domains.*',
message: 'Shared code cannot depend on domain code.',
},
],
}],
},
}
4. Clear Ownership Model
CODEOWNERS file:
# Checkout domain
/src/domains/checkout/** @checkout-team
/src/app/routes/checkout/** @checkout-team
# Products domain
/src/domains/products/** @products-team
# Shared components (requires 2 approvals)
/src/shared/components/** @frontend-leads
Benefits:
- Auto-assign reviewers
- Clear responsibility
- Prevents accidental changes
- Enables autonomy
- Tracks ownership over time
5. Public API Contracts
Each domain exports explicit public API:
// ✅ domains/checkout/index.ts
// Public API for checkout domain
export { CheckoutForm } from './components/CheckoutForm'
export { useCheckout } from './hooks/useCheckout'
export type { CheckoutState, CheckoutItem } from './types/checkout.types'
// Internal components not exported
// Other teams can't accidentally depend on internals
Benefits:
- Clear interface contract
- Can refactor internals safely
- Prevents tight coupling
- Documents what's public vs private
- Enables versioning
Team Workflow That Scales
1. Feature-Based Branching
main
├── feature/checkout-redesign (checkout team)
├── feature/products-search (products team)
└── feature/user-settings (user team)
Teams work independently. Merge conflicts rare because directory structure isolates changes.
2. Incremental Review Process
Don't review 2000-line PRs. Use stacked diffs:
PR #1: Add checkout types (50 lines)
↓ (merge, then)
PR #2: Add checkout API layer (100 lines)
↓ (merge, then)
PR #3: Add checkout components (300 lines)
↓ (merge, then)
PR #4: Integrate checkout flow (150 lines)
Each PR is reviewable in 10 minutes. Faster feedback, faster merging.
3. Domain Documentation
Each domain has README:
# Checkout Domain
## Ownership
- Team: @checkout-team
- Slack: #checkout-dev
## Purpose
Handles shopping cart, checkout flow, payment processing
## Public API
- `<CheckoutForm />` - Main checkout UI
- `useCheckout()` - Checkout state management
- `CheckoutState` - TypeScript types
## Dependencies
- User domain (for authentication)
- Products domain (for product data)
## Architecture Decision Records
- Why we use pessimistic UI for payments
- Why we chose Stripe over alternatives
Benefits:
- New engineers onboard faster
- Decisions are documented
- Cross-team coordination easier
- Prevents tribal knowledge
Tradeoffs
Domain structure:
- ✅ Scales to many engineers
- ❌ More directories to navigate
- ❌ Overhead for small features
Domain-scoped state:
- ✅ Independent teams
- ❌ Cross-domain queries are harder
- ❌ Need composition layer
Public APIs:
- ✅ Clear contracts
- ❌ More files to maintain
- ❌ Indirection can confuse
Layered architecture:
- ✅ Clear separation
- ❌ Sometimes needs exceptions
- ❌ Requires discipline
What I'd Do Differently Today
-
Start with domains from day one. Easier than refactoring later.
-
Document ownership immediately. Use CODEOWNERS from the start.
-
Enforce boundaries with linting. Rules prevent accidents.
-
Review architecture quarterly. Domains need to evolve with product.
-
Invest in tooling. Scripts for creating new domains, generating boilerplate, etc.
Most teams structure code for the team they have, not the team they'll have. By the time you feel the pain, refactoring is expensive.
Design your architecture for the team you'll be in 12 months. Your future self will thank you.
The best code structure is the one that lets engineers ship features without coordinating with the entire team.
That's not a technical problem. It's an architecture problem.