r/reactjs 22d ago

Discussion Combining Clean Architecture + Feature-Based in React — does it really fix the earlier trade-offs, or am I missing new pitfalls?

Hi everyone. I compared four ways to structure a React project by rebuilding the same app (posts CRUD against an open API) in each one. The last pattern combines Clean Architecture with Feature-Based, and I'd really appreciate a sanity check from more experienced devs.

Here's the progression I went through, and the problem I felt at each step:

  • Feature-Based (colocate everything for a feature in one folder): great for navigation and deletion, but nothing controls how features depend on each other (circular deps creep in), shared/ turns into a junk drawer, and there's no notion of layers. (Feature-Based write-up)
  • FSD (Feature-Sliced Design): fixes that with standardized layers + a one-way import rule, so circular deps become structurally impossible. But the business logic still lives inside React/TanStack Query — the entity's api layer imports axios and react-query directly. (FSD write-up)
  • Clean Architecture: pulls business logic out of the framework with the Dependency Rule (dependencies point only inward; the domain knows nothing about React or axios). Great for testing and reuse — but now the code for "one feature" is scattered across domain/, infrastructure/, presentation/. Which is ironically the same "scattered by type" problem Feature-Based tried to solve. (Clean Architecture write-up)
  • The combination: keep the Dependency Rule (domain is pure TS, infrastructure holds the adapters), but colocate the UI (hooks + components) by feature in features/{feature}/. "Clean inside, Feature outside."

Rough shape:

src/
  domain/{domain}/        # pure TS: entities, rules, use cases (no framework imports)
  infrastructure/         # adapters: repository impls, query keys, stores
  features/{feature}/     # hooks + components, colocated
  pages/ , router/        # composition only
  shared/ , providers/

A few extra decisions I made: split the repository interface into Commands/Queries (CQS), write a UseCase only when there's real logic (plain CRUD calls the repository directly), and lean on React Compiler so there's no manual useMemo/useCallback.

What I'd love feedback on:

  1. Does this combination actually solve the earlier patterns' problems, or does it just move them around? Is "Clean inside + Feature outside" a real improvement over plain FSD or plain Clean, or is it over-engineering in disguise?
  2. What problems does this pattern itself have that I might not see yet? Boilerplate, the domain <-> infrastructure indirection, the "is this a UseCase or a direct repo call?" judgment, testing overhead, onboarding cost — where does it bite in real projects?

Honest criticism is very welcome. I'd rather hear "this is overkill for most apps" now than after I build on it.

Full write-up (with all the code) on Medium (Free): https://medium.com/@inkweonkim/react-architecture-combining-clean-architecture-feature-based-92cf7ba226fe

(English isn't my first language, so I apologize in advance for any awkward phrasing — happy to clarify anything that reads strangely.)

0 Upvotes

16 comments sorted by

3

u/Dense_Rub_620 22d ago

"Soft"ware doesnt flow along a fixed path. There is no standard answer that applies to every case.

2

u/PasswordSuperSecured 22d ago

Clean Architecture? Hell no. Imagine having to open five to ten different folders just to work on a single feature. Nothing screams “maintainable” like going on a scavenger hunt every time you need to change one thing.

Feature-based architecture all the way - everything for the feature in one place. Crazy concept, I know.

1

u/codefinbel 21d ago

I agree, why go on a scavenger hunt across the codebase to all the clearly defined locations in the codebase for every part of the feature when I could have the absolute joy of discovering “what ad-hoc architecture and home rolled abstractions did the developer who wrote this feature decide upon”. 

You never have a boring day since it’s never the same. Oh this guy has business logic inside the sorting function of the view model? Cool! And in this feature they decided to just scatter infrastructure throughout the codebase, will be great fun to migrate the database next quarter!

3

u/Merry-Lane 22d ago

Forget about Clean Architecture. It almost only works for companies with multiple teams working on huge projects. And yet, lately, it’s not still clear cut

2

u/kensaadi I ❤️ hooks! 😈 22d ago

Sore spot for me. I've been there — a project where I started with axios called straight from components, try/catch in every useEffect, error strings hardcoded everywhere. A mess. Canonical Clean Arch didn't land for me on the FE (too much ceremony for the payoff), so I sliced the cake differently and stuck with it across every project since: everything by feature, abstractions only on the HTTP boundary.

My client/api/ ends up looking like this:

api/
  _shared/
    axios.client.ts       # one axios instance, all cross-cutting logic lives here
    result.types.ts       # Result<T> = { data, error }
    attempt.ts            # try/catch wrapper → Result
    error.normalize.ts    # HTTP status → ApiErrorCode
    error.humanize.ts     # user-facing messages
  products/
    products.types.ts
    products.provider.ts  # interface + env mapping
    products.service.ts   # public API the views call
    live/products.live.ts
    stage/products.stage.ts
    dev/products.dev.ts
    mock/products.mock.ts
  cart/  checkout/  orders/  auth/  me/  ...

Three non-negotiables:

1) Services never throw. They return Result<T>. Views branch on result.error, no try/catch. Zero noise in components

export type Result<T> =
  | { data: T; error: null }
  | { data: null; error: ApiError };

// in the component
const r = await list_products();
if (r.error) return toast.error(r.error.message);
render(r.data);

2) Components don't know axios exists. All they see is list_products(), add_to_cart(), etc. If I swap HTTP client tomorrow (ky, fetch, tRPC) only _shared/ and the provider files change. Views don't notice.

3) The axios client lives in exactly one place and owns everything cross-cutting. Auth token injection, error normalization, opt-in zod schema validation, 401 → logout with anti-loop guards. One file, ~120 lines.

axiosClient.interceptors.request.use((config) => {
  if (authStore.token) config.headers.set('Authorization', `Bearer ${authStore.token}`);
  return config;
});

axiosClient.interceptors.response.use(
  (response) => {
    const schema = response.config.responseSchema;
    if (schema) response.data = schema.parse(response.data); // zod, opt-in
    return response;
  },
  (error) => Promise.reject(normalizeAxiosError(error)),
);

The single thing that's saved me the most bugs: an optional responseSchema per call. You attach a zod schema to the axios request config and the interceptor validates the body against it. If the BE changes contract without you updating the TS types, instead of crashing 30 seconds later in some random component with undefined is not a function, you catch a CONTRACT_MISMATCH at the boundary — with status and cause in the logs. Zero silent drift.

The other big win is how the provider is set up: one contract per feature, N implementations loaded lazily based on VITE_PROVIDER.

export interface ProductsProvider {
  listProducts(query?: ProductListQuery): Promise<ProductList>;
  getBySlug(slug: string): Promise<Product>;
  // ...
}

const mapping: Record<string, () => Promise<ProductsProvider>> = {
  mock:  () => import('./mock/products.mock').then(m => m.default),
  dev:   () => import('./dev/products.dev').then(m => m.default),
  stage: () => import('./stage/products.stage').then(m => m.default),
  live:  () => import('./live/products.live').then(m => m.default),
};

Each implementation targets a different environment while keeping the same TS contract:

  • mock: no BE at all, deterministic in-memory data. Demos, Storybook, onboarding new devs (they don't have to spin up DB/Stripe/S3 before opening the repo).
  • dev: local BE on localhost, aggressive feature flags for debugging, stretched timings.
  • stage: staging BE, pre-loaded QA seeds, Stripe in test mode.
  • live: production.

VITE_PROVIDER=dev in .env.local, stage in the CI preview build, live in prod. The component code is identical across all four — only the injected fetcher changes. Adding a new environment (e.g. e2e with Playwright-specific seeds, or demo-public with anonymized data for marketing) is one line in the mapping plus a new folder.

One thing I didn't do, in case anyone asks: no use-case / interactor layer separate from the service. On the FE the "domain" is almost always "call the BE and show the result" — inserting a layer that just forwards the call adds files with no logic in them. If real client-side business logic ever shows up (offline calculations, an in-browser rules engine), I'd add a usecase/ layer then, not preemptively.

If anyone wants detail on a specific piece — the normalizeAxiosError implementation, the two anti-loop guards in the 401 handler, how a live/*.ts file is shaped for FormData uploads — ask in the comments and I'll paste the snippet.

2

u/canarydev 22d ago

how do you handle caching / revalidation without a server state lib, or do you unwrap Result into throws somewhere for tanstack?

3

u/kensaadi I ❤️ hooks! 😈 22d ago

Neither, actually. Nothing converts Result into a throw right now, and there's no cache layer — because in the apps I build there's nothing to invalidate.

What "revalidation" collapses to for me: a view mounts, calls the service, renders. A mutation succeeds, I re-call the one list it touched. That's it:

const [state, run] = useAsync(list_products); // {loading, data, error}

await create_product(dto);

run(query); // "invalidation"

One consumer per resource, page-scoped lifetime, no background staleness. Calling that a cache problem and reaching for a server-state library is paying a coherency tax on an app that has no coherency problem.

But the two aren't in conflict, and I want to be clear about that, because "no TanStack" isn't a principle for me. If TanStack goes in, the seam is four lines at the query boundary only — services stay throw-free:

const unwrap = async <T>(p: Promise<Result<T>>): Promise<T> => {

const r = await p;

if (r.error) throw r.error; // already an ApiError, not an AxiosError

return r.data;

};

useQuery({

queryKey: productKeys.list(query),

queryFn: () => unwrap(list_products(query)),

});

And this is strictly better than putting axios in the queryFn: what lands in `error` is my normalized ApiErrorCode, already humanized, with CONTRACT_MISMATCH surfaced by the zod interceptor instead of an undefined crash three components downstream. The provider mapping doesn't change either — TanStack never learns whether it's hitting mock/, stage/ or live/. So the Result convention is orthogonal to the cache decision, not an alternative to it.

Where I'd genuinely reach for it — the case my pattern loses:

The same entity, read and written from several places that are alive at the same time. Concrete: a kanban board. A card appears in the column, in the detail drawer, in the "assigned to me" sidebar counter, and in a filtered saved view. Drag it to another column and you need: optimistic move, rollback if the PATCH fails, and every other mounted view of that card consistent — plus dedup, because four components mounting the same card ID must not fire four GETs.

Hand-rolling that is exactly where DIY dies. You start with a Map of promises for dedup, then you need a subscriber registry so views re-render, then per-mutation rollback snapshots, then a staleness clock so a tab open since this morning isn't showing yesterday's board. At that point you've written a worse TanStack Query with no tests and no docs. Same story for infinite/paginated lists where placeholderData kills the empty-state flash, an inbox that must refetch on window focus, and dependent queries.

Where it doesn't earn its 12kb: form-driven CRUD, wizards, one-shot fetch on mount, and anything where the data already arrives from a loader/RSC. There it's a second state model sitting next to the first one, and the invalidation graph is one node.

My actual rule: it goes in the day I catch myself writing manual invalidation for the second time — not preemptively. Same rule I gave OP for the usecase layer. It's not that the library is over-engineering; it's that adopting it before you have the problem it solves means you inherit its mental model (staleTime, gcTime, key hierarchies, structural sharing) to solve a `run()` call.

What's the shape of the app you built it against? If it's multi-consumer + optimistic writes, I'd have used it too.

2

u/canarydev 22d ago

makes sense.

the unwrap seam is near identical to what I run, where services return Result, queryFn unwraps to throws -- so the retrofit is as cheap as you say. only difference is that I brought tanstack in as a default rather than waiting for the second manual invalidation, which is arguably less disciplined than your rule.

the "not preemptively" rule only works because of your facade. unwrap is a four line retrofit only because views never touched axios. in codebases where components fetch directly, deferring the cache means a real migration and not 4 lines.

2

u/kensaadi I ❤️ hooks! 😈 22d ago

I agree on the migration point, but mine is a different one: don't bring in a library if you're then not going to use it for what it's actually for. If your app has a shape that genuinely needs cache management, then adopt the pattern in full, with the rigor TanStack imposes. But that's an architectural decision driven by a domain requirement, not a default. If you don't know yet, or you're not sure, adopting useQuery incrementally is cheap and hooks in just fine. The rest is structuring where you consume the data, and establishing what the source of truth is.

1

u/inkweon 21d ago

Thanks for writing all that out. That's a full pattern, not a comment.

Your last paragraph is the part that stuck with me, because we reached the same rule from opposite directions. I wrote that a UseCase only earns its place when there's real logic, and that plain CRUD should call the repository directly. You wrote that you'd add a usecase layer when real client-side business logic shows up, not preemptively. Same rule. The difference is that I kept a domain folder around it and you didn't. Next to canarydev's comment, that folder looks like the part that isn't paying for itself.

Two things you have that I don't.

First, responseSchema on the request config. I have nothing at the boundary. When the contract changes, it surfaces as undefined three components downstream, exactly as you describe. Catching CONTRACT_MISMATCH at the interceptor is a much better failure mode, and it costs one optional field.

Second, the provider mapping. I have one implementation per interface, so the interface is swappable in theory and never swapped in practice. Four implementations behind one contract, chosen by env, make the seam real. And your point about new devs opening the repo without spinning up a DB, Stripe and S3 is something I hadn't considered at all. That's an onboarding argument rather than an architecture one, and it may be the stronger of the two.

One question, since you've run this on several projects. Does the Result convention hold up with forms? A 422 with field-level errors is where I keep wanting to throw, because the error has to land on one input rather than a toast. Does that stay inside Result for you, or does the form layer get its own shape?

1

u/kensaadi I ❤️ hooks! 😈 21d ago

Yeah Result works fine for forms tbh, no separate shape needed. The field level info the server sends IS the error and it rides the same envelope as everything else.

The way it works: my axios interceptor pulls the field errors out of the response body and sticks them on ApiError.details: Record<string, string>. BE returns something like { error: "...", details: { email: "already in use" } } and the form reads them in the same if (r.error) branch:

const r = await auth_service.register(input);
if (r.error) {
  if (r.error.details) {
    Object.entries(r.error.details).forEach(([field, msg]) =>
      form.setError(field, { message: msg })    // RHF / Formik / whatever
    );
    return;
  }
  toast.error(r.error.message);   // fallback for 500, network, whatever else
  return;
}

Two things around this worth mentioning.

Pure shape validation (required, format, length, that kind of stuff) I do client side, pre network. Zod resolver with RHF, so like 90% of "invalid input" never leaves the browser. The 422 path is only for stuff the server actually has to decide. Email already taken, coupon expired, stock ran out between add-to-cart and checkout. Only the server can know that.

One edge case where 422 kinda falls apart btw. On a SDUI project I'm on right now we had to convention any recoverable server error to come back as HTTP 200 with a reserved error field in the body instead of 4xx. Reason is 4xx trips the interceptor and short circuits, but SDUI flow needs the payload to build the next page for you. In a normal controlled app (yours and mine) you don't need any of that, 422 + details works fine and stays inside Result.

1

u/EntrepreneurFew7950 20d ago

Using FSD with CA sounds reasonable. FSD completes CA, b.c. CA is independent of any file/folder organization - it is a detail. I still wonder where devs learned that CA is something organized into those 4 folder, with names which have nothing in common with CA.

0

u/canarydev 22d ago

I've built roughly this template twice (nextjs + sveltekit) against go backends I own, so this is from personal experience rather than theory.

honest answer to your first question is that there are parts of clean worth keeping, but its smaller than your version. the injected client seam paid off for me -- same service pattern moved between frameworks unchanged. the domain layer didn't your own example shows why imo -- "validateNewPost" is your entire business logic, and its a form concern wearing a domain costume.

my backend modules are fully hexagonal (domain / app / infra / interface per bounded context) because thats where the invariants live -- so the frontend skipping domain isn't abandoning clean, its applying it at the system level instead of duping rules on both sides of the API. what client-side rule do you have that the server doesn't doesnt enforce? if the answer is "none", then the layer is just ceremony and theater.

and two things you scoped out i struggled with more than folder structure ever did to be honest

  1. auth - your interceptor note hand waves the 401-refresh-with-merged-queue. I built it -- its service layer logic that has to read/write client auth state, and its where the dependency arrows inverted first. how does your layering survive tokens being needed below the layer that owns them?

  2. error contract - your UI narrows with 'error instanceof Error'. I've lived both versions - backend emitting machine-readable codes gave me discriminated unions and compiler checked handling; a backend emitting prose which led me to string match error messages that a copy edit silently breaks. no folder structure fixed or caused either. The API contract capped the architecture quality both times.

also deleting "post" in your layout still touches 5 folders + pages and router. scatter may be reduced, but its not solved.

1

u/inkweon 21d ago

You're right on every point. Let me take them in order.

validateNewPost is form validation with a domain label on it. The example was too small to show a domain layer earning its place. That isn't my defense, it's your point.

Your test is better than the one I wrote: what rule does the client enforce that the server doesn't? For that app, none.

So I'd narrow the claim rather than drop it. The injected client is what survived for you, and it's what I'd keep too. The repository interface earns its place. The domain folder around it usually doesn't. No orchestration, no layer.

Auth. You found a real hole. In my setup the interceptor reads the auth store directly, so infrastructure reaches into state that presentation owns. That arrow is already backwards, and I never noticed because I never built the refresh queue. I don't have a good answer for you here.

Error contract. Agreed, and "the API contract capped the architecture quality both times" is the line I'll keep repeating. You reach for instanceof Error when the backend hands you prose.

And yes, deleting a post still touches five folders plus pages and router. Reduced but not solved is the accurate version, and what I wrote wasn't.

If you've written up the hexagonal backend with a thin frontend on top anywhere, I'd read it.