DEV Community

Cover image for Frontend Architecture: Where Does This File Go?
Manuj Sankrit
Manuj Sankrit

Posted on

Frontend Architecture: Where Does This File Go?

The PR That Started A Twenty-Comment War

The code was fine. Logic was correct. TypeScript was happy. Tests passed.

And then the review comment landed:

"Where does this actually belong?"

That was it. No suggested fix, no alternative. Just that one question hanging in the thread like a grenade with the pin pulled.

Within the hour, five developers were in the comments. Someone said it was a service. Someone else said it was clearly a utility. A third person floated the idea of a new folder entirely. The original author — who had been quietly watching this unfold — just wanted to transform an API response into a format the UI could consume. Six lines of code. Twenty comments of architectural debate.

The PR merged eventually. The file went into utils/ because everyone got tired. Six months later that utils/ folder had 61 files, zero organization, and a reputation among the team as the place where code goes to retire.

I have watched this happen more than once. The problem was never the code. It was that nobody agreed — explicitly, structurally — on where things belong.

That is what this post is about.


The Real Problem Is Not The Framework

When frontend projects start, the structure feels obvious. You have components. Maybe a hooks folder. A utils folder. An api folder. Everything fits.

Then the project grows. Features pile up. The team doubles. And somewhere around month four, a new developer joins and spends forty minutes on day three not writing code — just figuring out where to put a file. They ask a senior. The senior pauses genuinely, thinks, and says: "Honestly just put it in utils for now."

That "for now" is permanent.

The framework is not the problem. React, Next.js, Vue — none of them tell you how to structure your business logic. They tell you how to render. The organizational mess is entirely yours to solve.


Atomic Design — Why It Works And Then Stops Working

Atomic Design gets a bad reputation it does not fully deserve. For pure UI work — design systems, component libraries, isolated presentational components — it is still a solid mental model. Atoms, molecules, organisms, templates, pages. Clean hierarchy. Makes sense on a whiteboard.

The trouble arrives when business logic shows up. And it always shows up.

Suddenly your "molecule" is making an API call. Your "organism" is managing auth state. Your "page" is 900 lines long because it is doing everything — fetching, transforming, rendering, and handling three different error states. The atomic hierarchy describes visual composition but says nothing about logical responsibility. Those are different things, and mixing them is where the chaos begins.


Three Architectures, One Idea

Here is something the internet does not say clearly enough: Onion Architecture, Clean Architecture, and Hexagonal Architecture are the same idea with different diagrams.

Uncle Bob drew circles. Jeffrey Palermo drew different circles. Alistair Cockburn drew a hexagon. The underlying principle is identical across all three:

Business logic at the center. Everything else on the outside. Dependencies always point inward — never outward.

That is it. That is the whole secret. If your domain logic — the rules that make your product actually work — does not import React, does not import Axios, does not know whether it is talking to a REST API or GraphQL, you are doing it right. The framework becomes a detail. A replaceable outer layer.

When someone on your team says "we use Clean Architecture" and another team says "we use Onion," and they show you their folder structures — they probably look nearly identical. The argument is about vocabulary, not substance.

So let us stop arguing about the name and talk about what actually matters.


The Layers, Translated To Folders

Here is the mental model mapped to something you can actually create in your src/ directory:

src/
├── domain/          ← The heart. Zero dependencies.
├── application/     ← Orchestration. Talks to domain and infrastructure.
├── infrastructure/  ← The dirty work. APIs, storage, external services.
└── ui/              ← Rendering. As dumb as possible.
Enter fullscreen mode Exit fullscreen mode

domain/ — The Heart

This is your business logic. Pure TypeScript. No React, no Axios, no Next.js. If you can run this folder with node and zero additional installs, you are on the right track.

// domain/models/dealer.ts
export type Dealer = {
  id: string;
  name: string;
  distance: number;
  isOpen: boolean;
};

// domain/logic/sort-dealers.ts
// Pure function. No dependencies. Easily testable.
export const sortByDistance = (dealers: Dealer[]): Dealer[] =>
  [...dealers].sort((a, b) => a.distance - b.distance);
Enter fullscreen mode Exit fullscreen mode

If your domain folder imports anything from ui/ or infrastructure/, that is the dependency rule being violated. That is the thing worth arguing about in PR reviews.

application/ — The Orchestrator

Custom hooks, state management logic, use cases. This layer knows about the domain and coordinates infrastructure. To keep the application layer completely clean, it shouldn't tightly import a concrete infrastructure file directly. Instead, it interacts with data repositories or stores that abstract those implementation details away.

// application/hooks/use-dealers.ts
import { useDealersStore } from '@/application/store/dealers-store';
import { sortByDistance } from '@/domain/logic/sort-dealers';

export const useDealers = (location: { lat: number; lng: number }) => {
  // We read state and pull the loading/error flags from our store orchestrator
  const { dealers, isLoading, error, loadDealers } = useDealersStore();

  // Trigger side-effect via the store's delegation
  useEffect(() => {
    loadDealers(location);
  }, [location, loadDealers]);

  const sorted = dealers ? sortByDistance(dealers) : [];

  return { dealers: sorted, isLoading, error };
};
Enter fullscreen mode Exit fullscreen mode

The component calling this hook does not know whether the data came from REST, GraphQL, or a cached response. That is by design.

infrastructure/ — The Dirty Work

API clients, localStorage adapters, analytics wrappers. This is where implementation details live. This layer is allowed to know about Axios, Apollo, fetch — whatever. It translates the outside world into shapes the domain understands.

// infrastructure/repositories/dealers.ts
import { client } from '@/infrastructure/api/apollo-client';
import { GET_DEALERS } from './queries';
import type { Dealer } from '@/domain/models/dealer';

// A pure async function that handles backend fetching and shape translation
export const fetchDealersFromApi = async (location: {
  lat: number;
  lng: number;
}): Promise<Dealer[]> => {
  const { data } = await client.query({
    query: GET_DEALERS,
    variables: { location },
  });

  // Translate the raw API shape into our predictable domain shape
  return (
    data?.dealers.map((d: any) => ({
      id: d.id,
      name: d.displayName,
      distance: d.distanceKm,
      isOpen: d.operatingStatus === 'OPEN',
    })) ?? []
  );
};
Enter fullscreen mode Exit fullscreen mode

If tomorrow the API switches from GraphQL to REST, only this file changes. The hook in application/ does not know. The component in ui/ does not know. The domain definitely does not know.

ui/ — The Presentation Layer

Components, styles, design system. Their job is to render what they are given and report what the user did. Nothing more.

// ui/components/dealer-card.tsx
import type { Dealer } from '@/domain/models/dealer';

// No API calls. No business logic. Just render.
type DealerCardProps = {
  dealer: Dealer;
  onSelect: (id: string) => void;
};

export const DealerCard = ({ dealer, onSelect }: DealerCardProps) => (
  <div onClick={() => onSelect(dealer.id)}>
    <h3>{dealer.name}</h3>
    <p>{dealer.distance}km away</p>
    {dealer.isOpen && <span>Open now</span>}
  </div>
);
Enter fullscreen mode Exit fullscreen mode

A component that only renders what it receives is a component you can hand off to a designer, test in isolation, or replace entirely without touching anything else.

A Question We All Have — Where Does Zustand or Redux Go?

State management is the one that trips people up the most. It feels like it should live in ui/ because it is React-ish. It also feels like it could be infrastructure/ because it is "outside" the component. Neither is right.

Zustand and Redux live in application/. State management is orchestration — it holds the current runtime truth of our app and coordinates between what infrastructure fetches and what the UI renders. That is exactly the application layer's job.

// application/store/dealers-store.ts
import { create } from 'zustand';
import type { Dealer } from '@/domain/models/dealer';
import { fetchDealersFromApi } from '@/infrastructure/repositories/dealers';
//                                  ↑ infrastructure does the dirty work

type DealersStore = {
  dealers: Dealer[];
  selectedDealer: Dealer | null;
  isLoading: boolean;
  error: string | null;
  setSelectedDealer: (dealer: Dealer) => void;
  loadDealers: (location: { lat: number; lng: number }) => Promise<void>;
};

export const useDealersStore = create<DealersStore>((set) => ({
  dealers: [],
  selectedDealer: null,
  isLoading: false,
  error: null,
  setSelectedDealer: (dealer) => set({ selectedDealer: dealer }),
  loadDealers: async (location) => {
    set({ isLoading: true, error: null });
    try {
      const dealers = await fetchDealersFromApi(location); // clean delegation ✅
      set({ dealers, isLoading: false });
    } catch (err: any) {
      set({ error: err.message || 'Failed to fetch', isLoading: false });
    }
  },
}));
Enter fullscreen mode Exit fullscreen mode

The store orchestrates. The repository fetches. One line of difference, entirely different architecture.

The mental model to keep in mind:

  • Store filesapplication/store/
  • What the store holds → domain types and status flags
  • How the store gets data → delegates to infrastructure/
  • What the store exposes → consumed by ui/ components and application/ hooks

Putting It All Together: The Feature Blueprint

When you map a feature (like a Dealer Locator) cleanly across these layers, your directory architecture becomes a predictable blueprint where every file has a single unmistakable home:

src/features/dealer-locator/
├── domain/
│   ├── models/dealer.ts
│   └── logic/sort-dealers.ts
├── application/
│   ├── store/dealers-store.ts
│   └── hooks/use-dealers.ts
├── infrastructure/
│   └── repositories/dealers.ts
└── ui/
    ├── components/dealer-card.tsx
    └── components/dealer-list.tsx
Enter fullscreen mode Exit fullscreen mode

Where Does This File Go? — The Answer

Back to that PR. The developer wanted to transform an API response into a UI-consumable format.

That is infrastructure. It lives in infrastructure/repositories/ or infrastructure/adapters/. It is the translator between what the API returns and what the domain model expects.

The PR comment could have been: "This looks like an infrastructure concern — can we move it to infrastructure/repositories/?" Twelve words. No twenty-comment thread. No Friday evening utils/ regret.

When your layers are defined, the question answers itself most of the time.


Scaling To Larger Teams

This is where the architecture stops being about aesthetics and starts being about survival.

Boundary enforcement as a contract. When fifty developers work on the same codebase, the domain/ layer becomes a shared contract. Backend switches from REST to GraphQL? Only infrastructure/ changes. A new team joins and builds a mobile view? They consume the same application/ hooks. Nothing breaks.

The delete test. A well-architected feature can be deleted without cascading TypeScript errors across the codebase. If deleting a feature folder turns the entire app red, the boundaries were not real — they were suggestions. The delete test exposes this immediately.

Parallel work without stepping on each other. One developer builds the API adapter in infrastructure/. Another builds the UI in ui/. They agree on the domain/ interface first — the shape of the data — and work completely independently. No blocked PRs, no merge conflicts in business logic, no "waiting for the API to be ready."

⚠️ One thing teams get wrong: they define the layers but skip enforcing the dependency rule. A linting rule using eslint-plugin-import or dependency-cruiser can make violations a CI failure rather than a code review conversation. Automate the rule. Do not rely on everyone remembering it.


When This Is Overkill — Be Honest

A landing page. A small internal tool. A three-week prototype. You do not need this.

If you are one developer, shipping fast, with no plans to scale the team — a flat structure with sensible naming is completely fine. Over-engineering a small project with four layers of abstraction is its own kind of problem.

The honest version of this advice is: reach for this structure when the team grows, not before. The signal is usually that first PR thread where five people argue about where a file goes. That is the moment. Not earlier.


The One Rule That Matters

You can call it Onion. You can call it Clean. You can call it Hexagonal. You can call it "that circle diagram Jeffrey Palermo drew in 2008."

It does not matter.

What matters is one rule, applied consistently:

Dependencies always point inward. Business logic never imports framework code.

If your domain/ folder is clean, the rest of the architecture falls into place. The naming of the outer layers is a conversation. The dependency rule is not.

Next time someone opens a PR and asks "where does this actually belong?" — you will have an answer. And it will take twelve words, not twenty comments.

Pranipat 🙏!

Top comments (0)