<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Abisoye Alli-Balogun - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Abisoye Alli-Balogun - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Wed, 20 May 2026 06:34:30 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/AbisoyeAlli/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Navigate Microservices as a Frontend Engineer ]]>
                </title>
                <description>
                    <![CDATA[ Most frontend engineers don't choose microservices. They inherit them. One day you're fetching data from a single API, and the next you're stitching together responses from five services, each with it ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-navigate-microservices-as-a-frontend-engineer/</link>
                <guid isPermaLink="false">69f8de9b46610fd6060f5251</guid>
                
                    <category>
                        <![CDATA[ Microservices ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Frontend Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ architecture ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Abisoye Alli-Balogun ]]>
                </dc:creator>
                <pubDate>Mon, 04 May 2026 17:59:55 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/6a10811b-1150-490a-8f29-28797fd39861.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most frontend engineers don't choose microservices. They inherit them. One day you're fetching data from a single API, and the next you're stitching together responses from five services, each with its own contract, its own failure modes, and its own idea of what a "user" looks like.</p>
<p>The backend team talks about bounded contexts, eventual consistency, and service meshes. You're thinking about loading states, stale data, and why the checkout page breaks when the inventory service is slow.</p>
<p>This article is for frontend engineers working in microservice environments. You'll learn how to consume multiple service APIs without creating a tangled mess, how to handle partial failures gracefully in the UI, how to manage distributed state across services, and how to work effectively with backend teams on API contracts <strong>because half the battle is communication, not code</strong>.</p>
<p>The goal is not to turn you into a backend engineer, it's to give you the mental models and patterns that make frontend development in a microservice world less painful.</p>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p>To get the most out of this article, you should be familiar with:</p>
<ul>
<li><p>React or a similar component framework (the examples use React and TypeScript)</p>
</li>
<li><p>Basic understanding of REST APIs and HTTP</p>
</li>
<li><p>Experience fetching data in frontend applications (fetch, Axios, or React Query)</p>
</li>
<li><p>General awareness of what microservices are (you don't need to have built one)</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-frontends-microservice-problem">The Frontend's Microservice Problem</a></p>
</li>
<li><p><a href="#heading-pattern-1-the-backend-for-frontend-bff">Pattern 1: The Backend-for-Frontend (BFF)</a></p>
</li>
<li><p><a href="#heading-pattern-2-handling-partial-failures-in-the-ui">Pattern 2: Handling Partial Failures in the UI</a></p>
</li>
<li><p><a href="#heading-pattern-3-managing-distributed-state">Pattern 3: Managing Distributed State</a></p>
</li>
<li><p><a href="#heading-pattern-4-taming-multiple-api-contracts">Pattern 4: Taming Multiple API Contracts</a></p>
</li>
<li><p><a href="#heading-pattern-5-timeout-budgets-for-page-assembly">Pattern 5: Timeout Budgets for Page Assembly</a></p>
</li>
<li><p><a href="#heading-pattern-6-error-boundaries-per-service">Pattern 6: Error Boundaries Per Service</a></p>
</li>
<li><p><a href="#heading-working-with-backend-teams-on-contracts">Working With Backend Teams on Contracts</a></p>
</li>
<li><p><a href="#heading-when-to-push-back">When to Push Back</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-the-frontends-microservice-problem">The Frontend's Microservice Problem</h2>
<p>In a monolithic architecture, the frontend talks to one API. That API owns the database, handles the business logic, and returns exactly the shape of data the UI needs. Life is simple.</p>
<p>In a microservice architecture, that single API fractures into many:</p>
<pre><code class="language-text">Monolith:
  Browser → API → Database

Microservices:
  Browser → API Gateway → User Service
                        → Order Service
                        → Inventory Service
                        → Payment Service
                        → Notification Service
</code></pre>
<p>Each of those services is owned by a different team, deployed independently, and may use different data formats or conventions. As a frontend engineer, you now have several new problems:</p>
<ol>
<li><p><strong>Multiple contracts:</strong> Each service has its own API shape. A "product" in the inventory service has different fields than a "product" in the catalog service.</p>
</li>
<li><p><strong>Partial failures:</strong> The order service might respond in 50 ms while the recommendation service times out. Your UI needs to handle both.</p>
</li>
<li><p><strong>Data consistency:</strong> A user updates their address, but the order service still shows the old one because it hasn't synced yet.</p>
</li>
<li><p><strong>Increased latency:</strong> Assembling a single page might require three or four API calls instead of one.</p>
</li>
</ol>
<p>These aren't backend problems that happen to affect the frontend. They're fundamentally frontend problems that require frontend solutions.</p>
<h2 id="heading-pattern-1-the-backend-for-frontend-bff">Pattern 1: The Backend-for-Frontend (BFF)</h2>
<p>The most impactful pattern for frontend teams in a microservice world is the Backend-for-Frontend. A BFF is a thin API layer that sits between the browser and the microservices. It's owned by the frontend team and exists to serve the frontend's specific needs.</p>
<pre><code class="language-text">Without BFF:
  Browser → User Service    (call 1)
  Browser → Order Service   (call 2)
  Browser → Inventory Service (call 3)
  3 round trips, 3 contracts to manage

With BFF:
  Browser → BFF → User Service
                → Order Service
                → Inventory Service
  1 round trip, 1 contract to manage
</code></pre>
<p>The BFF aggregates calls, transforms responses into the shapes your components need, and handles cross-service concerns like authentication token forwarding.</p>
<pre><code class="language-typescript">// BFF endpoint: GET /api/order-summary/:orderId
// Aggregates data from three services into one frontend-friendly response
import express from "express";

const router = express.Router();

router.get("/api/order-summary/:orderId", async (req, res) =&gt; {
  const { orderId } = req.params;
  const token = req.headers.authorization;

  try {
    const [order, customer, shipment] = await Promise.allSettled([
      fetch(`\({ORDER_SERVICE}/orders/\){orderId}`, {
        headers: { Authorization: token },
      }).then((r) =&gt; r.json()),
      fetch(`\({USER_SERVICE}/users/\){req.userId}`, { // userId set by auth middleware
        headers: { Authorization: token },
      }).then((r) =&gt; r.json()),
      fetch(`\({SHIPPING_SERVICE}/shipments?orderId=\){orderId}`, {
        headers: { Authorization: token },
      }).then((r) =&gt; r.json()),
    ]);

    res.json({
      order: order.status === "fulfilled" ? order.value : null,
      customer: customer.status === "fulfilled" ? customer.value : null,
      shipment: shipment.status === "fulfilled" ? shipment.value : null,
      errors: [order, customer, shipment]
        .filter((r) =&gt; r.status === "rejected")
        .map((r) =&gt; r.reason.message),
    });
  } catch (error) {
    res.status(500).json({ error: "Failed to assemble order summary" });
  }
});
</code></pre>
<p>Notice the use of <code>Promise.allSettled</code> instead of <code>Promise.all</code>. This is critical in a microservice environment. <code>Promise.all</code> fails fast: if any one service is down, the entire request fails. <code>Promise.allSettled</code> lets you return partial data, which leads directly to the next pattern.</p>
<h3 id="heading-when-to-use-a-bff">When to Use a BFF</h3>
<p>A BFF is worth the investment when:</p>
<ul>
<li><p>Your frontend aggregates data from three or more services per page</p>
</li>
<li><p>Different clients (web, mobile, admin) need different data shapes from the same services</p>
</li>
<li><p>You want the frontend team to control response shapes without waiting on backend teams</p>
</li>
</ul>
<p>A BFF isn't necessary when:</p>
<ul>
<li><p>You have an API gateway that already handles aggregation (for example, Apollo Federation for GraphQL)</p>
</li>
<li><p>You only consume one or two services</p>
</li>
<li><p>Your backend teams already provide frontend-optimized endpoints</p>
</li>
</ul>
<h2 id="heading-pattern-2-handling-partial-failures-in-the-ui">Pattern 2: Handling Partial Failures in the UI</h2>
<p>In a monolith, a request either succeeds or fails. In a microservice world, it can partially succeed. The order data loads fine, but the recommendation service is down. The product details are available, but the review service is slow.</p>
<p>Your UI needs to handle this gracefully. The key principle: <strong>never let a non-critical service failure break a critical user flow.</strong></p>
<pre><code class="language-typescript">// Types for partial data loading
interface ServiceResult&lt;T&gt; {
  data: T | null;
  status: "loaded" | "error" | "loading";
  error?: string;
}

interface OrderPageData {
  order: ServiceResult&lt;Order&gt;;
  recommendations: ServiceResult&lt;Product[]&gt;;
  reviews: ServiceResult&lt;Review[]&gt;;
}
</code></pre>
<p>Build your components to render independently based on what data is available:</p>
<pre><code class="language-typescript">function OrderPage({ orderId }: { orderId: string }) {
  const { order, recommendations, reviews } = useOrderPageData(orderId);

  // Critical: order must load or the page makes no sense
  if (order.status === "loading") return &lt;OrderSkeleton /&gt;;
  if (order.status === "error") return &lt;ErrorPage message={order.error} /&gt;;

  return (
    &lt;div&gt;
      {/* Critical section: always rendered */}
      &lt;OrderDetails order={order.data} /&gt;

      {/* Non-critical: degrades gracefully */}
      &lt;section aria-label="Recommendations"&gt;
        {recommendations.status === "loaded" ? (
          &lt;RecommendationCarousel products={recommendations.data} /&gt;
        ) : recommendations.status === "error" ? (
          &lt;EmptyState message="Recommendations Unavailable" /&gt;
        ) : (
          &lt;CarouselSkeleton /&gt;
        )}
      &lt;/section&gt;

      {/* Non-critical: degrades gracefully */}
      &lt;section aria-label="Customer reviews"&gt;
        {reviews.status === "loaded" ? (
          &lt;ReviewList reviews={reviews.data} /&gt;
        ) : reviews.status === "error" ? (
          &lt;EmptyState message="Reviews unavailable right now" /&gt;
        ) : (
          &lt;ReviewSkeleton /&gt;
        )}
      &lt;/section&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<h3 id="heading-classifying-critical-vs-non-critical-data">Classifying Critical vs. Non-Critical Data</h3>
<p>Not all data on a page is equally important. Before building any page that pulls from multiple services, classify each data source:</p>
<table>
<thead>
<tr>
<th>Data Source</th>
<th>Critical?</th>
<th>Failure Strategy</th>
</tr>
</thead>
<tbody><tr>
<td>Order details</td>
<td>Yes</td>
<td>Show error page, block the entire view</td>
</tr>
<tr>
<td>Customer info</td>
<td>Yes</td>
<td>Show error page</td>
</tr>
<tr>
<td>Recommendations</td>
<td>No</td>
<td>Hide the section, show empty state</td>
</tr>
<tr>
<td>Reviews</td>
<td>No</td>
<td>Show "reviews unavailable" message</td>
</tr>
<tr>
<td>Recently viewed</td>
<td>No</td>
<td>Hide silently</td>
</tr>
</tbody></table>
<p>This classification should be a conscious decision made with your product team, not something you discover when a service goes down in production.</p>
<h2 id="heading-pattern-3-managing-distributed-state">Pattern 3: Managing Distributed State</h2>
<p>In a monolithic world, the server is the single source of truth. In a microservice world, truth is distributed. The user service knows the user's current address. The order service has a snapshot of the address at the time of the order. These might not match.</p>
<h3 id="heading-stale-data-and-cache-boundaries">Stale Data and Cache Boundaries</h3>
<p>When your frontend caches data from multiple services, you need to think about cache boundaries. Data from different services goes stale at different rates.</p>
<pre><code class="language-typescript">// Configure cache times based on how frequently the underlying data changes
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000, // Default: 30 seconds
    },
  },
});

// Product catalog: changes infrequently
function useProduct(productId: string) {
  return useQuery({
    queryKey: ["product", productId],
    queryFn: () =&gt; fetchProduct(productId),
    staleTime: 5 * 60_000, // 5 minutes: catalog updates are rare
  });
}

// Inventory levels: changes constantly
function useStockLevel(productId: string) {
  return useQuery({
    queryKey: ["stock", productId],
    queryFn: () =&gt; fetchStockLevel(productId),
    staleTime: 10_000, // 10 seconds: stock changes with every purchase
    refetchInterval: 30_000, // Poll every 30 seconds on active pages
  });
}

// User's own order: should reflect latest state
function useOrder(orderId: string) {
  return useQuery({
    queryKey: ["order", orderId],
    queryFn: () =&gt; fetchOrder(orderId),
    staleTime: 0, // Always refetch: user expects to see their latest action
  });
}
</code></pre>
<p>The mistake is treating all cached data the same. Product information from the catalog service can be cached for minutes. Stock levels from the inventory service need to be refreshed much more frequently. A user's own order data should always be fresh because they just performed an action and expect to see the result.</p>
<h3 id="heading-cross-service-invalidation">Cross-Service Invalidation</h3>
<p>The trickiest part of distributed state is knowing when to invalidate. When a user places an order, you need to:</p>
<ol>
<li><p>Invalidate the order list (order service)</p>
</li>
<li><p>Invalidate the stock level (inventory service)</p>
</li>
<li><p>Invalidate the user's loyalty points (user service)</p>
</li>
</ol>
<pre><code class="language-typescript">// After a successful order placement, invalidate across service boundaries
async function placeOrder(cart: Cart): Promise&lt;Order&gt; {
  const order = await api.post("/api/orders", { items: cart.items });

  // Invalidate data from multiple services that this action affected
  queryClient.invalidateQueries({ queryKey: ["orders"] });
  queryClient.invalidateQueries({ queryKey: ["stock"] });
  queryClient.invalidateQueries({ queryKey: ["loyalty-points"] });

  // Optimistically update the cart (owned by the frontend)
  queryClient.setQueryData(["cart"], { items: [] });

  return order;
}
</code></pre>
<p>This is manual and error-prone. Every time a new service cares about order events, you need to remember to add an invalidation here.</p>
<p>For more robust alternatives, you can use server-sent events or WebSocket connections to let the backend push invalidation signals to the frontend, or adopt a pub/sub pattern within your client-side state layer where cache keys subscribe to domain events.</p>
<p>These approaches are beyond this article's scope, but worth exploring once your invalidation table grows past a dozen entries.</p>
<p>In the meantime, documenting these cross-service dependencies in a table helps:</p>
<table>
<thead>
<tr>
<th>User Action</th>
<th>Services Affected</th>
<th>Cache Keys to Invalidate</th>
</tr>
</thead>
<tbody><tr>
<td>Place order</td>
<td>Order, Inventory, User</td>
<td><code>orders</code>, <code>stock</code>, <code>loyalty-points</code>, <code>cart</code></td>
</tr>
<tr>
<td>Update address</td>
<td>User, Shipping</td>
<td><code>user-profile</code>, <code>shipping-estimates</code></td>
</tr>
<tr>
<td>Write review</td>
<td>Reviews, Product</td>
<td><code>reviews</code>, <code>product</code> (rating changes)</td>
</tr>
</tbody></table>
<h2 id="heading-pattern-4-taming-multiple-api-contracts">Pattern 4: Taming Multiple API Contracts</h2>
<p>In a microservice world, each service defines its own API contract. The user service returns <code>firstName</code> and <code>lastName</code>. The order service returns <code>customerName</code> as a single string. The notification service expects <code>fullName</code>. Same concept, three different field names.</p>
<h3 id="heading-the-adapter-layer">The Adapter Layer</h3>
<p>Create an adapter layer that translates each service's response into a consistent domain model that your components use:</p>
<pre><code class="language-typescript">// Domain models: what the frontend actually works with
interface User {
  id: string;
  fullName: string;
  email: string;
  address: Address;
}

// Adapter for the User Service
function adaptUserServiceResponse(raw: UserServiceResponse): User {
  return {
    id: raw.userId,
    fullName: `\({raw.firstName} \){raw.lastName}`,
    email: raw.emailAddress,
    address: {
      line1: raw.address.street,
      city: raw.address.city,
      postcode: raw.address.zipCode,
      country: raw.address.countryCode,
    },
  };
}

// Adapter for the Order Service (which embeds a different user shape)
function adaptOrderCustomer(raw: OrderServiceCustomer): User {
  return {
    id: raw.customerId,
    fullName: raw.customerName,
    email: raw.email,
    address: {
      line1: raw.shippingAddress.addressLine1,
      city: raw.shippingAddress.city,
      postcode: raw.shippingAddress.postalCode,
      country: raw.shippingAddress.country,
    },
  };
}
</code></pre>
<p>Your components only work with the <code>User</code> type. They never see the raw service responses. When a service changes its API, you update one adapter, not every component that displays a user's name.</p>
<h3 id="heading-where-to-put-the-adapter-layer">Where to Put the Adapter Layer</h3>
<p>If you have a BFF, the adapters live there. The browser never sees the raw service response. If you're calling services directly from the frontend, place the adapters in your data-fetching layer, between the HTTP call and the cache:</p>
<pre><code class="language-typescript">// The adapter runs before data enters the cache
function useUser(userId: string) {
  return useQuery({
    queryKey: ["user", userId],
    queryFn: async () =&gt; {
      const raw = await fetch(`/api/users/${userId}`).then((r) =&gt; r.json());
      return adaptUserServiceResponse(raw);
    },
  });
}
</code></pre>
<h2 id="heading-pattern-5-timeout-budgets-for-page-assembly">Pattern 5: Timeout Budgets for Page Assembly</h2>
<p>When a page depends on multiple services, you need a timeout strategy. Without one, your page load time is determined by the slowest service, and in a microservice world, there's always a slow service.</p>
<p>A timeout budget allocates a maximum time for assembling all the data a page needs. If a non-critical service doesn't respond within its budget, you render without it.</p>
<p>In practice, this utility lives in a shared service layer (for example, <code>lib/api.ts</code>) rather than inline with each page's assembly logic. Here's the implementation:</p>
<pre><code class="language-typescript">// lib/api.ts: shared timeout utility
async function fetchWithTimeout&lt;T&gt;(
  url: string,
  options: RequestInit,
  timeoutMs: number
): Promise&lt;T | null&gt; {
  const controller = new AbortController();
  const timeout = setTimeout(() =&gt; controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal,
    });
    return response.json();
  } catch (error) {
    if (error instanceof DOMException &amp;&amp; error.name === "AbortError") {
      console.warn(`Request to \({url} timed out after \){timeoutMs}ms`);
    }
    return null;
  } finally {
    clearTimeout(timeout);
  }
}

// Page assembly with tiered timeouts
async function assembleProductPage(productId: string): Promise&lt;ProductPageData&gt; {
  // Critical data: longer timeout, page fails without it
  const product = await fetchWithTimeout&lt;Product&gt;(
    `/api/products/${productId}`,
    {},
    3000 // 3 second budget for critical data
  );

  if (!product) {
    throw new Error("Product not found");
  }

  // Non-critical data: shorter timeout, page renders without it
  const [reviews, recommendations, relatedProducts] = await Promise.all([
    fetchWithTimeout&lt;Review[]&gt;(
      `/api/reviews?productId=${productId}`,
      {},
      1500 // 1.5 second budget
    ),
    fetchWithTimeout&lt;Product[]&gt;(
      `/api/recommendations?productId=${productId}`,
      {},
      1000 // 1 second budget: nice to have
    ),
    fetchWithTimeout&lt;Product[]&gt;(
      `/api/products/${productId}/related`,
      {},
      1000
    ),
  ]);

  return {
    product,
    reviews: reviews ?? [],
    recommendations: recommendations ?? [],
    relatedProducts: relatedProducts ?? [],
  };
}
</code></pre>
<p>Notice the different budgets. Critical data (the product itself) gets 3 seconds. Non-critical data (reviews, recommendations) gets 1–1.5 seconds. If recommendations are slow, you show the product without them. The user doesn't wait for a service they may not even look at.</p>
<h2 id="heading-pattern-6-error-boundaries-per-service">Pattern 6: Error Boundaries Per Service</h2>
<p>React error boundaries are especially powerful in a microservice frontend. Instead of one error boundary at the page level, place boundaries around sections that map to different backend services.</p>
<p>If you haven't used error boundaries before, here's a minimal implementation. Error boundaries must be class components, React doesn't support them as function components yet (see the <a href="https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary">React docs</a> for more detail):</p>
<pre><code class="language-typescript">class ErrorBoundary extends React.Component&lt;
  { fallback: React.ReactNode; children: React.ReactNode },
  { hasError: boolean } &gt; {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    console.error("ErrorBoundary caught:", error, info);
  }

  render() {
    if (this.state.hasError) return this.props.fallback;
    return this.props.children;
  }
}
</code></pre>
<p>With that in place, scope your boundaries to individual service sections:</p>
<pre><code class="language-typescript">function ProductPage({ productId }: { productId: string }) {
  return (
    &lt;div&gt;
      {/* If the product service fails, show a full-page error */}
      &lt;ErrorBoundary fallback={&lt;ProductErrorPage /&gt;}&gt;
        &lt;Suspense fallback={&lt;ProductSkeleton /&gt;}&gt;
          &lt;ProductDetails productId={productId} /&gt;
        &lt;/Suspense&gt;
      &lt;/ErrorBoundary&gt;

      {/* If the review service fails, just hide reviews */}
      &lt;ErrorBoundary fallback={&lt;EmptyState message="Reviews unavailable" /&gt;}&gt;
        &lt;Suspense fallback={&lt;ReviewSkeleton /&gt;}&gt;
          &lt;ProductReviews productId={productId} /&gt;
        &lt;/Suspense&gt;
      &lt;/ErrorBoundary&gt;

      {/* If recommendations fail, hide silently */}
      &lt;ErrorBoundary fallback={null}&gt;
        &lt;Suspense fallback={&lt;CarouselSkeleton /&gt;}&gt;
          &lt;Recommendations productId={productId} /&gt;
        &lt;/Suspense&gt;
      &lt;/ErrorBoundary&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>Each boundary catches errors from its own data source independently. The review service crashing doesn't affect the product details. The recommendation service timing out doesn't show an error at all – the section simply doesn't render.</p>
<p>This maps directly to your critical/non-critical classification. Critical services get error boundaries with visible error UI. Non-critical services get boundaries that degrade silently or show a minimal empty state.</p>
<h2 id="heading-working-with-backend-teams-on-contracts">Working With Backend Teams on Contracts</h2>
<p>The technical patterns above solve symptoms. The root cause of most frontend pain in microservice environments is poor communication between frontend and backend teams about API contracts.</p>
<h3 id="heading-contract-conversations-to-have-early">Contract Conversations to Have Early</h3>
<h4 id="heading-1-what-fields-will-the-frontend-actually-use">1. What fields will the frontend actually use?</h4>
<p>Backend services often expose their entire data model. The frontend uses three fields. If the backend team knows which fields you depend on, they can maintain those fields more carefully and deprecate the ones nobody uses.</p>
<h4 id="heading-2-what-is-the-expected-latency-budget-for-this-endpoint">2. What is the expected latency budget for this endpoint?</h4>
<p>If the product page has a 2-second total budget and the recommendation service averages 1.8 seconds, you have a problem before you write any frontend code. Surface this early.</p>
<h4 id="heading-3-what-happens-when-this-service-is-degraded">3. What happens when this service is degraded?</h4>
<p>Ask each backend team: "If your service responds with 500 errors for an hour, what should the frontend show?" This question often reveals that nobody has thought about it, which is exactly why you need to ask.</p>
<h4 id="heading-4-how-will-you-communicate-breaking-changes">4. How will you communicate breaking changes?</h4>
<p>Agree on a process. Whether it is OpenAPI spec diffs in pull requests, a Slack channel for API changes, or versioned endpoints, pick something and hold each other to it.</p>
<h3 id="heading-api-contracts-as-shared-artifacts">API Contracts as Shared Artifacts</h3>
<p>Push for machine-readable contracts. OpenAPI specs, GraphQL schemas, or Protocol Buffer definitions serve as a shared source of truth between frontend and backend teams. They enable:</p>
<ul>
<li><p><strong>Automated type generation:</strong> Tools like <code>openapi-typescript</code> generate TypeScript types from OpenAPI specs. When the backend changes a field, your build fails immediately, not in production.</p>
</li>
<li><p><strong>Contract testing:</strong> Tools like <code>Pact</code> let you define the expected request/response pairs from the frontend's perspective. The backend runs these tests in their CI pipeline. If their changes break the frontend's expectations, the pipeline fails.</p>
</li>
<li><p><strong>Mock servers:</strong> Generated mocks from the spec let you build the frontend before the backend is ready. When the real service ships, your code already works.</p>
</li>
</ul>
<pre><code class="language-typescript">// Generated types from OpenAPI spec, always in sync with the backend
import type { components } from "./generated/inventory-api";

type Product = components["schemas"]["Product"];
type StockLevel = components["schemas"]["StockLevel"];

// If the backend renames "available" to "inStock",
// this code fails at compile time, not in production
function formatStockMessage(stock: StockLevel): string {
  if (stock.available &gt; 10) return "In Stock";
  if (stock.available &gt; 0) return `Only ${stock.available} left`;
  return "Out of Stock";
}
</code></pre>
<h3 id="heading-testing-against-multiple-services">Testing Against Multiple Services</h3>
<p>Contract testing catches backend-side breaking changes, but you also need to test your frontend's behavior when services respond in unexpected ways. <a href="https://mswjs.io/">Mock Service Worker (MSW)</a> lets you spin up per-service mock handlers in your test environment:</p>
<pre><code class="language-typescript">import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";

// Mock each service independently
const server = setupServer(
  http.get("/api/products/:id", () =&gt;
    HttpResponse.json({ productId: "abc-123", name: "Widget", price: 49.99 })
  ),
  http.get("/api/reviews", () =&gt;
    HttpResponse.json([{ rating: 5, body: "Great product" }])
  )
);

// Test: what happens when the review service is down?
test("renders product page when reviews service fails", async () =&gt; {
  server.use(
    http.get("/api/reviews", () =&gt; HttpResponse.error())
  );

  render(&lt;ProductPage productId="abc-123" /&gt;);

  expect(await screen.findByText("Widget")).toBeInTheDocument();
  expect(await screen.findByText("Reviews unavailable")).toBeInTheDocument();
});
</code></pre>
<p>This lets you simulate the partial failure scenarios from Pattern 2 in your test suite. Test your adapter layer (Pattern 4) with unit tests against raw service response fixtures, and use MSW for integration tests that verify the full page assembles correctly when individual services are slow, down, or return unexpected shapes.</p>
<h2 id="heading-when-to-push-back">When to Push Back</h2>
<p>Not every microservice problem has a frontend solution. Sometimes the right answer is to push back on the architecture.</p>
<p><strong>Push back when the frontend is making more than 5 API calls for a single page.</strong> This is a signal that either the services are too granular or there is a missing aggregation layer. The fix is a BFF or a composite API, not more <code>Promise.all</code> calls in the browser.</p>
<p><strong>Push back when two services return conflicting data about the same entity.</strong> If the user service says the user's name is "Jane" and the order service says it is "Janet," this is a data consistency problem that the frontend can't solve. It needs to be fixed at the source, either through event-driven syncing between services or by establishing one service as the authoritative source for that field.</p>
<p><strong>Push back when backend teams make breaking changes without notice.</strong> If your production app breaks because a service renamed a field in a minor version bump, that's a process failure. Advocate for versioned APIs, deprecation notices, and contract testing.</p>
<p>You're not just a consumer of APIs. You're a stakeholder in how those APIs are designed. The earlier you participate in API design conversations, the fewer surprises you deal with in production.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The patterns in this article give you a structured starting point, but the underlying principle is consistent across all of them:</p>
<p>Key takeaways:</p>
<ol>
<li><p><strong>Own the aggregation layer:</strong> A BFF gives the frontend team control over response shapes and lets you handle partial failures at the server level instead of the browser.</p>
</li>
<li><p><strong>Classify every data source as critical or non-critical:</strong> This single decision determines your error handling, timeout budgets, and loading strategies for every section of every page.</p>
</li>
<li><p><strong>Normalize at the boundary:</strong> Adapter layers between raw service responses and your components protect you from upstream API changes and give you a consistent domain model.</p>
</li>
<li><p><strong>Invest in contracts:</strong> Machine-readable API contracts, generated types, and contract testing catch breaking changes at build time instead of in production.</p>
</li>
<li><p><strong>Push back when needed:</strong> Not every microservice problem has a frontend solution. If the architecture creates an unreasonable burden on the UI layer, say so early.</p>
</li>
</ol>
<p>Microservices are a backend architecture decision, but their consequences are felt most acutely in the frontend. The patterns in this article won't make that complexity disappear, but they will give you a structured way to manage it.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Service-to-Service Communication: When to Use REST, gRPC, and Event-Driven Messaging ]]>
                </title>
                <description>
                    <![CDATA[ The communication layer is one of the few architectural decisions that touches everything in your apps. It determines your latency floor, how independently teams can deploy, how failures propagate, an ]]>
                </description>
                <link>https://www.freecodecamp.org/news/service-to-service-communication-when-to-use-rest-grpc-and-event-driven-messaging/</link>
                <guid isPermaLink="false">69dea59891716f3cfb76ac50</guid>
                
                    <category>
                        <![CDATA[ distributed system ]]>
                    </category>
                
                    <category>
                        <![CDATA[ service-to-service communication ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api architecture ]]>
                    </category>
                
                    <category>
                        <![CDATA[ REST API ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Abisoye Alli-Balogun ]]>
                </dc:creator>
                <pubDate>Tue, 14 Apr 2026 20:37:44 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/b56ca584-75e1-4d0f-a88a-c2419a0b5d6e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The communication layer is one of the few architectural decisions that touches everything in your apps. It determines your latency floor, how independently teams can deploy, how failures propagate, and how much pain you feel every time a contract needs to change.</p>
<p>There are three dominant patterns: REST over HTTP, gRPC with Protocol Buffers, and event-driven messaging through a broker. Most production systems use a mix of all three. The skill is knowing which pattern fits which interaction.</p>
<p>In this article, you'll learn the core mechanics of each communication style, the real trade-offs between them across five dimensions (latency, coupling, schema evolution, debugging, and operational complexity), and a decision framework for choosing the right pattern for each service interaction.</p>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p>To get the most out of this article, you should be familiar with:</p>
<ul>
<li><p>Basic HTTP concepts (request/response, status codes, headers)</p>
</li>
<li><p>Working with APIs in any backend language (the examples use TypeScript and Node.js)</p>
</li>
<li><p>General understanding of microservices architecture</p>
</li>
<li><p>Familiarity with JSON as a data interchange format</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-three-patterns-at-a-glance">The Three Patterns at a Glance</a></p>
</li>
<li><p><a href="#heading-rest-the-default-choice">REST: The Default Choice</a></p>
</li>
<li><p><a href="#heading-grpc-the-performance-choice">gRPC: The Performance Choice</a></p>
</li>
<li><p><a href="#heading-event-driven-messaging-the-decoupling-choice">Event-Driven Messaging: The Decoupling Choice</a></p>
</li>
<li><p><a href="#heading-the-five-trade-off-dimensions">The Five Trade-Off Dimensions</a></p>
</li>
<li><p><a href="#heading-the-decision-framework">The Decision Framework</a></p>
</li>
<li><p><a href="#heading-hybrid-architectures-using-all-three">Hybrid Architectures: Using All Three</a></p>
</li>
<li><p><a href="#heading-schema-governance-at-scale">Schema Governance at Scale</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-the-three-patterns-at-a-glance">The Three Patterns at a Glance</h2>
<p>Before diving deep, here's the landscape:</p>
<table>
<thead>
<tr>
<th></th>
<th>REST</th>
<th>gRPC</th>
<th>Event-Driven</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Communication</strong></td>
<td>Synchronous</td>
<td>Synchronous (+ streaming)</td>
<td>Asynchronous</td>
</tr>
<tr>
<td><strong>Protocol</strong></td>
<td>HTTP/1.1 or HTTP/2</td>
<td>HTTP/2</td>
<td>Broker-dependent (TCP)</td>
</tr>
<tr>
<td><strong>Serialization</strong></td>
<td>JSON (typically)</td>
<td>Protocol Buffers (binary)</td>
<td>JSON, Avro, Protobuf</td>
</tr>
<tr>
<td><strong>Coupling</strong></td>
<td>Request-time</td>
<td>Request-time + schema</td>
<td>Temporal decoupling</td>
</tr>
<tr>
<td><strong>Best for</strong></td>
<td>Public APIs, CRUD</td>
<td>Internal high-throughput</td>
<td>Workflows, event sourcing</td>
</tr>
</tbody></table>
<p>Each has strengths, and none is universally better. The rest of this article explores why.</p>
<h2 id="heading-rest-the-default-choice">REST: The Default Choice</h2>
<p>REST over HTTP is the most widely understood communication pattern. Services expose resources at URL endpoints, and clients interact through standard HTTP methods.</p>
<pre><code class="language-typescript">// Order service calls the inventory service
async function checkInventory(productId: string): Promise&lt;InventoryStatus&gt; {
  const response = await fetch(
    `https://inventory-service/api/v1/products/${productId}/stock`,
    {
      method: "GET",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${getServiceToken()}`,
      },
    }
  );

  if (!response.ok) {
    throw new HttpError(response.status, await response.text());
  }

  return response.json();
}
</code></pre>
<h3 id="heading-where-rest-excels">Where REST Excels</h3>
<p>Every language, framework, and platform speaks HTTP. Your frontend, your mobile app, your partner integrations, and your internal services can all use the same protocol.</p>
<p>The tooling is also mature: load balancers, API gateways, caching proxies, and debugging tools all understand HTTP natively.</p>
<p>It's also relatively simple. A new developer can read a REST call and understand what it does. The URL describes the resource. The HTTP method describes the action. The status code describes the outcome. There's no schema compilation step, no code generation, and no special client library required.</p>
<p>Beyond this, HTTP has built-in caching semantics. A <code>GET /products/123</code> response with a <code>Cache-Control: max-age=60</code> header can be cached by every proxy between the caller and the server. gRPC and event-driven patterns have no equivalent built-in mechanism.</p>
<pre><code class="language-typescript">// REST response with cache headers
app.get("/api/v1/products/:id", async (req, res) =&gt; {
  const product = await getProduct(req.params.id);

  res.set("Cache-Control", "public, max-age=60");
  res.set("ETag", computeETag(product));

  res.json(product);
});
</code></pre>
<h3 id="heading-where-rest-falls-short">Where REST Falls Short</h3>
<p>REST's resource-oriented model often requires multiple round-trips to assemble a response. Fetching an order with its items, customer details, and shipping status might mean three separate HTTP calls. Each call adds network latency, TCP handshake overhead, and serialization cost.</p>
<pre><code class="language-typescript">// Three sequential calls to build one view
async function getOrderDetails(orderId: string): Promise&lt;OrderDetails&gt; {
  const order = await fetch(`/api/orders/${orderId}`).then((r) =&gt; r.json());
  const customer = await fetch(`/api/customers/${order.customerId}`).then((r) =&gt; r.json());
  const shipment = await fetch(`/api/shipments/${order.shipmentId}`).then((r) =&gt; r.json());

  return { order, customer, shipment };
}
</code></pre>
<p>You can mitigate this with composite endpoints or GraphQL, but that adds complexity. gRPC handles this more naturally with message composition.</p>
<p>The serialization overhead is also an issue. JSON is human-readable but expensive to parse. For high-throughput internal communication where nobody reads the payloads, you are paying a tax in CPU and bandwidth for readability you do not need.</p>
<p>Finally, there's no streaming. Standard REST is request-response. If you need the server to push updates to the client (real-time order tracking, live metrics), REST requires workarounds like polling, Server-Sent Events, or WebSockets. None of these are part of the REST model itself.</p>
<h2 id="heading-grpc-the-performance-choice">gRPC: The Performance Choice</h2>
<p>gRPC is a Remote Procedure Call framework built on HTTP/2 and Protocol Buffers. Instead of URLs and JSON, you define services and messages in <code>.proto</code> files, and the framework generates strongly-typed client and server code.</p>
<h3 id="heading-defining-the-contract">Defining the Contract</h3>
<pre><code class="language-protobuf">// inventory.proto
syntax = "proto3";

package inventory;

service InventoryService {
  // Unary: single request, single response
  rpc CheckStock(StockRequest) returns (StockResponse);

  // Server streaming: single request, stream of responses
  rpc WatchStockLevels(WatchRequest) returns (stream StockUpdate);

  // Client streaming: stream of requests, single response
  rpc BulkUpdateStock(stream StockAdjustment) returns (BulkUpdateResult);
}

message StockRequest {
  string product_id = 1;
  string warehouse_id = 2;
}

message StockResponse {
  string product_id = 1;
  int32 available = 2;
  int32 reserved = 3;
  google.protobuf.Timestamp last_updated = 4;
}

message StockUpdate {
  string product_id = 1;
  int32 available = 2;
  string warehouse_id = 3;
}
</code></pre>
<p>After running <code>protoc</code> (the Protocol Buffer compiler), you get generated client and server stubs in your target language. The TypeScript client looks like this:</p>
<pre><code class="language-typescript">import { InventoryServiceClient } from "./generated/inventory";
import { credentials } from "@grpc/grpc-js";

const client = new InventoryServiceClient(
  "inventory-service:50051",
  credentials.createInsecure()
);

async function checkStock(productId: string): Promise&lt;StockResponse&gt; {
  return new Promise((resolve, reject) =&gt; {
    client.checkStock(
      { productId, warehouseId: "warehouse-eu-1" },
      (error, response) =&gt; {
        if (error) reject(error);
        else resolve(response);
      }
    );
  });
}
</code></pre>
<h3 id="heading-where-grpc-excels">Where gRPC Excels</h3>
<p>Protocol Buffers serialize to a compact binary format. A message that is 1 KB as JSON might be 300 bytes as Protobuf. Combined with HTTP/2 multiplexing (multiple requests over a single TCP connection), gRPC delivers significantly lower latency and higher throughput than REST for internal service calls. And we all know performance is important.</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>REST (JSON/HTTP 1.1)</th>
<th>gRPC (Protobuf/HTTP 2)</th>
</tr>
</thead>
<tbody><tr>
<td>Serialization size</td>
<td>Larger (text-based JSON)</td>
<td>Significantly smaller (binary Protobuf)</td>
</tr>
<tr>
<td>Serialization time</td>
<td>Slower (JSON parse/stringify)</td>
<td>Faster (binary encode/decode)</td>
</tr>
<tr>
<td>Requests per connection</td>
<td>1 (without pipelining)</td>
<td>Multiplexed</td>
</tr>
<tr>
<td>Connection overhead</td>
<td>New connection per request (HTTP/1.1)</td>
<td>Persistent connections with multiplexing</td>
</tr>
</tbody></table>
<p>The exact improvement depends on payload size, network topology, and server implementation. In benchmarks, the difference ranges from marginal (tiny payloads on fast networks) to an order of magnitude (large payloads, high concurrency).</p>
<p>The takeaway: gRPC's binary serialization and HTTP/2 multiplexing give it a structural advantage for internal traffic, but you should measure in your own environment before making latency claims.</p>
<p>Also, gRPC natively supports four communication patterns: unary (request-response), server streaming, client streaming, and bidirectional streaming. This makes it a natural fit for real-time use cases like live stock updates, log tailing, or progress reporting.</p>
<pre><code class="language-typescript">// Server streaming: watch inventory changes in real time
function watchStockLevels(warehouseId: string): void {
  const stream = client.watchStockLevels({ warehouseId });

  stream.on("data", (update: StockUpdate) =&gt; {
    console.log(`Product \({update.productId}: \){update.available} available`);
  });

  stream.on("error", (error) =&gt; {
    console.error("Stream error:", error.message);
    // Reconnect logic here
  });

  stream.on("end", () =&gt; {
    console.log("Stream ended");
  });
}
</code></pre>
<p>Finally, it has strong typing across services. The <code>.proto</code> file is the single source of truth. Both the client and server are generated from it. If the inventory service changes the <code>StockResponse</code> message, the order service's build fails until it regenerates its client. You catch breaking changes at compile time, not at 3 AM.</p>
<h3 id="heading-where-grpc-falls-short">Where gRPC Falls Short</h3>
<p>The first key issue is browser support. Browsers can't make native gRPC calls because the browser's <code>fetch</code> API doesn't expose the HTTP/2 framing that gRPC requires (for example, trailers for status codes and fine-grained control over bidirectional streams).</p>
<p>You need gRPC-Web, which uses a proxy like Envoy to translate between the browser-compatible subset of gRPC and the full protocol. Alternatively, you can place a REST or GraphQL gateway in front of your gRPC services.</p>
<p>Either way, gRPC isn't a viable choice for any endpoint that a browser calls directly — which is why the decision framework in this article defaults to REST for public-facing APIs.</p>
<p>It's also difficult to debug. You can't <code>curl</code> a gRPC endpoint. The binary payloads aren't human-readable. Tools like <code>grpcurl</code> and Postman's gRPC support help, but the debugging experience is worse than inspecting a JSON response in a browser's network tab.</p>
<pre><code class="language-bash"># Debugging a REST endpoint
curl -s https://inventory-service/api/v1/products/abc-123/stock | jq

# Debugging a gRPC endpoint (requires grpcurl)
grpcurl -plaintext -d '{"product_id": "abc-123"}' \
  inventory-service:50051 inventory.InventoryService/CheckStock
</code></pre>
<p>Finally, operational overhead is an issue. You need to manage <code>.proto</code> files, run code generation in your build pipeline, version your proto definitions, and ensure all consumers regenerate when schemas change.</p>
<p>For a team with two services, this is manageable. For twenty services, you need a proto registry and a governance process.</p>
<h2 id="heading-event-driven-messaging-the-decoupling-choice">Event-Driven Messaging: The Decoupling Choice</h2>
<p>Event-driven communication flips the model. Instead of service A calling service B directly, service A publishes an event to a broker (Kafka, RabbitMQ, Amazon SNS/SQS, or similar), and service B consumes it asynchronously.</p>
<pre><code class="language-typescript">// Order service publishes an event after confirming an order
import { Kafka } from "kafkajs";

const kafka = new Kafka({ brokers: ["kafka:9092"] });
const producer = kafka.producer();

async function publishOrderConfirmed(order: Order): Promise&lt;void&gt; {
  await producer.send({
    topic: "order.confirmed",
    messages: [
      {
        key: order.id,
        value: JSON.stringify({
          eventType: "order.confirmed",
          eventId: crypto.randomUUID(),
          timestamp: new Date().toISOString(),
          data: {
            orderId: order.id,
            customerId: order.customerId,
            items: order.items.map((item) =&gt; ({
              productId: item.productId,
              quantity: item.quantity,
            })),
            total: order.total,
          },
        }),
      },
    ],
  });
}
</code></pre>
<pre><code class="language-typescript">// Inventory service consumes the event independently
const consumer = kafka.consumer({ groupId: "inventory-service" });

async function startInventoryConsumer(): Promise&lt;void&gt; {
  await consumer.subscribe({ topic: "order.confirmed" });

  await consumer.run({
    eachMessage: async ({ message }) =&gt; {
      const event = JSON.parse(message.value.toString());

      for (const item of event.data.items) {
        await decrementStock(item.productId, item.quantity);
      }

      logger.info("Inventory updated for order", {
        orderId: event.data.orderId,
      });
    },
  });
}
</code></pre>
<h3 id="heading-where-event-driven-excels">Where Event-Driven Excels</h3>
<p>First, it employs temporal decoupling. The producer doesn't wait for the consumer. The order service publishes "order confirmed" and moves on. If the inventory service is down, the event sits in the broker until it recovers. No timeout, no retry logic in the producer, no cascading failure.</p>
<p>One event can also trigger multiple independent reactions. When an order is confirmed, the inventory service decrements stock, the notification service sends a confirmation email, the analytics service records a conversion, and the shipping service starts fulfillment. The order service doesn't know or care about any of these consumers.</p>
<pre><code class="language-text">order.confirmed event
  ├── inventory-service    → Decrement stock
  ├── notification-service → Send confirmation email
  ├── analytics-service    → Record conversion
  └── shipping-service     → Create shipment
</code></pre>
<p>Adding a new consumer requires zero changes to the producer. This is the lowest coupling you can achieve between services.</p>
<p>There's also a natural audit trail. If your broker retains events (Kafka does this by default), you have a complete history of everything that happened. You can replay events to rebuild state, debug issues by examining the exact sequence of events, or spin up a new service that processes historical events to backfill its data.</p>
<h3 id="heading-where-event-driven-falls-short">Where Event-Driven Falls Short</h3>
<p>After the order service publishes "order confirmed," there's a window where the inventory service hasn't yet processed the event. During that window, a concurrent request might read stale stock levels. If your use case requires "read your own writes" consistency, event-driven communication alone is not enough.</p>
<pre><code class="language-typescript">// The problem: order confirmed, but stock not yet decremented
async function handleCheckout(cart: Cart): Promise&lt;Order&gt; {
  const order = await createOrder(cart);
  await publishOrderConfirmed(order);

  // If another request checks stock RIGHT NOW,
  // it sees the old (pre-decrement) value.
  // The inventory consumer hasn't processed the event yet.
  return order;
}
</code></pre>
<p>Debugging also gets more complex. When something goes wrong in a synchronous call chain, you get a stack trace. When something goes wrong in an event-driven flow, you get a message in a dead-letter queue and a question: which producer sent this? When? What was the system state at that time? Distributed tracing helps, but correlating events across services is fundamentally harder than following a request through a call stack.</p>
<p>You can also have issues with ordering guarantees. Most brokers guarantee ordering within a partition (Kafka) or a queue, but not globally. If the order service publishes "order confirmed" and then "order cancelled," the inventory service might process the cancellation first if the events land on different partitions.</p>
<pre><code class="language-typescript">// Use a consistent partition key to guarantee ordering per entity
await producer.send({
  topic: "order.events",
  messages: [
    {
      // All events for the same order go to the same partition
      key: order.id,
      value: JSON.stringify(event),
    },
  ],
});
</code></pre>
<p>Keying messages by entity ID (order ID, customer ID) ensures events for the same entity are processed in order. Events for different entities can be processed in parallel.</p>
<p>Finally, your operations get more complex. Running a message broker isn't free. Kafka requires ZooKeeper (or KRaft), topic management, partition rebalancing, consumer group coordination, and monitoring for consumer lag. Managed services like Amazon MSK, Confluent Cloud, or Amazon SQS reduce this burden but add cost.</p>
<h3 id="heading-handling-broker-failures">Handling Broker Failures</h3>
<p>What happens when the broker is unavailable? If your service writes to the database and then publishes an event, a broker outage means the event is lost even though the database write succeeded.</p>
<p>These patterns help:</p>
<h4 id="heading-1-the-outbox-pattern">1. The Outbox Pattern</h4>
<p>Instead of publishing directly to the broker, write the event to an "outbox" table in the same database transaction as your business data. A separate process (a poller or a change-data-capture connector like Debezium) reads the outbox table and publishes to the broker.</p>
<pre><code class="language-typescript">// Outbox pattern: write event to the database, not the broker
// db injected via dependency injection
async function confirmOrder(order: Order, db: Database): Promise&lt;void&gt; {
  await db.transaction(async (tx) =&gt; {
    // Business write and event write in the same transaction
    await tx.update("orders", { id: order.id, status: "confirmed" });
    await tx.insert("outbox", {
      id: crypto.randomUUID(),
      topic: "order.confirmed",
      key: order.id,
      payload: JSON.stringify({
        orderId: order.id,
        customerId: order.customerId,
        items: order.items,
        total: order.total,
      }),
      created_at: new Date(),
    });
  });
  // A separate relay process picks up outbox rows and publishes to Kafka
}
</code></pre>
<p>Because the event and the business data are written atomically, you never lose an event due to a broker outage. The relay process retries until the broker is back.</p>
<h4 id="heading-2-at-least-once-delivery">2. At-least-once delivery</h4>
<p>Most brokers guarantee at-least-once delivery, meaning consumers may see the same event more than once (for example, after a rebalance or a retry). Your consumers must be idempotent: processing the same event twice should produce the same result as processing it once.</p>
<pre><code class="language-typescript">// Idempotent consumer: use the eventId to deduplicate
async function handleOrderConfirmed(event: EventEnvelope&lt;OrderData&gt;): Promise&lt;void&gt; {
  const alreadyProcessed = await db.query(
    "SELECT 1 FROM processed_events WHERE event_id = $1",
    [event.eventId]
  );

  if (alreadyProcessed.rows.length &gt; 0) {
    logger.info("Duplicate event, skipping", { eventId: event.eventId });
    return;
  }

  await db.transaction(async (tx) =&gt; {
    await decrementStock(tx, event.data.items);
    await tx.insert("processed_events", {
      event_id: event.eventId,
      processed_at: new Date(),
    });
  });
}
</code></pre>
<p>The combination of the outbox pattern (producer side) and idempotent consumers (consumer side) gives you reliable event-driven communication even when the broker has intermittent failures.</p>
<h2 id="heading-the-five-trade-off-dimensions">The Five Trade-Off Dimensions</h2>
<p>Choosing a communication pattern isn't about which is "best." It's about which trade-offs you can accept for each specific interaction. Here are the five dimensions that matter most.</p>
<h3 id="heading-1-latency">1. Latency</h3>
<table>
<thead>
<tr>
<th>Pattern</th>
<th>Relative Latency</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td>gRPC</td>
<td>Lowest</td>
<td>Binary serialization, HTTP/2 multiplexing, persistent connections</td>
</tr>
<tr>
<td>REST</td>
<td>Low-moderate</td>
<td>JSON parsing overhead, typically HTTP/1.1 connection setup</td>
</tr>
<tr>
<td>Event-driven</td>
<td>Highest (by design)</td>
<td>Broker write, replication, consumer poll interval</td>
</tr>
</tbody></table>
<p>Exact numbers depend on payload size, network hops, and infrastructure. The structural ordering is consistent: gRPC is fastest for synchronous calls, REST is close behind, and event-driven messaging trades latency for decoupling.</p>
<p>If the caller needs an immediate response (user-facing checkout, real-time search), use gRPC or REST. If the caller doesn't need the result right now (send email, update analytics), use events.</p>
<h3 id="heading-2-coupling">2. Coupling</h3>
<p>Coupling has two dimensions: <strong>temporal</strong> (does the caller wait for the receiver?) and <strong>schema</strong> (do they share a contract?).</p>
<table>
<thead>
<tr>
<th>Pattern</th>
<th>Temporal Coupling</th>
<th>Schema Coupling</th>
</tr>
</thead>
<tbody><tr>
<td>REST</td>
<td>High (caller blocks)</td>
<td>Low (JSON is flexible)</td>
</tr>
<tr>
<td>gRPC</td>
<td>High (caller blocks)</td>
<td>High (shared <code>.proto</code> files)</td>
</tr>
<tr>
<td>Event-driven</td>
<td>None (fire and forget)</td>
<td>Medium (shared event schema)</td>
</tr>
</tbody></table>
<p>REST's loose typing is a double-edged sword. You can add fields to a JSON response without breaking consumers (additive changes are safe). But you can also accidentally remove a field, and the consumer fails at runtime instead of compile time.</p>
<p>gRPC's strict typing catches breaking changes at build time, but it means every schema change requires regenerating clients. For two services, this is trivial. For twenty services consuming the same proto, you need a coordination process.</p>
<p>Event-driven messaging decouples in time but still couples on the event schema. If the <code>order.confirmed</code> event changes its structure, every consumer must handle both the old and new format during the transition.</p>
<h3 id="heading-3-schema-evolution">3. Schema Evolution</h3>
<p>Schema evolution is how you change the contract between services without breaking existing consumers. This is where the three patterns diverge most sharply.</p>
<h4 id="heading-rest-json">REST (JSON):</h4>
<pre><code class="language-typescript">// Version 1: price as a number
{ "productId": "abc-123", "price": 49.99 }

// Version 2: price as an object (breaking change)
{ "productId": "abc-123", "price": { "amount": 49.99, "currency": "USD" } }
</code></pre>
<p>JSON has no built-in versioning. You manage it through one of three strategies:</p>
<table>
<thead>
<tr>
<th>Strategy</th>
<th>How It Works</th>
<th>Trade-offs</th>
</tr>
</thead>
<tbody><tr>
<td><strong>URL versioning</strong> (<code>/api/v1/</code> vs <code>/api/v2/</code>)</td>
<td>Each version is a separate endpoint. Consumers opt in to the new version explicitly.</td>
<td>Simplest to understand. Duplicates route handlers. Hard to sunset old versions when many consumers pin to <code>/v1/</code>.</td>
</tr>
<tr>
<td><strong>Header versioning</strong> (<code>Accept: application/vnd.myapi.v2+json</code>)</td>
<td>Single URL, version negotiated via headers.</td>
<td>Cleaner URLs, no route duplication. Harder to test (you can't just paste a URL into a browser). Proxy and cache behavior is trickier since the response varies by header.</td>
</tr>
<tr>
<td><strong>Defensive parsing</strong> (consumer-side tolerance)</td>
<td>No explicit versioning. Consumers ignore unknown fields and use defaults for missing ones.</td>
<td>Zero coordination cost for additive changes. Breaks down for structural changes (field renames, type changes) where the consumer can't infer intent.</td>
</tr>
</tbody></table>
<p>Additive changes (new fields) are safe with any strategy. Structural changes (renaming fields, changing types) require explicit versioning — URL or header — so consumers can migrate at their own pace.</p>
<h4 id="heading-grpc-protocol-buffers">gRPC (Protocol Buffers):</h4>
<pre><code class="language-protobuf">// Protocol Buffers have built-in evolution rules
message StockResponse {
  string product_id = 1;
  int32 available = 2;
  int32 reserved = 3;
  // Field 4 was removed (never reuse field numbers)
  string warehouse_id = 5;       // New field: old clients ignore it
  optional string region = 6;    // Optional: old clients don't send it
}
</code></pre>
<p>Protocol Buffers handle evolution well by design. You can add new fields (old clients ignore them), deprecate fields (stop writing them, keep the number reserved), and use <code>optional</code> for fields that may not be present.</p>
<p>You can't rename fields, change field types, or reuse field numbers. These rules are enforced by the tooling.</p>
<h4 id="heading-event-driven-avrojson-schema">Event-driven (Avro/JSON Schema):</h4>
<p>For events, schema registries like Confluent Schema Registry enforce compatibility rules:</p>
<pre><code class="language-typescript">// Register a schema with backward compatibility
// New consumers can read old events, old consumers can read new events
const schema = {
  type: "record",
  name: "OrderConfirmed",
  fields: [
    { name: "orderId", type: "string" },
    { name: "customerId", type: "string" },
    { name: "total", type: "double" },
    // New field with default: backward compatible
    { name: "currency", type: "string", default: "USD" },
  ],
};
</code></pre>
<p>With a schema registry, producers can't publish events that violate the compatibility contract. This is the strongest governance model: the registry rejects incompatible schemas before they reach consumers.</p>
<h3 id="heading-4-debugging-and-observability">4. Debugging and Observability</h3>
<table>
<thead>
<tr>
<th>Pattern</th>
<th>Debugging Experience</th>
</tr>
</thead>
<tbody><tr>
<td>REST</td>
<td>Best. Human-readable payloads, browser DevTools, <code>curl</code>, standard HTTP tracing.</td>
</tr>
<tr>
<td>gRPC</td>
<td>Moderate. Binary payloads need <code>grpcurl</code> or Postman. Metadata is inspectable. Distributed tracing works well.</td>
</tr>
<tr>
<td>Event-driven</td>
<td>Hardest. Asynchronous flows require correlation IDs, dead-letter queue inspection, and broker-specific tooling.</td>
</tr>
</tbody></table>
<p>For event-driven systems, correlation IDs are essential:</p>
<pre><code class="language-typescript">// Always include a correlation ID in events
interface EventEnvelope&lt;T&gt; {
  eventId: string;
  eventType: string;
  correlationId: string; // Links related events across services
  causationId: string;   // The event that caused this one
  timestamp: string;
  source: string;
  data: T;
}

async function publishEvent&lt;T extends { entityId: string }&gt;(
  topic: string,
  type: string,
  data: T,
  correlationId: string,
  causationId?: string
): Promise&lt;void&gt; {
  const event: EventEnvelope&lt;T&gt; = {
    eventId: crypto.randomUUID(),
    eventType: type,
    correlationId,
    causationId: causationId ?? correlationId,
    timestamp: new Date().toISOString(),
    source: SERVICE_NAME,
    data,
  };

  await producer.send({
    topic,
    messages: [{ key: data.entityId, value: JSON.stringify(event) }],
  });
}
</code></pre>
<p>When investigating an issue, you search for the correlation ID across all services and reconstruct the full event chain. Without it, you're searching for a needle in a haystack.</p>
<h3 id="heading-5-operational-complexity">5. Operational Complexity</h3>
<table>
<thead>
<tr>
<th>Pattern</th>
<th>What You Operate</th>
</tr>
</thead>
<tbody><tr>
<td>REST</td>
<td>HTTP server, load balancer, API gateway</td>
</tr>
<tr>
<td>gRPC</td>
<td>gRPC server, proto registry, code generation pipeline, gRPC-Web proxy (if browser clients exist)</td>
</tr>
<tr>
<td>Event-driven</td>
<td>Message broker (Kafka/RabbitMQ/SQS), schema registry, dead-letter queues, consumer lag monitoring</td>
</tr>
</tbody></table>
<p>REST has the lowest operational overhead. Every team knows how to run an HTTP server.</p>
<p>gRPC adds a build-time dependency (proto compilation) and requires teams to learn new tooling.</p>
<p>Event-driven adds a runtime dependency (the broker) that must be highly available because if the broker goes down, inter-service communication stops.</p>
<h2 id="heading-the-decision-framework">The Decision Framework</h2>
<p>Use this framework when deciding how a specific pair of services should communicate. The answer is rarely one pattern for your entire system.</p>
<pre><code class="language-text">Does the caller need an immediate response?
├── Yes → Is this a public-facing or browser-accessible API?
│         ├── Yes → REST
│         └── No  → Is throughput or latency critical?
│                   ├── Yes → gRPC
│                   └── No  → REST (simpler, good enough)
└── No  → Can the caller tolerate eventual consistency?
          ├── No  → Use synchronous call (REST or gRPC) with async follow-up
          └── Yes → Does the event need to trigger multiple consumers?
                    ├── Yes → Event-driven messaging
                    └── No  → Is ordering critical?
                              ├── Yes → Event-driven with partition key
                              └── No  → Event-driven (or simple queue like SQS)
</code></pre>
<p>Some concrete examples:</p>
<table>
<thead>
<tr>
<th>Interaction</th>
<th>Pattern</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td>Browser fetches product details</td>
<td>REST</td>
<td>Browser can't call gRPC natively, plus REST offers cacheability</td>
</tr>
<tr>
<td>Checkout validates payment in real time</td>
<td>gRPC</td>
<td>Low latency, strong typing, internal-only (no browser in the path)</td>
</tr>
<tr>
<td>Order confirmed triggers fulfillment</td>
<td>Event-driven</td>
<td>Multiple consumers, temporal decoupling</td>
</tr>
<tr>
<td>Frontend fetches user profile</td>
<td>REST</td>
<td>Simple CRUD, cacheable, browser-native</td>
</tr>
<tr>
<td>ML service scores recommendations</td>
<td>gRPC</td>
<td>High throughput, binary payloads, streaming</td>
</tr>
<tr>
<td>User signup triggers welcome email</td>
<td>Event-driven</td>
<td>Async, no need for immediate response</td>
</tr>
<tr>
<td>Service health checks</td>
<td>REST</td>
<td>Simplicity, universal tooling</td>
</tr>
<tr>
<td>Real-time stock level monitoring</td>
<td>gRPC streaming</td>
<td>Continuous updates, bidirectional if needed</td>
</tr>
</tbody></table>
<h2 id="heading-hybrid-architectures-using-all-three">Hybrid Architectures: Using All Three</h2>
<p>Most production systems use a combination. Here's a pattern that works well:</p>
<pre><code class="language-text">┌──────────┐    REST     ┌──────────────┐    gRPC    ┌──────────────┐
│ Browser  │────────────▶│  API Gateway │───────────▶│ Order Service│
└──────────┘             └──────────────┘            └──────┬───────┘
                                                           │
                                                    publishes event
                                                           │
                                                           ▼
                                                    ┌─────────────┐
                                                    │    Kafka     │
                                                    └──────┬──────┘
                                          ┌────────────────┼────────────────┐
                                          ▼                ▼                ▼
                                   ┌────────────┐  ┌────────────┐  ┌────────────┐
                                   │ Inventory  │  │ Notification│  │ Analytics  │
                                   │  Service   │  │  Service    │  │  Service   │
                                   └────────────┘  └────────────┘  └────────────┘
</code></pre>
<ul>
<li><p><strong>REST</strong> at the edge: the browser talks to the API gateway using standard HTTP. Cacheable, debuggable, universally supported.</p>
</li>
<li><p><strong>gRPC</strong> between the gateway and internal services: low latency, strong typing, efficient serialization.</p>
</li>
<li><p><strong>Event-driven</strong> for downstream reactions: the order service publishes an event, and multiple consumers react independently.</p>
</li>
</ul>
<h3 id="heading-the-anti-synchronous-trap">The Anti-Synchronous Trap</h3>
<p>A common mistake is using synchronous calls (REST or gRPC) where events are a better fit. The symptom: a service that makes five synchronous calls during a single request, waiting for each to complete before responding to the caller.</p>
<pre><code class="language-typescript">// Anti-pattern: synchronous fan-out
async function confirmOrder(order: Order): Promise&lt;void&gt; {
  await inventoryService.decrementStock(order.items);    // 50ms
  await paymentService.capturePayment(order.paymentId);  // 200ms
  await notificationService.sendConfirmation(order);     // 100ms
  await analyticsService.recordConversion(order);        // 80ms
  await shippingService.createShipment(order);           // 150ms
  // Total: 580ms, and if any one fails, the order fails
}
</code></pre>
<p>Only the first two calls (inventory and payment) are critical to confirming the order. The rest are reactions that can happen asynchronously:</p>
<pre><code class="language-typescript">// Better: synchronous for critical path, events for reactions
async function confirmOrder(order: Order): Promise&lt;void&gt; {
  // Critical path: must succeed for the order to be valid
  await inventoryService.decrementStock(order.items);
  await paymentService.capturePayment(order.paymentId);

  // Non-critical: publish event, let consumers handle the rest
  await publishOrderConfirmed(order);
  // Total: 250ms, and notification/analytics/shipping failures
  // don't block the checkout
}
</code></pre>
<p>This is the same tiered approach from my <a href="https://abisoye.dev/blog/designing-resilient-apis">designing resilient APIs</a> article. Critical operations are synchronous. Non-critical reactions are event-driven. The caller responds faster, and downstream failures do not cascade.</p>
<h2 id="heading-schema-governance-at-scale">Schema Governance at Scale</h2>
<p>As your service count grows, schema management becomes a first-class concern. Here's a practical approach for each pattern.</p>
<h3 id="heading-rest-openapi-as-the-contract">REST: OpenAPI as the Contract</h3>
<pre><code class="language-yaml"># openapi/inventory-service.yaml
openapi: "3.1.0"
info:
  title: Inventory Service
  version: "1.2.0"
paths:
  /api/v1/products/{productId}/stock:
    get:
      operationId: getStock
      parameters:
        - name: productId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Stock level for the product
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StockResponse"
components:
  schemas:
    StockResponse:
      type: object
      required: [productId, available, reserved]
      properties:
        productId:
          type: string
        available:
          type: integer
        reserved:
          type: integer
</code></pre>
<p>Generate client SDKs from the OpenAPI spec using tools like <code>openapi-typescript</code> or <code>openapi-generator</code>. This gives you type safety without the build-time coupling of gRPC.</p>
<h3 id="heading-grpc-proto-registry">gRPC: Proto Registry</h3>
<p>Store <code>.proto</code> files in a shared repository or a dedicated proto registry (Buf Schema Registry is a good option). Use Buf's breaking change detection in CI:</p>
<pre><code class="language-bash"># Detects breaking changes before they merge
buf breaking --against ".git#branch=main"
</code></pre>
<p>This command requires a <code>buf.yaml</code> configuration file at the root of your proto directory. The file defines your module name and any lint or breaking change rules. See the <a href="https://buf.build/docs/configuration/v2/buf-yaml">Buf documentation</a> for setup details.</p>
<p>This fails your pull request if you rename a field, change a type, or reuse a field number. Non-breaking changes (adding fields, adding services) pass through.</p>
<h3 id="heading-events-schema-registry-with-compatibility-modes">Events: Schema Registry with Compatibility Modes</h3>
<p>For event-driven systems, a schema registry enforces compatibility at publish time. Confluent Schema Registry supports four modes:</p>
<table>
<thead>
<tr>
<th>Mode</th>
<th>Rule</th>
<th>Use Case</th>
</tr>
</thead>
<tbody><tr>
<td><strong>BACKWARD</strong></td>
<td>New schema can read old data</td>
<td>Consumer-first evolution</td>
</tr>
<tr>
<td><strong>FORWARD</strong></td>
<td>Old schema can read new data</td>
<td>Producer-first evolution</td>
</tr>
<tr>
<td><strong>FULL</strong></td>
<td>Both directions</td>
<td>Safest, most restrictive</td>
</tr>
<tr>
<td><strong>NONE</strong></td>
<td>No checks</td>
<td>Development only</td>
</tr>
</tbody></table>
<p>Use <code>FULL</code> compatibility for production topics. It ensures that any consumer, regardless of which schema version it was built against, can read any event on the topic.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this article, you learned the core mechanics of REST, gRPC, and event-driven messaging, the five trade-off dimensions that matter when choosing between them (latency, coupling, schema evolution, debugging, and operational complexity), and a decision framework for matching patterns to specific service interactions.</p>
<p>The key takeaways:</p>
<ol>
<li><p><strong>REST for the edge:</strong> Browser clients, public APIs, simple CRUD. Cacheable, debuggable, universally supported.</p>
</li>
<li><p><strong>gRPC for internal hot paths:</strong> High-throughput service-to-service calls where latency matters and both sides are under your control.</p>
</li>
<li><p><strong>Events for reactions:</strong> When the producer shouldn't wait, when multiple consumers need the same signal, or when temporal decoupling prevents cascading failures.</p>
</li>
<li><p><strong>Use all three:</strong> Most production systems combine patterns. REST at the boundary, gRPC internally, events for async workflows.</p>
</li>
<li><p><strong>Schema governance scales the system:</strong> OpenAPI for REST, proto registries for gRPC, schema registries for events. Without governance, schema changes become the primary source of production incidents.</p>
</li>
</ol>
<p>The right communication pattern isn't a global decision. It's a per-interaction decision, made deliberately, based on which trade-offs you can accept for that specific data flow.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Reusable Architecture for Large Next.js Applications ]]>
                </title>
                <description>
                    <![CDATA[ Every Next.js project starts the same way: you run npx create-next-app, write a few pages, maybe add an API route or two, and things feel clean. Then the project grows. Features multiply. A second app ]]>
                </description>
                <link>https://www.freecodecamp.org/news/reusable-architecture-for-large-nextjs-applications/</link>
                <guid isPermaLink="false">69d00029e466e2b762517489</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ architecture ]]>
                    </category>
                
                    <category>
                        <![CDATA[ monorepo ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Abisoye Alli-Balogun ]]>
                </dc:creator>
                <pubDate>Fri, 03 Apr 2026 18:00:09 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/0a713a19-a418-4954-bfca-9b8bc1e77a03.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every Next.js project starts the same way: you run <code>npx create-next-app</code>, write a few pages, maybe add an API route or two, and things feel clean.</p>
<p>Then the project grows. Features multiply. A second app appears, maybe a separate admin dashboard, a marketing site, or a mobile-facing API. Suddenly, you're copying components between repos, duplicating business logic, arguing over where auth utilities belong, and asking yourself: <em>where did it all go wrong?</em></p>
<p>The answer is almost always architecture, or rather, the absence of one. Not the kind that lives in a Notion doc but the kind baked into your folder structure, your module boundaries, and the tools you reach for at the start of a project (not after it's already broken).</p>
<p>This article is a practical guide to building layered, reusable architecture in Next.js.</p>
<p>You'll learn about the App Router's colocation model, building scalable folder structures around features, sharing logic across apps with Turborepo, drawing clean data-fetching boundaries using Server Components, designing a testing strategy that matches your layer structure, and wiring up a CI/CD pipeline that only builds and tests what actually changed.</p>
<p>By the end, you'll have a blueprint you can actually use, not just admire.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-core-problem-coupling-without-intention">The Core Problem: Coupling Without Intention</a></p>
</li>
<li><p><a href="#heading-layer-1-the-app-router-and-colocation">Layer 1: The App Router and Colocation</a></p>
</li>
<li><p><a href="#heading-layer-2-feature-based-folder-structure">Layer 2: Feature-Based Folder Structure</a></p>
</li>
<li><p><a href="#heading-layer-3-monorepo-with-turborepo-sharing-logic-across-apps">Layer 3: Monorepo with Turborepo (Sharing Logic Across Apps)</a></p>
</li>
<li><p><a href="#heading-layer-4-server-components-and-data-fetching-boundaries">Layer 4: Server Components and Data-Fetching Boundaries</a></p>
</li>
<li><p><a href="#heading-layer-5-testing-strategy-for-a-layered-codebase">Layer 5: Testing Strategy for a Layered Codebase</a></p>
</li>
<li><p><a href="#heading-layer-6-cicd-with-turborepo">Layer 6: CI/CD with Turborepo</a></p>
</li>
<li><p><a href="#heading-putting-it-all-together-the-full-blueprint">Putting It All Together: The Full Blueprint</a></p>
</li>
<li><p><a href="#heading-common-pitfalls-and-how-to-avoid-them">Common Pitfalls and How to Avoid Them</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ul>
<h2 id="heading-the-core-problem-coupling-without-intention">The Core Problem: Coupling Without Intention</h2>
<p>When a component reaches directly into a global store, when a page imports a utility from three directories away, when your auth logic is spread across <code>/lib</code>, <code>/helpers</code>, and <code>/utils</code> with no clear owner, every file knows too much about every other file.</p>
<p>The app still runs. But now changing one thing breaks three others, onboarding takes a week, and adding a second app means copying half the first one.</p>
<p>Layered architecture solves this by giving everything a place, and making those places mean something.</p>
<h2 id="heading-layer-1-the-app-router-and-colocation">Layer 1: The App Router and Colocation</h2>
<p>Next.js 13+ introduced the App Router with a file-system-based routing model that does something subtly powerful: it lets you colocate everything related to a route <em>inside</em> that route's folder.</p>
<p>Before the App Router, pages lived in <code>/pages</code>, components lived in <code>/components</code>, and data fetching was scattered. The App Router flips this. A route segment can now own its layout, its loading and error states, its server actions, and even its local components, all in one place.</p>
<h3 id="heading-what-colocation-actually-means">What Colocation Actually Means</h3>
<p>Consider a <code>/dashboard</code> route. In the App Router model, its folder might look like this:</p>
<pre><code class="language-plaintext">app/
  dashboard/
    page.tsx              # The route entry point
    layout.tsx            # Dashboard-specific shell/navigation
    loading.tsx           # Streaming loading state
    error.tsx             # Error boundary
    components/
      StatsCard.tsx       # Used only within dashboard
      ActivityFeed.tsx
    lib/
      queries.ts          # Data fetching for this route only
      formatters.ts       # Dashboard-specific transforms
</code></pre>
<p>The key insight: <code>StatsCard.tsx</code> and <code>queries.ts</code> don't belong to your whole application, they belong to <code>/dashboard</code>. When you delete or refactor the dashboard, you delete or refactor one folder. Nothing else breaks.</p>
<p>This is colocation. It's not a new idea, but the App Router makes it idiomatic in Next.js for the first time.</p>
<h3 id="heading-the-rule-of-proximity">The Rule of Proximity</h3>
<p>A good heuristic: <em>a file should live as close as possible to where it's used.</em> If it's used in one route, it lives in that route's folder. If it's used by two routes under the same parent segment, it moves up one level. If it's used across the entire app, it belongs in a shared layer (more on that shortly).</p>
<pre><code class="language-plaintext">app/
  (marketing)/          # Route group , no URL segment
    layout.tsx          # Shared layout for marketing pages
    page.tsx
    about/
      page.tsx
  (dashboard)/
    layout.tsx          # Different shell for app routes
    dashboard/
      page.tsx
    settings/
      page.tsx
</code></pre>
<p>Route groups (folders wrapped in parentheses) let you share layouts across segments without polluting the URL. This is a clean way to separate concerns, marketing pages and app pages can have entirely different shells without any URL trickery.</p>
<h2 id="heading-layer-2-feature-based-folder-structure">Layer 2: Feature-Based Folder Structure</h2>
<p>Colocation handles the route level. But large applications have cross-cutting concerns – things that don't belong to any single route but aren't generic utilities either.</p>
<p>This is where most projects fall apart: the <code>/components</code> folder becomes a dumping ground, <code>/lib</code> becomes a junk drawer, and nobody agrees on where <code>useAuth</code> should live.</p>
<p>Feature-based folder structure brings order to this chaos.</p>
<h3 id="heading-organising-by-domain-not-by-file-type">Organising by Domain, Not by File Type</h3>
<p>Instead of grouping files by what they <em>are</em> (components, hooks, utils), group them by what they <em>do</em>.</p>
<pre><code class="language-plaintext">src/
  features/
    auth/
      components/
        LoginForm.tsx
        AuthGuard.tsx
      hooks/
        useAuth.ts
        useSession.ts
      lib/
        tokenStorage.ts
        validators.ts
      types.ts
      index.ts            # Public API , only export what others need

    billing/
      components/
        PricingTable.tsx
        SubscriptionBadge.tsx
      hooks/
        useSubscription.ts
      lib/
        stripe.ts
      types.ts
      index.ts

    notifications/
      ...
</code></pre>
<p>Each feature folder is a self-contained unit. It has its own components, hooks, utilities, and types. Crucially, it has a barrel file (<code>index.ts</code>) that defines its <em>public API</em>, the things other parts of the app are allowed to import.</p>
<h3 id="heading-enforcing-boundaries-with-barrel-exports">Enforcing Boundaries with Barrel Exports</h3>
<p>The <code>index.ts</code> is not optional. It's the mechanism that prevents features from becoming entangled.</p>
<pre><code class="language-typescript">// features/auth/index.ts
export { LoginForm } from './components/LoginForm';
export { AuthGuard } from './components/AuthGuard';
export { useAuth } from './hooks/useAuth';
export type { AuthUser, AuthState } from './types';

// NOT exported, internal implementation detail:
// tokenStorage.ts, validators.ts
</code></pre>
<p>Now, the rest of your app imports from <code>@/features/auth</code>, never from <code>@/features/auth/lib/tokenStorage</code>. If you refactor how tokens are stored internally, nothing outside the feature breaks. This is the essence of encapsulation, not just as a theoretical principle, but as a structural one enforced by your folder layout.</p>
<h3 id="heading-shared-vs-feature">Shared vs. Feature</h3>
<p>Not everything belongs in a feature. Truly generic utilities: a <code>cn()</code> classname helper, a date formatter, or a base HTTP client, for example, belong in a shared layer:</p>
<pre><code class="language-plaintext">src/
  shared/
    components/
      Button.tsx
      Modal.tsx
      Spinner.tsx
    hooks/
      useDebounce.ts
      useMediaQuery.ts
    lib/
      http.ts
      dates.ts
    ui/              # shadcn/ui or design system components
</code></pre>
<p>The rule: <code>shared/</code> has zero knowledge of any feature. Features can import from <code>shared/</code>. <code>shared/</code> never imports from a feature.</p>
<h2 id="heading-layer-3-monorepo-with-turborepo-sharing-logic-across-apps">Layer 3: Monorepo with Turborepo (Sharing Logic Across Apps)</h2>
<p>Single-repo architecture gets you far, but most teams eventually end up with multiple apps: a customer-facing Next.js app, an admin panel, a separate marketing site, maybe a set of API services.</p>
<p>The question becomes: <em>how do you share code between them without copy-pasting?</em></p>
<p>The answer is a monorepo with shared packages, and Turborepo is currently the best tool for Next.js teams doing this.</p>
<h3 id="heading-the-monorepo-shape">The Monorepo Shape</h3>
<p>A well-structured Turborepo looks like this:</p>
<pre><code class="language-plaintext">my-platform/
  apps/
    web/              # Customer-facing Next.js app
    admin/            # Internal admin panel (also Next.js)
    marketing/        # Marketing site
  packages/
    ui/               # Shared component library
    config/           # Shared ESLint, TypeScript, Tailwind configs
    auth/             # Shared auth utilities and types
    database/         # Prisma client + query helpers
    utils/            # Generic utilities
  turbo.json
  package.json        # Root workspace config
</code></pre>
<p><code>apps/</code> contains deployable applications. <code>packages/</code> contains shared code that apps depend on. Neither app imports directly from the other, all sharing flows through <code>packages/</code>.</p>
<h3 id="heading-setting-up-a-shared-package">Setting Up a Shared Package</h3>
<p>A package is just a folder with a <code>package.json</code> that other workspace members can depend on.</p>
<pre><code class="language-json">// packages/ui/package.json
{
  "name": "@my-platform/ui",
  "version": "0.0.1",
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "exports": {
    ".": "./src/index.ts"
  }
}
</code></pre>
<pre><code class="language-typescript">// packages/ui/src/index.ts
export { Button } from './Button';
export { Modal } from './Modal';
export { Card } from './Card';
</code></pre>
<p>Now your apps consume it like any npm package:</p>
<pre><code class="language-json">// apps/web/package.json
{
  "dependencies": {
    "@my-platform/ui": "*"
  }
}
</code></pre>
<pre><code class="language-tsx">// apps/web/app/dashboard/page.tsx
import { Card, Button } from '@my-platform/ui';
</code></pre>
<p>Change <code>Card</code> once in <code>packages/ui</code>, and every app that uses it gets the update, no copy-pasting, no drift.</p>
<p><strong>Important:</strong> Because the package points directly at TypeScript source files (not compiled output), each consuming Next.js app must tell the bundler to transpile it. Add this to your Next.js config:</p>
<pre><code class="language-ts">// apps/web/next.config.ts
const config: import('next').NextConfig = {
  transpilePackages: ['@my-platform/ui', '@my-platform/auth', '@my-platform/utils'],
};

export default config;
</code></pre>
<p>Without this, the build fails with syntax errors, Next.js doesn't transpile packages from <code>node_modules</code> or workspace dependencies by default. The alternative is compiling each package to <code>dist/</code> and pointing <code>exports</code> there, but that adds a build step to every package and slows down the dev feedback loop. For internal monorepo packages, <code>transpilePackages</code> is the simpler tradeoff.</p>
<h3 id="heading-the-turbojson-pipeline">The <code>turbo.json</code> Pipeline</h3>
<p>Turborepo's real power is its build pipeline. It understands the dependency graph between your packages and apps, caches build outputs, and runs tasks in parallel where possible.</p>
<pre><code class="language-json">// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**"]
    },
    "lint": {
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "type-check": {
      "dependsOn": ["^build"],
      "outputs": []
    }
  }
}
</code></pre>
<p>The <code>^build</code> syntax means: <em>before building this package, build all its dependencies first.</em> So if <code>apps/web</code> depends on <code>packages/ui</code>, Turborepo ensures <code>packages/ui</code> is built before <code>apps/web</code> starts. Remote caching means if <code>packages/ui</code> hasn't changed, Turborepo skips rebuilding it entirely, even across CI runs and team members' machines.</p>
<h3 id="heading-what-goes-in-a-package-vs-an-app">What Goes in a Package vs. an App</h3>
<p>A useful litmus test:</p>
<table>
<thead>
<tr>
<th>Lives in <code>packages/</code></th>
<th>Lives in <code>apps/</code></th>
</tr>
</thead>
<tbody><tr>
<td>Design system / UI primitives</td>
<td>Route definitions</td>
</tr>
<tr>
<td>Auth utilities and types</td>
<td>App-specific layouts</td>
</tr>
<tr>
<td>Database client and queries</td>
<td>Feature-specific pages</td>
</tr>
<tr>
<td>Shared TypeScript configs</td>
<td>API route handlers</td>
</tr>
<tr>
<td>Analytics abstractions</td>
<td>Environment-specific config</td>
</tr>
<tr>
<td>Generic hooks (useDebounce)</td>
<td>App-specific business logic</td>
</tr>
</tbody></table>
<p>If two apps need the same logic, it goes in a package. If only one app needs it, it stays in that app, even if you <em>think</em> the other app might need it someday. Premature abstraction is just as damaging as none at all.</p>
<h2 id="heading-layer-4-server-components-and-data-fetching-boundaries">Layer 4: Server Components and Data-Fetching Boundaries</h2>
<p>The App Router's Server Components model is arguably the most architecturally significant change Next.js has ever shipped, and also the most misunderstood.</p>
<p>Most developers approach it as a performance optimisation. It is that, but it's more importantly an <em>architectural boundary</em>. Understanding where that boundary sits, and designing around it deliberately, is what separates scalable App Router codebases from ones that fight the framework.</p>
<h3 id="heading-the-mental-model-two-worlds">The Mental Model: Two Worlds</h3>
<p>Every component in the App Router lives in one of two worlds:</p>
<p><strong>Server Components</strong> (default) run exclusively on the server. They can <code>await</code> data directly, access databases, read environment variables, and reduce the JavaScript sent to the browser. They can't use browser APIs, <code>useState</code>, <code>useEffect</code>, or event handlers.</p>
<p><strong>Client Components</strong> (<code>'use client'</code>) run in the browser (and also during SSR/hydration). They can use hooks, handle events, and access browser APIs. They can't directly <code>await</code> server-side resources.</p>
<p>The directive <code>'use client'</code> doesn't mean <em>"this runs only in the browser"</em> , it means <em>"this is the boundary where the server-to-client handoff begins."</em> Any module <em>imported</em> by a Client Component becomes part of the client bundle.</p>
<p>But Server Components <em>passed as props</em> (typically via <code>children</code>) retain their server-only nature, they're rendered on the server and streamed as HTML, not included in the client bundle. This distinction is what makes the composition pattern below work.</p>
<h3 id="heading-designing-the-boundary">Designing the Boundary</h3>
<p>The goal is to push the <code>'use client'</code> boundary as far down the tree as possible, keeping data fetching and heavy logic on the server, and reserving Client Components for genuinely interactive leaves.</p>
<p>A pattern that works well in practice:</p>
<pre><code class="language-tsx">// app/dashboard/page.tsx , Server Component
// Fetches data, no 'use client' directive needed

import { getMetrics } from '@/features/analytics/lib/queries';
import { MetricsDashboard } from './components/MetricsDashboard';

export default async function DashboardPage() {
  const metrics = await getMetrics();   // Direct DB call , no API round-trip
  return &lt;MetricsDashboard data={metrics} /&gt;;
}
</code></pre>
<pre><code class="language-tsx">// app/dashboard/components/MetricsDashboard.tsx , Server Component
// Composes layout, delegates interactivity to leaves

import { StatsCard } from './StatsCard';
import { ChartSection } from './ChartSection';

export function MetricsDashboard({ data }) {
  return (
    &lt;div className="grid gap-6"&gt;
      &lt;StatsCard value={data.revenue} label="Revenue" /&gt;
      &lt;ChartSection points={data.trend} /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<pre><code class="language-tsx">// app/dashboard/components/ChartSection.tsx , Client Component
// Interactive chart needs browser APIs

'use client';

import { useState } from 'react';
import { LineChart, RangeSelector } from '@my-platform/ui';

export function ChartSection({ points }) {
  const [range, setRange] = useState('7d');
  return (
    &lt;div&gt;
      &lt;RangeSelector value={range} onChange={setRange} /&gt;
      &lt;LineChart data={points.filter(/* range logic */)} /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>The data flows from server to client in one direction. The server does the expensive work (database query), passes serialisable data down as props, and the client receives a ready-to-render dataset – no loading spinners, no client-side fetch waterfalls.</p>
<h3 id="heading-colocating-data-fetching-with-routes">Colocating Data Fetching with Routes</h3>
<p>A powerful pattern enabled by Server Components is colocating data fetching directly with the route that needs it, eliminating the need for global state management in many cases.</p>
<pre><code class="language-plaintext">app/
  orders/
    page.tsx              # await getOrders() , renders list
    [id]/
      page.tsx            # await getOrder(id) , renders single order
      loading.tsx         # Streaming skeleton while awaiting
      components/
        OrderTimeline.tsx  # Server Component , renders timeline data
        CancelButton.tsx  # 'use client' , needs click handler
</code></pre>
<p>Each page fetches its own data, scoped to what it needs. Nested layouts and pages can fetch concurrently when using <code>Promise.all</code> or parallel route segments. And <code>loading.tsx</code> gives you streaming suspense boundaries without writing a single <code>&lt;Suspense&gt;</code> wrapper manually.</p>
<h3 id="heading-when-to-use-a-fetch-layer-vs-direct-queries">When to Use a Fetch Layer vs. Direct Queries</h3>
<p>As apps scale, you'll want a consistent approach to data access. A practical pattern:</p>
<pre><code class="language-typescript">// packages/database/src/queries/orders.ts
// Runs on the server , can be imported in any Server Component

import { db } from '../client';

export async function getOrdersByUser(userId: string) {
  return db.order.findMany({
    where: { userId },
    include: { items: true },
    orderBy: { createdAt: 'desc' },
  });
}
</code></pre>
<pre><code class="language-typescript">// packages/database/src/index.ts
export { getOrdersByUser } from './queries/orders';
export { getProductById } from './queries/products';
// ...
</code></pre>
<p>Your Server Components import from <code>@my-platform/database</code>. Your Client Components never touch this package: they call API routes or Server Actions if they need to mutate data. This keeps the boundary clean and auditable.</p>
<h3 id="heading-server-actions-for-mutations">Server Actions for Mutations</h3>
<p>Data fetching flows through Server Components, but mutations need their own boundary. Server Actions (<code>'use server'</code>) let you define server-side functions that Client Components can call directly – no API route boilerplate needed.</p>
<pre><code class="language-typescript">// app/orders/[id]/actions.ts
'use server';

import { db } from '@my-platform/database';
import { revalidatePath } from 'next/cache';

export async function cancelOrder(orderId: string) {
  await db.order.update({
    where: { id: orderId },
    data: { status: 'cancelled', cancelledAt: new Date() },
  });

  revalidatePath(`/orders/${orderId}`);
}
</code></pre>
<pre><code class="language-tsx">// app/orders/[id]/components/CancelButton.tsx
'use client';

import { cancelOrder } from '../actions';
import { useTransition } from 'react';

export function CancelButton({ orderId }: { orderId: string }) {
  const [isPending, startTransition] = useTransition();

  return (
    &lt;button
      disabled={isPending}
      onClick={() =&gt; startTransition(() =&gt; cancelOrder(orderId))}
    &gt;
      {isPending ? 'Cancelling...' : 'Cancel Order'}
    &lt;/button&gt;
  );
}
</code></pre>
<p>The architectural decision:</p>
<ul>
<li><p><strong>use Server Actions for mutations that are colocated with a specific route</strong> (cancelling an order, updating a profile).</p>
</li>
<li><p><strong>Use API routes for mutations that are consumed by external clients</strong> (webhooks, mobile apps, third-party integrations).</p>
</li>
</ul>
<p>Server Actions keep mutation logic close to the UI that triggers it. API routes provide a stable contract for external consumers.</p>
<p>This completes the data flow picture: Server Components handle reads, Server Actions handle writes, and Client Components are the interactive surface that connects them.</p>
<h2 id="heading-layer-5-testing-strategy-for-a-layered-codebase">Layer 5: Testing Strategy for a Layered Codebase</h2>
<p>The testing pyramid is one of those concepts that sounds obvious in theory but falls apart in practice, usually because the codebase doesn't have clear boundaries to test against. When everything is tangled, every test becomes an integration test by accident.</p>
<p>The layered architecture you've built changes this: each layer has a defined surface area, so you can test each one at the right level of abstraction.</p>
<h3 id="heading-test-each-layer-at-the-right-granularity">Test Each Layer at the Right Granularity</h3>
<p>The layered architecture maps naturally onto the testing pyramid:</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Test Type</th>
<th>Tools</th>
</tr>
</thead>
<tbody><tr>
<td><code>packages/</code> (utils, db queries)</td>
<td>Unit tests</td>
<td>Vitest</td>
</tr>
<tr>
<td><code>features/</code> (hooks, lib, components)</td>
<td>Unit + Integration</td>
<td>Vitest + React Testing Library</td>
</tr>
<tr>
<td>App Router pages (Server Components)</td>
<td>Integration</td>
<td>Vitest + custom render</td>
</tr>
<tr>
<td>Critical user flows (checkout, auth)</td>
<td>End-to-end</td>
<td>Playwright</td>
</tr>
</tbody></table>
<p>The goal: test shared packages exhaustively, test features thoroughly, test pages for integration correctness, and use E2E only for the flows that matter most.</p>
<p>Not everything needs an E2E test, and treating E2E as the default testing strategy is one of the most expensive mistakes a team can make.</p>
<h3 id="heading-unit-testing-shared-packages">Unit Testing Shared Packages</h3>
<p>Packages in <code>packages/</code> are the easiest to test. They're pure TypeScript with no framework coupling. Use Vitest:</p>
<pre><code class="language-typescript">// packages/utils/src/dates.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { formatRelativeDate } from './dates';

describe('formatRelativeDate', () =&gt; {
  beforeEach(() =&gt; {
    // Pin the clock to avoid flaky results near midnight
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-03-15T12:00:00Z'));
  });

  afterEach(() =&gt; {
    vi.useRealTimers();
  });

  it('returns "today" for dates within the current day', () =&gt; {
    expect(formatRelativeDate(new Date())).toBe('today');
  });

  it('returns "yesterday" for dates on the previous day', () =&gt; {
    const yesterday = new Date('2026-03-14T15:00:00Z');
    expect(formatRelativeDate(yesterday)).toBe('yesterday');
  });
});
</code></pre>
<p>Keep package tests colocated with the source file. A <code>dates.ts</code> file has a <code>dates.test.ts</code> sibling. No separate <code>__tests__</code> folders, those are relics of less structured codebases.</p>
<h3 id="heading-testing-feature-modules">Testing Feature Modules</h3>
<p>Features are where most of your business logic lives, so they get the most test coverage. The key rule: test the public API of the feature, not its internals.</p>
<pre><code class="language-typescript">// features/auth/hooks/useAuth.test.ts
import { renderHook, act } from '@testing-library/react';
import { useAuth } from '../hooks/useAuth';
import { createWrapper } from '@/test/utils'; // your test provider wrapper

describe('useAuth', () =&gt; {
  it('returns authenticated state when session exists', async () =&gt; {
    const { result } = renderHook(() =&gt; useAuth(), {
      wrapper: createWrapper({ session: mockSession }),
    });

    expect(result.current.isAuthenticated).toBe(true);
    expect(result.current.user.email).toBe(mockSession.user.email);
  });

  it('redirects to login when session is null', async () =&gt; {
    const { result } = renderHook(() =&gt; useAuth(), {
      wrapper: createWrapper({ session: null }),
    });

    expect(result.current.isAuthenticated).toBe(false);
  });
});
</code></pre>
<p>Notice that the test imports from the hook directly, not from the feature's <code>index.ts</code> barrel. Feature index exports are public APIs. They're tested through integration tests that consume the feature as a whole. Internal hooks and utilities are tested at the unit level. Both are valid, and the distinction is intentional.</p>
<h3 id="heading-testing-server-components">Testing Server Components</h3>
<p>Server Components are async functions that return JSX. Testing them directly is still an evolving story. React's test renderer doesn't natively handle async components, and calling <code>await DashboardPage()</code> then passing the result to <code>render()</code> produces subtle issues (missing context, <code>act()</code> warnings, or outright failures depending on your setup).</p>
<p>The most reliable approach today is to <strong>test the layers separately</strong>: mock the data layer to verify it's called correctly, and test the presentational component with static props.</p>
<pre><code class="language-typescript">// app/dashboard/components/MetricsDashboard.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MetricsDashboard } from './MetricsDashboard';

describe('MetricsDashboard', () =&gt; {
  it('renders revenue metric from provided data', () =&gt; {
    render(
      &lt;MetricsDashboard data={{ revenue: 84200, trend: [] }} /&gt;
    );

    expect(screen.getByText('£84,200')).toBeInTheDocument();
  });
});
</code></pre>
<pre><code class="language-typescript">// features/analytics/lib/queries.test.ts
import { describe, it, expect } from 'vitest';
import { getMetrics } from './queries';

describe('getMetrics', () =&gt; {
  it('returns revenue and trend data', async () =&gt; {
    const metrics = await getMetrics();

    expect(metrics.revenue).toBeGreaterThan(0);
    expect(Array.isArray(metrics.trend)).toBe(true);
  });
});
</code></pre>
<p>The key insight: mock at the data layer boundary, not at the database or network layer. The data query has its own tests in <code>packages/database</code>. The presentational component has its own tests with static props. The Server Component page wires them together, and that wiring is verified by your E2E tests, which are better suited to catching integration issues across the async boundary.</p>
<h3 id="heading-end-to-end-tests-with-playwright">End-to-End Tests with Playwright</h3>
<p>Reserve Playwright for the flows that touch multiple layers and where a breakage would be catastrophic: authentication, checkout, and form submission with side effects. Don't use it for visual regressions or static content, as that's expensive and slow.</p>
<pre><code class="language-typescript">// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test('user can log in and reach dashboard', async ({ page }) =&gt; {
  await page.goto('/login');
  await page.fill('[name="email"]', 'test@example.com');
  await page.fill('[name="password"]', 'password123');
  await page.click('button[type="submit"]');

  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
</code></pre>
<p>Colocate E2E tests in a top-level <code>e2e/</code> folder at the monorepo root. They span apps and don't belong inside any single app's directory.</p>
<h3 id="heading-configuring-vitest-across-the-monorepo">Configuring Vitest Across the Monorepo</h3>
<p>Each package and app has its own <code>vitest.config.ts</code>, but they can share a base config via a shared package:</p>
<pre><code class="language-typescript">// packages/config/vitest.base.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
    },
  },
});
</code></pre>
<pre><code class="language-typescript">// apps/web/vitest.config.ts
import { mergeConfig } from 'vitest/config';
import base from '@my-platform/config/vitest.base';

export default mergeConfig(base, {
  test: {
    include: ['src/**/*.test.{ts,tsx}', 'app/**/*.test.{ts,tsx}'],
  },
});
</code></pre>
<p>This ensures consistent test configuration across every app and package without duplication.</p>
<h2 id="heading-layer-6-cicd-with-turborepo">Layer 6: CI/CD with Turborepo</h2>
<p>A well-designed monorepo without a smart CI pipeline is just a big repo. Turborepo's real power emerges in CI, where it can cut build and test times dramatically through caching and intelligent task scheduling.</p>
<h3 id="heading-the-core-insight-only-run-what-changed">The Core Insight: Only Run What Changed</h3>
<p>Traditional CI pipelines run everything on every commit. In a monorepo, this means running tests for <code>apps/admin</code> when you only changed a utility in <code>apps/web</code>. Turborepo's dependency graph awareness eliminates this.</p>
<p>When you run <code>turbo test</code>, Turborepo:</p>
<ol>
<li><p>Builds the dependency graph from your <code>package.json</code> files</p>
</li>
<li><p>Checks which packages have changed (against the last cached state)</p>
</li>
<li><p>Runs tests only for changed packages and their dependents</p>
</li>
<li><p>Caches results. If nothing changed, it restores from cache instantly.</p>
</li>
</ol>
<p>A change to <code>packages/ui</code> triggers tests for <code>packages/ui</code>, <code>apps/web</code>, and <code>apps/admin</code> (since both depend on it). A change only to <code>apps/web</code> triggers tests for <code>apps/web</code> only.</p>
<h3 id="heading-remote-caching">Remote Caching</h3>
<p>Without remote caching, Turborepo's local cache doesn't help in CI – each run starts fresh. With remote caching, build and test artifacts are stored in the cloud and shared across all CI runners and developers' machines.</p>
<pre><code class="language-bash"># Authenticate with Turborepo remote cache (Vercel)
npx turbo login
npx turbo link
</code></pre>
<p>Or use a self-hosted cache server if you need to keep artifacts on your own infrastructure. Once configured, a CI run on a branch that touched only <code>apps/web</code> might take 45 seconds instead of 8 minutes, because every <code>packages/*</code> task restores from cache.</p>
<h3 id="heading-a-production-ready-github-actions-pipeline">A Production-Ready GitHub Actions Pipeline</h3>
<p>Here's a complete pipeline that uses Turborepo's caching, runs affected tasks only, and splits lint, test, and build into parallel jobs:</p>
<pre><code class="language-yaml"># .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ secrets.TURBO_TEAM }}

jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx turbo lint --filter="...[origin/main]"

  test:
    name: Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx turbo test --filter="...[origin/main]"

  build:
    name: Build
    runs-on: ubuntu-latest
    needs: [lint, test]
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx turbo build --filter="...[origin/main]"

  e2e:
    name: E2E Tests
    runs-on: ubuntu-latest
    needs: [build]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx playwright install --with-deps

      - name: Build the app (restores from Turborepo cache if unchanged)
        run: npx turbo build --filter="apps/web"

      - name: Run E2E tests
        run: npx turbo e2e
</code></pre>
<p>The E2E job assumes Playwright's <code>webServer</code> config handles starting the app automatically. Configure this in your <code>playwright.config.ts</code>:</p>
<pre><code class="language-typescript">// playwright.config.ts
export default defineConfig({
  webServer: {
    command: 'npm run start --prefix apps/web',
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
});
</code></pre>
<p>This way Playwright starts the production server before tests run and tears it down afterwards – no manual server management in CI.</p>
<p>The <code>--filter="...[origin/main]"</code> flag is the critical piece. It tells Turborepo to run tasks only for packages that have changed since the <code>main</code> branch, plus all packages that depend on those changed packages. This is the most impactful optimisation in the whole pipeline.</p>
<h3 id="heading-filtering-strategies">Filtering Strategies</h3>
<p>Turborepo's <code>--filter</code> flag is flexible and worth understanding:</p>
<pre><code class="language-bash"># Only run tasks for packages that changed vs main
turbo test --filter="...[origin/main]"

# Run tasks for a specific app and all its dependencies
turbo build --filter="apps/web..."

# Run tasks for everything except a specific app
turbo test --filter="!apps/admin"

# Run tasks for all apps (not packages)
turbo build --filter="./apps/*"
</code></pre>
<p>For most CI pipelines, <code>--filter="...[origin/main]"</code> on feature branches and <code>turbo run test build</code> (no filter) on <code>main</code> merges is the right split. You want fast feedback on PRs and confidence that everything still works on main.</p>
<h3 id="heading-deployment-pipeline-with-per-app-filtering">Deployment Pipeline with Per-App Filtering</h3>
<p>When deploying to Vercel, Netlify, or any platform with per-app deployments, Turborepo lets you detect which apps actually changed and skip deployments for unchanged ones:</p>
<pre><code class="language-yaml"># .github/workflows/deploy.yml
- name: Check if web app changed
  id: check-web
  run: |
    CHANGED=$(npx turbo run build --filter="apps/web...[origin/main]" --dry=json | jq '.packages | length')
    echo "changed=\(CHANGED" &gt;&gt; \)GITHUB_OUTPUT

- name: Deploy web
  if: steps.check-web.outputs.changed != '0'
  run: vercel deploy --prod
  env:
    VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
</code></pre>
<p>This ensures your admin app doesn't trigger a deployment when only the marketing site changed, reducing deploy times, costs, and the blast radius of any deployment failure.</p>
<h3 id="heading-environment-variable-management">Environment Variable Management</h3>
<p>One of the trickier parts of a monorepo CI setup is environment variables: each app needs its own secrets, but some are shared across apps.</p>
<p>A clean convention:</p>
<pre><code class="language-plaintext"># .env (repo root , shared across all apps in local dev)
DATABASE_URL=...
REDIS_URL=...

# apps/web/.env.local (web-specific overrides)
NEXT_PUBLIC_APP_URL=https://app.example.com
STRIPE_KEY=...

# apps/admin/.env.local (admin-specific)
NEXT_PUBLIC_APP_URL=https://admin.example.com
ADMIN_SECRET=...
</code></pre>
<p>In CI, store shared secrets as organisation-level GitHub secrets and app-specific secrets as repository-level secrets scoped to the appropriate environment.</p>
<p>Never store secrets in <code>turbo.json</code> or any committed file. Instead, use <code>env</code> in your pipeline steps and Turborepo's <code>globalEnv</code> field in <code>turbo.json</code> to declare which env vars should bust the cache when they change:</p>
<pre><code class="language-json">// turbo.json
{
  "globalEnv": ["NODE_ENV", "DATABASE_URL"],
  "tasks": {
    "build": {
      "env": ["NEXT_PUBLIC_APP_URL", "STRIPE_KEY"],
      "dependsOn": ["^build"],
      "outputs": [".next/**"]
    }
  }
}
</code></pre>
<p>This tells Turborepo: if <code>DATABASE_URL</code> changes, invalidate the cache for all tasks. If <code>NEXT_PUBLIC_APP_URL</code> changes, only invalidate the <code>build</code> task. Without this, you risk Turborepo restoring a cached build that was compiled against a different environment, a subtle and painful bug.</p>
<h2 id="heading-putting-it-all-together-the-full-blueprint">Putting It All Together: The Full Blueprint</h2>
<p>Here's what the complete architecture looks like assembled:</p>
<pre><code class="language-plaintext">my-platform/
  apps/
    web/
      app/
        (marketing)/
          layout.tsx
          page.tsx
          about/page.tsx
        (app)/
          layout.tsx            # Auth-protected shell
          dashboard/
            page.tsx            # Server Component , fetches data
            loading.tsx
            components/
              MetricsDashboard.tsx
              ChartSection.tsx  # 'use client'
          orders/
            page.tsx
            [id]/
              page.tsx
              components/
                OrderTimeline.tsx
                CancelButton.tsx  # 'use client'
      src/
        features/
          auth/
            components/
            hooks/
            lib/
            index.ts
          billing/
            ...
        shared/
          components/
          hooks/
          lib/
    admin/
      app/
        ...                     # Same layer structure
      src/
        features/
          ...
  packages/
    ui/                         # Shared primitives
    auth/                       # Shared auth logic
    database/                   # Prisma + queries
    config/                     # ESLint, TS, Tailwind configs
    utils/                      # Generic helpers
  turbo.json
  package.json
</code></pre>
<p>Notice how the <code>'use client'</code> boundary appears only at the interactive leaves: <code>ChartSection.tsx</code> needs <code>useState</code>, and <code>CancelButton.tsx</code> needs a click handler and <code>useTransition</code>. Everything above them (<code>MetricsDashboard.tsx</code>, <code>OrderTimeline.tsx</code>, the page components) stays on the server, fetching data and composing layout without shipping any JavaScript to the browser.</p>
<p>The layers stack cleanly:</p>
<ol>
<li><p><strong>Turborepo packages</strong>: the lowest layer. Generic, reusable, no app-specific knowledge.</p>
</li>
<li><p><strong>Shared feature layer</strong>: cross-cutting app concerns. Can consume packages, knows nothing of routes.</p>
</li>
<li><p><strong>Feature modules</strong>: domain logic, encapsulated behind barrel exports.</p>
</li>
<li><p><strong>App Router</strong>: routes, layouts, colocation. Consumes features and packages. Data flows through Server Components, interactivity is delegated to Client Component leaves.</p>
</li>
</ol>
<h2 id="heading-common-pitfalls-and-how-to-avoid-them">Common Pitfalls and How to Avoid Them</h2>
<p><strong>"I'll just put it in</strong> <code>/utils</code> <strong>for now."</strong> This is how junk drawers form. If you can't name what a utility belongs to, it probably needs a new feature folder, not a generic dumping ground.</p>
<p><strong>Over-extracting packages too early</strong>: Not everything needs to be a shared package. Start in the app, extract to a package only when a second consumer appears. The cost of premature abstraction is maintenance overhead and false coupling.</p>
<p><strong>Client Components at the top of every tree</strong>: If your route's <code>page.tsx</code> has <code>'use client'</code> at the top, you've lost most of what Server Components give you. Push the directive down to the interactive leaf.</p>
<p><strong>Circular package dependencies</strong>: If <code>packages/auth</code> imports from <code>packages/database</code> and <code>packages/database</code> imports from <code>packages/auth</code>, you have a cycle. Keep the dependency graph a DAG: each package should have one clear level of abstraction.</p>
<p><strong>Barrel files that export everything</strong>: The barrel file is a public API, not an index of every file in the folder. Export only what other parts of the app are meant to use.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>Good architecture isn't about finding the perfect structure, it's about making the right decisions easy and the wrong decisions hard.</p>
<ul>
<li><p><strong>Colocation</strong> makes it easy to find what you need.</p>
</li>
<li><p><strong>Feature modules</strong> make it hard to accidentally couple unrelated domains.</p>
</li>
<li><p><strong>Turborepo</strong> makes it easy to share code and hard to duplicate it.</p>
</li>
<li><p><strong>Server Components</strong> make it easy to fetch data where you need it and hard to send unnecessary JavaScript to the browser.</p>
</li>
</ul>
<p>None of these ideas are new. Layered architecture, separation of concerns, and encapsulation are decades-old principles. What Next.js and Turborepo give you is a modern toolkit to express them idiomatically in a JavaScript codebase.</p>
<p>The best time to set this up is at the start of a project. The second best time is now, before the next feature makes untangling things twice as hard.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
