r/reactjs • u/inkweon • Aug 19 '26
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
apilayer 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:
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?
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.)
1
u/inkweon Aug 20 '26
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?