Architecture Principles and Trade-offs
Why Architecture Matters More Than You Think
You can build a startup MVP with spaghetti code and ship faster than competitors. But here is what happens at scale: a 50-person team, 200+ components, 40 feature flags, and three backend services. Every "quick fix" creates a ripple effect across the codebase. Engineers spend more time understanding code than writing it. Deployments break unrelated features. Sound familiar?
Architecture is not about perfection. It is about making the cost of change predictable.
Think of architecture like city planning. You can build a house anywhere, but without roads, zoning, and utility lines, the city becomes gridlocked as it grows. Architecture is the roads and zoning of your codebase. You are not designing every building -- you are creating the constraints that make every building work together. Bad architecture feels like a city where the hospital is next to the nightclub and the fire station has no road access.
Separation of Concerns
This is the most fundamental principle. Every module, component, and function should have one reason to change.
In frontend terms, separation of concerns means splitting:
- Data fetching from data display
- Business logic from UI rendering
- State management from component structure
- Styling from behavior
- Routing from page content
function UserProfile({ userId }: { userId: string }) {
const user = use(fetchUser(userId));
return (
<ProfileLayout>
<Avatar src={user.avatar} name={user.name} />
<UserStats completedCourses={user.stats.completed} streak={user.stats.streak} />
<RecentActivity activities={user.recentActivity} />
</ProfileLayout>
);
}
Notice what this component does NOT do: it does not fetch data with useEffect, does not manage loading states inline, does not contain CSS-in-JS styling logic, and does not handle routing. Each concern lives elsewhere.
The anti-pattern is the "god component" -- 500 lines, fetches its own data, manages 8 state variables, contains inline styles, and handles three different user flows. You have seen it. You have probably written it. We all have.
Dependency Direction: Always Inward
This is the principle that separates senior architects from everyone else. In a well-architected frontend:
UI depends on domain logic. Domain logic never depends on UI.
┌─────────────────────────────────────┐
│ UI / Components │ ← Depends on everything below
├─────────────────────────────────────┤
│ Application Services │ ← Orchestrates domain + infra
├─────────────────────────────────────┤
│ Domain / Business │ ← Pure logic, zero dependencies
├─────────────────────────────────────┤
│ Infrastructure / APIs │ ← External world adapters
└─────────────────────────────────────┘
Dependencies point inward (downward in this diagram). The domain layer knows nothing about React, nothing about your API client, nothing about your state management library.
function calculateCourseProgress(
completedTopics: number,
totalTopics: number,
quizScores: number[]
): CourseProgress {
const topicProgress = totalTopics > 0 ? completedTopics / totalTopics : 0;
const avgQuizScore = quizScores.length > 0
? quizScores.reduce((sum, s) => sum + s, 0) / quizScores.length
: 0;
return {
percentage: Math.round(topicProgress * 100),
status: topicProgress === 1 ? "completed" : topicProgress > 0 ? "in-progress" : "not-started",
averageQuizScore: Math.round(avgQuizScore),
};
}
This function is pure domain logic. It does not import React. It does not call fetch. It does not read from localStorage. It can be tested with a simple function call, reused in a server component or a CLI tool, and it will never break because you swapped from REST to GraphQL.
A common violation: importing a React hook inside a utility function. If your calculatePrice function imports useContext to get the currency, you have inverted the dependency. The domain function now depends on the UI framework. Instead, pass the currency as a parameter.
SOLID Principles for Frontend
SOLID was designed for object-oriented backends, but the principles translate directly to component-based frontends.
Single Responsibility
One component, one reason to change. A CourseCard renders a course card. It does not fetch course data, manage favorites state, or handle analytics tracking.
Open-Closed
Components should be open for extension, closed for modification. Use composition and props instead of if/else branches for every new variant.
function Card({ children, className }: CardProps) {
return <div className={cn("rounded-xl border p-4", className)}>{children}</div>;
}
function CourseCard({ course }: { course: Course }) {
return (
<Card className="hover:border-accent transition-colors">
<CourseCardContent course={course} />
</Card>
);
}
function FeaturedCourseCard({ course }: { course: Course }) {
return (
<Card className="border-accent bg-accent/5">
<FeaturedBadge />
<CourseCardContent course={course} />
</Card>
);
}
No if (featured) inside Card. Instead, compose different cards from the same base.
Liskov Substitution
Any component that accepts ButtonProps should work wherever a Button is expected. If your IconButton silently ignores children, it violates Liskov substitution.
Interface Segregation
Do not force components to accept props they do not use. A UserAvatar should not require the entire User object just to display a picture.
function UserAvatar({ src, name, size }: { src: string; name: string; size: "sm" | "md" | "lg" }) {
return <img src={src} alt={name} className={avatarSizes[size]} />;
}
Dependency Inversion
High-level modules should not depend on low-level modules. Both should depend on abstractions. In React, this means components depend on interfaces (props types), not concrete implementations.
- 1Separation of concerns: one reason to change per module
- 2Dependency direction: always inward -- UI depends on domain, never reverse
- 3Open-closed: extend through composition, not modification
- 4Interface segregation: components only accept props they actually use
- 5Dependency inversion: depend on abstractions (prop types), not implementations
YAGNI vs. Extensibility
YAGNI (You Aren't Gonna Need It) says: do not build for hypothetical futures. Extensibility says: design so future changes are cheap. These seem contradictory, but they work together.
The rule: make the simple thing easy and the complex thing possible.
| Scenario | YAGNI Says | Extensibility Says | Right Call |
|---|---|---|---|
| Single auth provider today | Hardcode the auth calls | Abstract behind an interface | Abstract -- auth providers change constantly |
| One chart type needed | Build just the bar chart | Build a generic chart framework | YAGNI -- build bar chart, extract if needed |
| API returns flat data, UI needs nested | Transform inline in component | Build a generic data transformer | YAGNI -- inline transform, extract on second use |
| Three very similar list pages | Copy-paste with tweaks | Build a generic list page builder | Extract shared parts -- three is the threshold |
| Might need i18n someday | Hardcode English strings | Wrap all strings in t() from day one | Abstract -- retrofitting i18n is extremely painful |
The threshold is usually three. One use case? Inline it. Two? Notice the pattern. Three? Extract the abstraction.
The Architecture Decision Spectrum
Architecture is not binary. It is a spectrum of trade-offs.
The default answer is always the simplest architecture that solves your actual problems. Most teams over-architect. A modular monolith handles 95% of real-world scenarios.
Conway's Law in Frontend
"Organizations which design systems are constrained to produce designs which are copies of the communication structures of these organizations." -- Melvin Conway, 1967
This is not just an observation -- it is a law of nature in software. Your frontend architecture will mirror your team structure whether you plan for it or not.
Conway's Law in Practice
Team A owns authentication. Team B owns the dashboard. Team C owns the course player. Each team has their own sprint cadence, their own deployment pipeline, and their own technical preferences.
If you force a monolith SPA, here is what happens:
- Teams step on each other's code daily
- Merge conflicts become a full-time job
- One team's broken build blocks everyone
- Shared components become nobody's responsibility
If you align architecture to team structure:
- Team A owns
packages/author a micro-frontend - Team B owns
packages/dashboard - Team C owns
packages/course-player - Shared UI lives in
packages/design-systemwith its own team
The architecture reflects the communication structure, and both work smoothly.
The Inverse Conway Maneuver: design your team structure first, then let the architecture follow. If you want a modular monolith, organize cross-functional teams around features. If you want micro-frontends, organize teams around user journeys.
Information Hiding
Modules should expose a minimal public API and hide everything else. In frontend, this means:
// features/course-progress/index.ts -- the public API
export { CourseProgressBar } from "./components/progress-bar";
export { useCourseProgress } from "./hooks/use-course-progress";
export type { CourseProgress } from "./types";
// Everything else is internal -- other features cannot import it
// features/course-progress/utils/calculate-weighted-score.ts ← internal
// features/course-progress/components/progress-ring.tsx ← internal
The barrel export (index.ts) acts as a contract. Other features only import from the barrel. Internal files can be refactored freely without breaking consumers.
In some bundler configurations, barrel exports can prevent tree-shaking and bloat bundle size. Next.js handles this with optimizePackageImports in the config. For internal feature barrels, the cost is negligible -- but for shared library packages, measure the impact.
| What developers do | What they should do |
|---|---|
| Every component exported from every file, no barrel exports Without a public API boundary, any file can import any internal module. Refactoring becomes impossible because you never know what depends on what. | Feature folders with barrel exports exposing only the public API |
| Domain logic imports React hooks or browser APIs Dependency inversion. Domain logic that depends on React cannot run on the server, in tests, or in a different framework. | Domain logic is pure functions that accept data as parameters |
| Choosing micro-frontends because it sounds modern Micro-frontends add massive operational overhead. For teams under 30 engineers, the complexity almost never pays off. | Starting with a modular monolith and migrating when team size demands it |