Skip to content

Architecture Principles and Trade-offs

expert18 min read

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.

Mental Model

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.

Quiz
A React component fetches data, transforms it, renders a table, and handles row selection. How many concerns does it mix?

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.

Common Trap

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.

Quiz
Your utility function imports useAuth() to check user permissions before calculating prices. What principle does this violate?

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.

Key Rules
  1. 1Separation of concerns: one reason to change per module
  2. 2Dependency direction: always inward -- UI depends on domain, never reverse
  3. 3Open-closed: extend through composition, not modification
  4. 4Interface segregation: components only accept props they actually use
  5. 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.

ScenarioYAGNI SaysExtensibility SaysRight Call
Single auth provider todayHardcode the auth callsAbstract behind an interfaceAbstract -- auth providers change constantly
One chart type neededBuild just the bar chartBuild a generic chart frameworkYAGNI -- build bar chart, extract if needed
API returns flat data, UI needs nestedTransform inline in componentBuild a generic data transformerYAGNI -- inline transform, extract on second use
Three very similar list pagesCopy-paste with tweaksBuild a generic list page builderExtract shared parts -- three is the threshold
Might need i18n somedayHardcode English stringsWrap all strings in t() from day oneAbstract -- retrofitting i18n is extremely painful

The threshold is usually three. One use case? Inline it. Two? Notice the pattern. Three? Extract the abstraction.

Quiz
Your app has one payment provider (Stripe). A teammate proposes abstracting it behind a PaymentProvider interface for future flexibility. What is the right call?

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.

Quiz
Your company has 8 frontend engineers working on one product. A new VP of Engineering suggests adopting micro-frontends for scalability. What is the best response?

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/auth or a micro-frontend
  • Team B owns packages/dashboard
  • Team C owns packages/course-player
  • Shared UI lives in packages/design-system with 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.

Barrel exports have a bundling cost

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 doWhat 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
1/11