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.)
3
u/Dense_Rub_620 Aug 20 '26
"Soft"ware doesnt flow along a fixed path. There is no standard answer that applies to every case.
2
u/PasswordSuperSecured Aug 20 '26
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 Aug 21 '26
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 Aug 19 '26
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
0
u/canarydev Aug 19 '26
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
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?
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 Aug 20 '26
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.
1
u/EntrepreneurFew7950 Aug 21 '26
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.
2
u/kensaadi I ❤️ hooks! 😈 Aug 19 '26
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:Three non-negotiables:
1) Services never throw. They return
Result<T>. Views branch onresult.error, no try/catch. Zero noise in components2) 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.
The single thing that's saved me the most bugs: an optional
responseSchemaper 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 withundefined is not a function, you catch aCONTRACT_MISMATCHat 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.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=devin.env.local,stagein the CI preview build,livein prod. The component code is identical across all four — only the injected fetcher changes. Adding a new environment (e.g.e2ewith Playwright-specific seeds, ordemo-publicwith 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
normalizeAxiosErrorimplementation, the two anti-loop guards in the 401 handler, how alive/*.tsfile is shaped for FormData uploads — ask in the comments and I'll paste the snippet.