02

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" abstraction

3. 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:

  1. Can you find the bug at 3 AM? With feature folders, yes. The error is in the checkout feature? Check features/checkout.

  2. Can a junior dev add a feature? Create features/new-thing, follow the pattern from other features. Done.

  3. 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 logic

Everything 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.