Learning Guides
Menu

Architecture Patterns

13 min readFrontend Patterns & Concepts

Architecture Patterns

As applications grow, good architecture becomes essential. These patterns help you organize code in ways that scale with your team and complexity.

Project Structure Patterns

Feature-Based Structure

Organize by feature, not by file type.

PLAINTEXT
src/
├── features/
│   ├── auth/
│   │   ├── components/
│   │   │   ├── LoginForm.tsx
│   │   │   ├── SignupForm.tsx
│   │   │   └── AuthProvider.tsx
│   │   ├── hooks/
│   │   │   ├── useAuth.ts
│   │   │   └── useSession.ts
│   │   ├── api/
│   │   │   └── authApi.ts
│   │   ├── types.ts
│   │   └── index.ts
│   │
│   ├── products/
│   │   ├── components/
│   │   ├── hooks/
│   │   ├── api/
│   │   └── index.ts
│   │
│   └── cart/
│       ├── components/
│       ├── hooks/
│       ├── store/
│       └── index.ts

├── shared/
│   ├── components/    # Reusable UI components
│   ├── hooks/         # Shared hooks
│   ├── utils/         # Utility functions
│   └── types/         # Shared types

├── app/               # Next.js App Router routes
└── lib/               # Third-party configurations

Barrel Exports

JAVASCRIPT
// features/auth/index.ts
export { LoginForm } from './components/LoginForm';
export { SignupForm } from './components/SignupForm';
export { AuthProvider } from './components/AuthProvider';
export { useAuth } from './hooks/useAuth';
export { useSession } from './hooks/useSession';
export type { User, Session, AuthState } from './types';
 
// Usage - clean imports
import { useAuth, LoginForm } from '@/features/auth';

Layered Architecture

Separate concerns into distinct layers.

PLAINTEXT
┌─────────────────────────────────────────┐
│         Presentation Layer              │
│   (Components, Pages, UI)               │
├─────────────────────────────────────────┤
│         Application Layer               │
│   (Hooks, State, Business Logic)        │
├─────────────────────────────────────────┤
│         Domain Layer                    │
│   (Entities, Value Objects, Rules)      │
├─────────────────────────────────────────┤
│         Infrastructure Layer            │
│   (API, Storage, External Services)     │
└─────────────────────────────────────────┘
JAVASCRIPT
// domain/entities/Order.ts
export class Order {
  constructor(
    public id: string,
    public items: OrderItem[],
    public status: OrderStatus,
    public createdAt: Date
  ) {}
 
  get total(): number {
    return this.items.reduce((sum, item) =>
      sum + item.price * item.quantity, 0
    );
  }
 
  canCancel(): boolean {
    return this.status === 'pending' || this.status === 'processing';
  }
 
  addItem(item: OrderItem): void {
    if (this.status !== 'pending') {
      throw new Error('Cannot modify non-pending order');
    }
    this.items.push(item);
  }
}
 
// infrastructure/api/orderApi.ts
export const orderApi = {
  async getOrder(id: string): Promise<Order> {
    const response = await fetch(`/api/orders/${id}`);
    const data = await response.json();
    return new Order(data.id, data.items, data.status, new Date(data.createdAt));
  },
 
  async createOrder(items: OrderItem[]): Promise<Order> {
    const response = await fetch('/api/orders', {
      method: 'POST',
      body: JSON.stringify({ items })
    });
    const data = await response.json();
    return new Order(data.id, data.items, data.status, new Date(data.createdAt));
  }
};
 
// application/hooks/useOrder.ts
export function useOrder(orderId: string) {
  const [order, setOrder] = useState<Order | null>(null);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    orderApi.getOrder(orderId)
      .then(setOrder)
      .finally(() => setLoading(false));
  }, [orderId]);
 
  const cancelOrder = useCallback(async () => {
    if (!order?.canCancel()) {
      throw new Error('Order cannot be cancelled');
    }
    await orderApi.cancelOrder(orderId);
    // Refresh order
  }, [order, orderId]);
 
  return { order, loading, cancelOrder };
}
 
// presentation/components/OrderDetails.tsx
export function OrderDetails({ orderId }: { orderId: string }) {
  const { order, loading, cancelOrder } = useOrder(orderId);
 
  if (loading) return <Skeleton />;
  if (!order) return <NotFound />;
 
  return (
    <div>
      <h1>Order #{order.id}</h1>
      <p>Total: ${order.total}</p>
      <p>Status: {order.status}</p>
 
      {order.canCancel() && (
        <button onClick={cancelOrder}>Cancel Order</button>
      )}
    </div>
  );
}

Module Boundaries

As codebases grow, developers start importing from anywhere. Component A imports a utility from Feature B, which imports a hook from Feature C. Soon, everything depends on everything—changing one thing breaks ten others.

Module boundaries solve this by defining clear public APIs for each module. Think of each feature as a library with an index.ts that exports only what other modules should use.

Benefits:

  • Easier refactoring (internals can change without affecting others)
  • Clear ownership (who's responsible for what)
  • Faster builds (changes to internals don't require recompiling dependents)
  • Better code review (changes to public API get more scrutiny)

The pattern:

  • Each module has a single entry point (index.ts)
  • Only explicitly exported items are public API
  • Everything else is internal implementation
  • Enforce with ESLint rules
JAVASCRIPT
// features/products/index.ts - PUBLIC API
// Only export what other modules should use
export { ProductCard } from './components/ProductCard';
export { ProductList } from './components/ProductList';
export { useProducts } from './hooks/useProducts';
export { useProduct } from './hooks/useProduct';
export type { Product, ProductCategory } from './types';
 
// Internal implementation details stay private
// features/products/utils/transformers.ts - NOT EXPORTED
// features/products/components/ProductImage.tsx - NOT EXPORTED
 
// Enforce with ESLint
// .eslintrc.js
module.exports = {
  rules: {
    'no-restricted-imports': [
      'error',
      {
        patterns: [
          {
            group: ['@/features/*/components/*', '!@/features/*/index'],
            message: 'Import from feature index file instead'
          }
        ]
      }
    ]
  }
};

Dependency Injection

Dependency Injection (DI) means passing dependencies to a function/class rather than creating them inside. This simple change has profound implications for testability and flexibility.

The problem without DI:

  • Functions create their own dependencies internally
  • You can't swap implementations (e.g., for testing)
  • Changing a dependency requires modifying the consumer
  • Testing requires mocking modules (fragile)

The solution with DI:

  • Dependencies are passed in (injected)
  • You can inject mocks for testing
  • You can swap implementations without changing consumers
  • Dependencies are explicit and documented

In frontend code:

  • Pass API clients to services
  • Pass services to hooks/components via Context
  • Pass configuration as parameters
JAVASCRIPT
// Without DI - hard to test, tightly coupled
function UserService() {
  async function getUser(id) {
    const response = await fetch(`/api/users/${id}`); // Hardcoded
    return response.json();
  }
  return { getUser };
}
 
// With DI - testable, flexible
function createUserService(httpClient) {
  async function getUser(id) {
    return httpClient.get(`/users/${id}`);
  }
  return { getUser };
}
 
// Production
const httpClient = createHttpClient({ baseUrl: "/api" });
const userService = createUserService(httpClient);
 
// Testing
const mockClient = { get: jest.fn().mockResolvedValue({ id: 1 }) };
const userService = createUserService(mockClient);

React Context for DI

React Context is a natural fit for dependency injection—it provides a way to pass dependencies down the component tree without prop drilling.

The pattern:

  1. Create services at the app root
  2. Provide them via Context
  3. Components consume services via custom hook
  4. Swap services in tests by providing different Context value
JAVASCRIPT
// services/ServiceProvider.tsx
const ServiceContext = createContext(null);
 
export function ServiceProvider({ children, services }) {
  return (
    <ServiceContext.Provider value={services}>
      {children}
    </ServiceContext.Provider>
  );
}
 
export function useServices() {
  const context = useContext(ServiceContext);
  if (!context) {
    throw new Error("useServices must be used within ServiceProvider");
  }
  return context;
}
 
// services/createServices.ts
export function createServices(config) {
  const httpClient = createHttpClient(config.apiUrl);
  const authService = createAuthService(httpClient);
  const userService = createUserService(httpClient);
  const productService = createProductService(httpClient);
 
  return {
    auth: authService,
    users: userService,
    products: productService,
  };
}
 
// App.tsx
function App() {
  const services = useMemo(
    () =>
      createServices({
        apiUrl: process.env.NEXT_PUBLIC_API_URL,
      }),
    [],
  );
 
  return (
    <ServiceProvider services={services}>
      <AppContent />
    </ServiceProvider>
  );
}
 
// Usage in components
function UserProfile({ userId }) {
  const { users } = useServices();
  const [user, setUser] = useState(null);
 
  useEffect(() => {
    users.getUser(userId).then(setUser);
  }, [userId, users]);
 
  return <Profile user={user} />;
}

Repository Pattern

The Repository Pattern abstracts data access behind a consistent interface. Instead of components calling fetch('/api/users') directly, they call userRepository.findAll().

Why this matters:

  • Swappable backends: Change from REST to GraphQL without touching components
  • Testable: Inject mock repositories for testing
  • Centralized logic: Caching, error handling, data transformation in one place
  • Type safety: Repository interface defines exactly what's possible

When to use:

  • Applications with complex data access patterns
  • When you might change data sources
  • When you want consistent data access across the app
  • When testing data-dependent components
JAVASCRIPT
// repositories/types.ts
interface Repository<T> {
  findById(id: string): Promise<T | null>;
  findAll(query?: Query): Promise<T[]>;
  create(data: Partial<T>): Promise<T>;
  update(id: string, data: Partial<T>): Promise<T>;
  delete(id: string): Promise<void>;
}
 
// repositories/userRepository.ts
export function createUserRepository(httpClient): Repository<User> {
  return {
    async findById(id) {
      try {
        return await httpClient.get(`/users/${id}`);
      } catch (error) {
        if (error.status === 404) return null;
        throw error;
      }
    },
 
    async findAll(query = {}) {
      const params = new URLSearchParams(query);
      return httpClient.get(`/users?${params}`);
    },
 
    async create(data) {
      return httpClient.post('/users', data);
    },
 
    async update(id, data) {
      return httpClient.patch(`/users/${id}`, data);
    },
 
    async delete(id) {
      await httpClient.delete(`/users/${id}`);
    }
  };
}
 
// Can swap implementations easily
export function createMockUserRepository(): Repository<User> {
  const users = new Map<string, User>();
 
  return {
    async findById(id) {
      return users.get(id) || null;
    },
    // ... mock implementations
  };
}

Command Query Separation (CQS)

CQS is a simple principle: methods should either read data (Query) or change data (Command), never both.

  • Queries: Return data, have no side effects, can be called multiple times safely
  • Commands: Perform actions, may have side effects, change system state

Why this helps:

  • Queries can be cached aggressively (they never change data)
  • Commands clearly indicate something will change
  • Easier to reason about what code does
  • Works naturally with React Query's useQuery / useMutation

In practice with React Query:

  • useQuery for queries (GET requests, reading data)
  • useMutation for commands (POST/PUT/DELETE, changing data)
  • Commands invalidate relevant queries after success
JAVASCRIPT
// queries - return data, no side effects
export const queries = {
  useUser(id: string) {
    return useQuery(['user', id], () => userApi.getUser(id));
  },
 
  useUsers(filters: UserFilters) {
    return useQuery(['users', filters], () => userApi.getUsers(filters));
  },
 
  useUserOrders(userId: string) {
    return useQuery(['user-orders', userId], () => orderApi.getByUser(userId));
  }
};
 
// commands - perform actions, may have side effects
export const commands = {
  useCreateUser() {
    const queryClient = useQueryClient();
 
    return useMutation(
      (data: CreateUserData) => userApi.create(data),
      {
        onSuccess: () => {
          queryClient.invalidateQueries(['users']);
        }
      }
    );
  },
 
  useUpdateUser() {
    const queryClient = useQueryClient();
 
    return useMutation(
      ({ id, data }: { id: string; data: UpdateUserData }) =>
        userApi.update(id, data),
      {
        onSuccess: (_, { id }) => {
          queryClient.invalidateQueries(['user', id]);
          queryClient.invalidateQueries(['users']);
        }
      }
    );
  },
 
  useDeleteUser() {
    const queryClient = useQueryClient();
 
    return useMutation(
      (id: string) => userApi.delete(id),
      {
        onSuccess: () => {
          queryClient.invalidateQueries(['users']);
        }
      }
    );
  }
};
 
// Usage
function UserManager() {
  const { data: users } = queries.useUsers({ active: true });
  const createUser = commands.useCreateUser();
  const deleteUser = commands.useDeleteUser();
 
  return (
    <div>
      <UserForm onSubmit={createUser.mutate} />
      <UserList
        users={users}
        onDelete={(id) => deleteUser.mutate(id)}
      />
    </div>
  );
}

Event-Driven Architecture

In tightly coupled systems, components call each other directly. When User logs out, the AuthService must know about CartService, AnalyticsService, WebSocketService—and call each one.

Event-driven architecture inverts this relationship. AuthService just announces "user logged out." Any interested service listens and responds. AuthService doesn't know or care who's listening.

Benefits:

  • Loose coupling: Publisher doesn't know about subscribers
  • Extensibility: Add new behaviors without modifying existing code
  • Testability: Test components in isolation
  • Flexibility: Enable/disable features by adding/removing listeners

When to use:

  • Cross-cutting concerns (analytics, logging, error tracking)
  • Actions that trigger multiple independent effects
  • When you want to avoid circular dependencies
  • For plugin-like architectures
JAVASCRIPT
// events/eventBus.ts
type EventHandler<T = any> = (payload: T) => void;
 
class EventBus {
  private handlers = new Map<string, Set<EventHandler>>();
 
  on<T>(event: string, handler: EventHandler<T>): () => void {
    if (!this.handlers.has(event)) {
      this.handlers.set(event, new Set());
    }
    this.handlers.get(event)!.add(handler);
 
    // Return unsubscribe function
    return () => this.handlers.get(event)?.delete(handler);
  }
 
  emit<T>(event: string, payload?: T): void {
    this.handlers.get(event)?.forEach(handler => {
      try {
        handler(payload);
      } catch (error) {
        console.error(`Error in event handler for ${event}:`, error);
      }
    });
  }
 
  once<T>(event: string, handler: EventHandler<T>): () => void {
    const unsubscribe = this.on(event, (payload: T) => {
      unsubscribe();
      handler(payload);
    });
    return unsubscribe;
  }
}
 
export const eventBus = new EventBus();
 
// Define event types
export const AppEvents = {
  USER_LOGGED_IN: 'user:loggedIn',
  USER_LOGGED_OUT: 'user:loggedOut',
  CART_UPDATED: 'cart:updated',
  ORDER_PLACED: 'order:placed',
  NOTIFICATION: 'ui:notification'
} as const;
 
// Usage - producer
function AuthService() {
  async function login(credentials) {
    const user = await api.login(credentials);
    eventBus.emit(AppEvents.USER_LOGGED_IN, { user });
    return user;
  }
 
  async function logout() {
    await api.logout();
    eventBus.emit(AppEvents.USER_LOGGED_OUT);
  }
}
 
// Usage - consumer
function AnalyticsTracker() {
  useEffect(() => {
    const unsub1 = eventBus.on(AppEvents.USER_LOGGED_IN, ({ user }) => {
      analytics.identify(user.id);
    });
 
    const unsub2 = eventBus.on(AppEvents.ORDER_PLACED, ({ order }) => {
      analytics.track('purchase', { orderId: order.id, total: order.total });
    });
 
    return () => {
      unsub1();
      unsub2();
    };
  }, []);
 
  return null;
}

Micro-Frontends

As frontend applications grow, they face the same scaling challenges as backend monoliths:

  • Teams step on each other's toes
  • Deployments require coordinating many people
  • Changes in one area break other areas
  • Tech stack upgrades are all-or-nothing

Micro-frontends apply microservice principles to the frontend: split the UI into independently developed, deployed, and maintained pieces.

When micro-frontends make sense:

  • Very large teams (multiple squads working on same product)
  • Different parts of the app need different release cadences
  • You want to migrate incrementally (old tech → new tech)
  • Teams need true autonomy

When to avoid:

  • Small teams (overhead isn't worth it)
  • Consistent UX is critical (harder to maintain across microfrontends)
  • Simple applications

Implementation approaches:

  • Module Federation (Webpack 5) - Load remote modules at runtime
  • iframes - Strongest isolation, worst UX
  • Web Components - Framework-agnostic components
  • Build-time integration - Combine at CI/CD time
JAVASCRIPT
// Module Federation (Webpack 5)
// host/webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "host",
      remotes: {
        products: "products@http://localhost:3001/remoteEntry.js",
        cart: "cart@http://localhost:3002/remoteEntry.js",
        checkout: "checkout@http://localhost:3003/remoteEntry.js",
      },
      shared: ["react", "react-dom"],
    }),
  ],
};
 
// products/webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "products",
      filename: "remoteEntry.js",
      exposes: {
        "./ProductList": "./src/ProductList",
        "./ProductDetail": "./src/ProductDetail",
      },
      shared: ["react", "react-dom"],
    }),
  ],
};
 
// host/App.tsx
const ProductList = React.lazy(() => import("products/ProductList"));
const Cart = React.lazy(() => import("cart/Cart"));
 
function App() {
  return (
    <Suspense fallback={<Loading />}>
      <Routes>
        <Route path="/products" element={<ProductList />} />
        <Route path="/cart" element={<Cart />} />
      </Routes>
    </Suspense>
  );
}

Configuration Management

Configuration scattered across the codebase leads to inconsistency and hard-to-find bugs. Centralized configuration provides:

  • Single source of truth for all config values
  • Type safety (TypeScript catches typos)
  • Validation (fail fast if required config is missing)
  • Documentation (all config in one place)

What belongs in configuration:

  • API URLs and endpoints
  • Feature flags
  • Third-party service keys (public ones)
  • Timeouts and retry settings
  • Environment-specific values

Best practices:

  • Validate config at startup (fail fast)
  • Use as const for type inference
  • Provide sensible defaults
  • Group related config together
  • Document required vs optional values
JAVASCRIPT
// config/index.ts
const config = {
  api: {
    baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api',
    timeout: parseInt(process.env.NEXT_PUBLIC_API_TIMEOUT || '30000'),
  },
  features: {
    darkMode: process.env.NEXT_PUBLIC_FEATURE_DARK_MODE === 'true',
    analytics: process.env.NEXT_PUBLIC_FEATURE_ANALYTICS === 'true',
    beta: process.env.NEXT_PUBLIC_FEATURE_BETA === 'true',
  },
  sentry: {
    dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
    environment: process.env.NODE_ENV,
  }
} as const;
 
// Validate required config
function validateConfig() {
  const required = ['api.baseUrl'];
 
  for (const path of required) {
    const value = path.split('.').reduce((obj, key) => obj?.[key], config);
    if (value === undefined) {
      throw new Error(`Missing required config: ${path}`);
    }
  }
}
 
validateConfig();
 
export { config };
 
// Usage
import { config } from '@/config';
 
fetch(`${config.api.baseUrl}/users`);
 
if (config.features.analytics) {
  initAnalytics();
}

Summary

PatternPurposeWhen to Use
Feature-based structureOrganize by domainMedium to large apps
Layered architectureSeparate concernsComplex business logic
Module boundariesEncapsulationTeam scaling
Dependency injectionTestability, flexibilityAlways
Repository patternAbstract data accessMultiple data sources
CQSSeparate reads/writesComplex state
Event-drivenLoose couplingCross-cutting concerns
Micro-frontendsIndependent deploymentVery large apps

Key Principles:

  1. Start simple, add architecture as complexity grows
  2. Make dependencies explicit
  3. Define clear module boundaries
  4. Separate what changes for different reasons
  5. Optimize for deletion—make code easy to remove