Why This Matters

If you’ve used AI coding agents (Claude Code, Cursor, Copilot Workspace, or similar), you’ve noticed that they work great on small focused tasks but fall apart on tangled monoliths. They hallucinate imports, edit the wrong files, and produce code that conflicts with what another agent just wrote.

The problem isn’t the AI. The problem is the codebase. I’ve been building a full-stack app with React and FastAPI, and I’ve landed on a structure that makes multi-agent development actually work. The core idea: make your app look like a collection of small apps, each isolated in its own folder.

In this article I am using my preferred tech stack (React, MUI, FastAPI), but the same concepts can apply with other tech stacks as well.

Organize by Feature, Not by Role

The typical tutorial structure groups files by technical role: all components in one folder, all hooks in another, all services in a third. To understand or modify the “invoices” feature, you jump across five directories. An AI agent working on invoices gets context from all over the place and inevitably touches files that serve other features.

I organize by domain feature instead. Everything related to invoices lives together. Everything related to users lives together. Shared utilities are minimal and explicitly extracted only when needed.

Frontend Structure

frontend/src/
  features/
    invoices/
      components/
        InvoiceTable.tsx
        InvoiceRow.tsx
        CreateInvoiceForm.tsx
        CreateInvoiceForm.logic.ts
        CreateInvoiceForm.styles.ts
      hooks/
        useInvoices.ts
      api/
        invoiceApi.ts
      types/
        invoice.ts
      InvoicesPage.tsx
      index.ts
    users/
      components/
        UserList.tsx
        InviteUserForm.tsx
        InviteUserForm.logic.ts
        InviteUserForm.styles.ts
      hooks/
        useUsers.ts
      api/
        userApi.ts
      types/
        user.ts
      UsersPage.tsx
      index.ts
  shared/
    components/
      Button.tsx
      Modal.tsx
      DataTable.tsx
    hooks/
      useAuth.ts
    api/
      client.ts
    styles/
      theme.ts
  App.tsx
  router.tsx

The index.ts Is the Public API

Each feature’s index.ts exports only what other features are allowed to import. If invoices needs to show a user name, it imports from features/users/index.ts, never from features/users/components/UserCard.tsx directly.

// features/invoices/index.ts
export { InvoicesPage } from './InvoicesPage'
export type { Invoice, InvoiceStatus } from './types/invoice'

Separate Logic from UI

I split every non-trivial component into two files: Component.tsx for rendering and Component.logic.ts for state, validation, and side effects. The .tsx file stays a pure function of props and hooks. The .logic.ts file exports a custom hook that owns the behavior.

// components/CreateInvoiceForm.logic.ts
import { useState } from 'react'
import { invoiceApi } from '../api/invoiceApi'
import type { InvoiceCreate } from '../types/invoice'

export function useCreateInvoiceForm(onSuccess: () => void) {
  const [form, setForm] = useState<InvoiceCreate>({
    client_name: '',
    amount: 0,
    due_date: '',
  })
  const [submitting, setSubmitting] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const updateField = (field: keyof InvoiceCreate, value: any) =>
    setForm((prev) => ({ ...prev, [field]: value }))

  const submit = async () => {
    setSubmitting(true)
    setError(null)
    try {
      await invoiceApi.create(form)
      onSuccess()
    } catch (e: any) {
      setError(e.response?.data?.detail ?? 'Something went wrong')
    } finally {
      setSubmitting(false)
    }
  }

  return { form, updateField, submit, submitting, error }
}
// components/CreateInvoiceForm.tsx
import { useCreateInvoiceForm } from './CreateInvoiceForm.logic'
import { Form, Input, ErrorText } from './CreateInvoiceForm.styles'

export function CreateInvoiceForm({ onSuccess }: { onSuccess: () => void }) {
  const { form, updateField, submit, submitting, error } =
    useCreateInvoiceForm(onSuccess)

  return (
    <Form onSubmit={(e) => { e.preventDefault(); submit() }}>
      <Input
        value={form.client_name}
        onChange={(e) => updateField('client_name', e.target.value)}
        placeholder="Client name"
      />
      {error && <ErrorText>{error}</ErrorText>}
      <button disabled={submitting} type="submit">
        {submitting ? 'Creating...' : 'Create Invoice'}
      </button>
    </Form>
  )
}

This pays off immediately with AI agents. When you ask an agent to change form validation, it only needs CreateInvoiceForm.logic.ts. When you ask it to adjust layout, it only needs CreateInvoiceForm.tsx and the .styles.ts file. Neither edit risks breaking the other.

Keep Styles Next to Components

I use styled-components with a dedicated .styles.ts file per component so that styles live right next to the component they belong to. The rule is simple: no global stylesheets for component-level styling. The only shared styles are in shared/styles/theme.ts for design tokens (colors, spacing, breakpoints) that get consumed via the ThemeProvider.

// components/CreateInvoiceForm.styles.ts
import styled from 'styled-components'

export const Form = styled.form`
  display: flex;
  flex-direction: column;
  gap: ${({ theme }) => theme.spacing.md};
`

export const Input = styled.input`
  padding: ${({ theme }) => theme.spacing.sm};
  border: 1px solid ${({ theme }) => theme.colors.border};
  border-radius: ${({ theme }) => theme.radii.sm};
`

export const ErrorText = styled.p`
  color: ${({ theme }) => theme.colors.danger};
  font-size: ${({ theme }) => theme.fontSizes.sm};
`

Pick one styling approach and use it everywhere. The moment you mix two strategies, agents start guessing which one to use and they guess wrong half the time.

The Rule for shared/

A component moves to shared/ only when it is used by two or more features and contains zero business logic. A Button is shared. An InvoiceStatusBadge is not, even if you think another feature might use it someday. Premature extraction into shared creates a junk drawer that every feature depends on.

Backend Structure: FastAPI

Same principle. Group by domain, not by role:

backend/app/
  features/
    invoices/
      router.py
      service.py
      models.py
      schemas.py
    users/
      router.py
      service.py
      models.py
      schemas.py
    auth/
      router.py
      service.py
      models.py
      schemas.py
  shared/
    database.py
    config.py
    middleware.py
  main.py

Each feature gets its own APIRouter. The main.py wires them together:

# app/main.py
from app.features.invoices.router import router as invoices_router
from app.features.users.router import router as users_router
from app.features.auth.router import router as auth_router

app = FastAPI()

app.include_router(auth_router, prefix="/auth", tags=["auth"])
app.include_router(users_router, prefix="/users", tags=["users"])
app.include_router(invoices_router, prefix="/invoices", tags=["invoices"])

When features need each other, import the service, not the internals. The service layer is the boundary:

# app/features/invoices/service.py
from app.features.users.service import UserService

class InvoiceService:
    def __init__(self, db: Session):
        self.db = db
        self.user_service = UserService(db)

    def create_invoice(self, data, user_id: int):
        user = self.user_service.get_by_id(user_id)
        if not user:
            raise ValueError("User not found")
        # ...

Why This Works for AI Agents

Small context window. When you ask an agent to add a discount field to invoices, you point it at features/invoices/ and it has everything: schema, model, service, API layer, components, hooks, types. It doesn’t need to understand users or dashboard.

Parallel work. One agent works on features/invoices/, another on features/users/. They edit different files. No merge conflicts.

Clear boundaries. The index.ts on the frontend and service.py on the backend are explicit interfaces. An agent knows exactly what a feature exposes and what it consumes.

Logic/UI split. An agent changing validation doesn’t touch the template. An agent changing layout doesn’t touch the business logic. This alone cuts unintended side effects in half.

Add an AGENTS.md

Put a short file in the repo root that tells agents about the structure:

# Project Structure

Feature-based organization. Each feature is self-contained.

## Adding a new feature
1. Create folder under `frontend/src/features/` and `backend/app/features/`
2. Follow `features/invoices/` as a reference
3. Register backend router in `app/main.py`
4. Add frontend route in `router.tsx`

## Rules
- Import from `index.ts` (frontend) or `service.py` (backend), never internal files
- Split components: `Component.tsx` for UI, `Component.logic.ts` for behavior
- Styles use styled-components in `Component.styles.ts`, colocated with components
- Shared components must have zero business logic

Cheap to maintain. Dramatically improves agent output.

Is this the best way to build an application?

Probably not, maybe I will change my mind on it later, but for now I see this as the most productive way to do AI assisted development and the resulting code is clean enough that I can actually read it and debug it myself.


Want to discuss this? Get in touch.