Writings · essay
The Art of Clean Feature Architecture: What I Actually Build
Feature-based folders, minimal abstractions, obvious boundaries. The architecture I actually ship.
- Published
- Read
- 3m
Contents
There's a disconnect between what gets debated and what gets shipped. This is the architecture I actually build, not the theoretical version. It's the one that works at 2 AM.
My Real Architecture Philosophy
After building e-commerce platforms and SaaS dashboards, I've settled on one principle: the best architecture is obvious. Not clever. Just obvious.
Look at this structure from a recent project:
src/
├── features/
│ ├── blog/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── api/
│ │ └── utils/
│ ├── chat/
│ │ ├── components/
│ │ ├── hooks/
│ │ └── utils/
│ └── products/
├── shared/
│ ├── components/ui/
│ ├── hooks/
│ └── lib/
└── server/Each feature is self-contained, shared code lives in shared/, server code stays in server/. No mystery folders or abstractions that require a decoder ring to understand.
The Component That Changed My Perspective
A real component from production:
export const BlogCard = memo(function BlogCard({
article,
styles = DEFAULT_STYLES,
}: Readonly<Props>) {
const format = useFormatter();
const readTime = useMemo(
() =>
Math.ceil(
convertLexicalToPlaintext({ data: article.content }).split(" ").length /
200,
),
[article.content],
);
return (
<Link href={`/blog/${article.slug}`}>
<Card className="group h-full overflow-hidden">
{/*Clean, focused, single responsibility*/}
</Card>
</Link>
);
});No prop drilling, no context spaghetti, no abstractions for abstraction's sake. One component, one job.
The Pattern I Keep Coming Back To
The pattern I use:
1. Features Own Their Domain
// features/chat/hooks/useChatApi.ts
export function useChatApi() {
// All chat logic lives here
// Not scattered across utils, helpers, services
}
// features/products/api/index.ts
export async function getProducts() {
// Product API calls stay with products
}2. Shared Means Actually Shared
// shared/components/ui/button.tsx
// This button is used EVERYWHERE
// Not "might be shared someday"
// shared/hooks/useCopy.ts
// A hook that 5+ features actually use
// Not a "just in case" abstraction3. Clean Imports Tell the Story
import { BlogCard } from "@/features/blog/components/card";
import { Button } from "@/shared/components/ui/button";
import { api } from "@/server/trpc";One glance and you know exactly where everything comes from. No detective work required.
The Hero Component Philosophy
Every feature gets a hero section. Same pattern every time:
export const HeroSection = memo(() => {
const t = useTranslations("pages.home.hero");
const [state, setState] = useState();
// Effects close to usage
// No effect chains
// No callback hell
return (
<section className="relative flex h-dvh items-center">
{/*Content*/}
</section>
);
});Centered, focused, no distractions. The architecture mirrors the UI.
Why I Stopped Chasing Perfect
I tried DDD, Clean Architecture, Hexagonal Architecture. The best one turned out to be whichever your team understands without reading a slide deck.
New developers pick it up in a day instead of a week, features ship faster, and bugs land where they're obvious.
The Real-World Test
My test for whether an architecture works:
-
Can you find the bug at 3 AM? With feature folders, yes. The error is in the checkout feature? Check
features/checkout. -
Can a junior dev add a feature? Create
features/new-thing, follow the pattern from other features. Done. -
Can you delete a feature cleanly? Delete the folder. If anything breaks, it wasn't properly isolated.
The Mistakes That Led Me Here
The Monorepo Phase
I went through a phase where everything had to be a monorepo with 47 packages. Took 5 minutes just to understand the import paths. Now it's one codebase, clear boundaries.
The Abstraction Addiction
I once created a "FormBuilder" that could handle any form. It had 2000 lines of configuration options. Now I just write forms. Takes 10 minutes, works every time.
The Perfect Type System
Spent weeks on a type system that covered every edge case. Nobody understood it. Now I type what matters and move on.
What This Actually Looks Like in Production
Here's a real feature structure from a production app:
features/verification/
├── components/
│ ├── hero-section.tsx # The main hero
│ ├── verification-form.tsx # The form
│ └── results/ # Result states
│ ├── success.tsx
│ └── error.tsx
├── api/
│ └── index.ts # API calls
├── types/
│ └── index.ts # Types
└── utils/
└── validation.ts # Validation logicEverything the verification feature needs is right there. No hunting for where things live.
The Tooling That Makes It Work
Architecture isn't just folders. It's the entire developer experience:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
}
}Simple scripts. No custom build tools or proprietary abstractions, just the tools everyone knows.
The Payoff
Six months into using this architecture on multiple projects:
- Onboarding time: 1 day instead of 1 week
- Feature development: 40% faster (measured, not guessed)
- Bug resolution: Usually under an hour
- Developer satisfaction: noticeably better
The real payoff: I stopped thinking about architecture. Good design is invisible — it just works.