Engineering
Your Folder Structure Is Lying to You

The way most developers organize a full-stack project creates problems that don’t show up until it’s too late to fix them.
I’ve worked on codebases that were a joy to navigate and codebases that made me genuinely anxious every time I had to add a feature.
The difference was rarely the technology. It was the folder structure.
A bad structure doesn’t announce itself on day one. It feels fine when there are 10 files. It gets uncomfortable around 50. By 200 files, you’re spending more time hunting for things than actually building them. And by then, the cost of fixing it is so high that most teams just accept the mess and move on.
The right structure, on the other hand, disappears. You stop thinking about where files go. New developers onboard faster. Features live where you expect them to live. The codebase becomes something you’re proud to show people.
Here’s what that looks like — for both frontend and backend, in a modern full-stack project.
First Decision: Monorepo or Multi-Repo?
Before you write a single line of code, you need to decide whether your frontend and backend live in the same repository or separate ones.
Most teams default to separate repos without thinking about it. That’s often the wrong call.
Choose a monorepo when:
- Your team is 2–10 developers
- Frontend and backend share types, validation schemas, or business logic
- You deploy frontend and backend together
- You want a single place for CI/CD, linting, and tooling
Choose multi-repo when:
- You have separate teams with clear ownership boundaries
- Frontend and backend have completely independent deployment cycles
- You’re running microservices with different tech stacks
For most product teams building a standard web application, a monorepo is the right call. The shared code benefits alone are worth it. The rest of this article assumes a monorepo setup.
The Root Structure
Start clean at the root level. Everything has a home, nothing is ambiguous:
my-project/
├── frontend/ # All frontend code
├── backend/ # All backend code
├── shared/ # Shared types, utils, constants
├── docs/ # Documentation
├── scripts/ # Build, deployment, utility scripts
├── docker/ # Docker configurations
├── .github/ # CI/CD workflows
├── package.json # Root package.json for workspace management
├── README.md
└── .gitignore
The shared/ folder is the part most people skip. It’s also the part that saves you the most pain — more on that later.
Frontend Structure: Organize by Feature, Not by File Type
This is where most projects go wrong.
The instinct is to organize by what a file is — a component, a service, a hook, a type. So you end up with:
src/
├── components/
├── services/
├── hooks/
├── types/
└── pages/
This looks clean on day one. By month three, your components/ folder has 80 files in it, and nobody remembers what half of them do or which page uses them.
Organize by feature instead. Every feature owns its own components, services, hooks, and types:
frontend/
├── public/
├── src/
│ ├── apps/ # Feature-based modules
│ │ ├── dashboard/
│ │ ├── auth/
│ │ └── admin/
│ ├── shared/ # Code shared across features
│ │ ├── components/ # Reusable UI components
│ │ ├── hooks/ # Shared custom hooks
│ │ ├── utils/ # Utility functions
│ │ ├── types/ # Global TypeScript types
│ │ ├── constants/ # App-wide constants
│ │ ├── styles/ # Global styles and themes
│ │ └── services/ # API clients
│ ├── assets/
│ ├── router/
│ ├── store/
│ └── main.ts
├── tests/
├── package.json
├── vite.config.ts
└── tsconfig.json
Here’s what a single feature looks like internally:
src/apps/user-management/
├── components/
│ ├── UserList.vue
│ ├── UserForm.vue
│ └── UserCard.vue
├── services/
│ └── userApi.ts
├── types/
│ └── user.types.ts
└── pages/
├── UsersPage.vue
└── UserDetailPage.vue
Everything related to user management lives in one place. When you delete a feature, you delete one folder. When you debug a feature, you look in one folder. The mental overhead drops dramatically.
Build a Shared Component Library
Reusable UI components deserve their own structure — not just a flat list of files:
src/shared/components/
├── Button/
│ ├── Button.vue
│ ├── Button.types.ts
│ ├── Button.stories.ts
│ └── index.ts
├── Card/
├── Modal/
└── index.ts # Barrel exports
Each component is self-contained: its implementation, its types, and its stories all live together. The index.ts barrel export means your imports stay clean no matter where you are in the project.
Set Up Path Aliases Early
Do this before you write more than 10 files. Relative imports (../../shared/components) turn into archaeology projects at scale.
// vite.config.ts
export default defineConfig({
resolve: {
alias: {
'@': resolve(__dirname, './src'),
'@shared': resolve(__dirname, './src/shared'),
'@apps': resolve(__dirname, './src/apps'),
'@components': resolve(__dirname, './src/shared/components')
}
}
})
Now instead of ../../shared/components/Button, you write @components/Button. Refactoring paths becomes a non-issue.
Backend Structure: Make the Layers Obvious
The most maintainable backend codebases have clear separation between three things:
- What the HTTP layer does (receiving requests, sending responses)
- What the business logic does (the actual rules of your application)
- What the data layer does (talking to the database)
When these are muddled together, you end up with controllers that contain business logic, services that write raw SQL, and models that send emails. It’s a mess that is very easy to fall into and very hard to get out of.
The structure that enforces this separation:
backend/
├── src/
│ ├── controllers/ # HTTP request handlers — nothing else
│ ├── services/ # Business logic — nothing else
│ ├── repositories/ # Data access — nothing else
│ ├── models/ # Data models and entities
│ ├── middleware/ # Express middleware
│ ├── routes/ # Route definitions
│ ├── utils/ # Utility functions
│ ├── types/ # TypeScript types
│ ├── config/ # Configuration
│ ├── validators/ # Request validation schemas
│ └── app.ts
├── tests/
├── migrations/
├── seeds/
├── docs/
├── package.json
└── tsconfig.json
Go Domain-Driven at Scale
For larger applications with multiple product areas, the flat structure above starts to blur. Switch to domain-driven organization:
backend/src/
├── domains/
│ ├── user/
│ │ ├── user.controller.ts
│ │ ├── user.service.ts
│ │ ├── user.repository.ts
│ │ ├── user.model.ts
│ │ ├── user.types.ts
│ │ └── user.routes.ts
│ ├── product/
│ └── order/
├── shared/
│ ├── middleware/
│ ├── utils/
│ └── types/
└── infrastructure/
├── database/
├── cache/
└── external-apis/
Same principle as the frontend feature structure: everything related to a domain lives together. A new developer can open domains/user/ and understand the entire user feature without touching anything else.
The Shared Folder: The Part That Ties It All Together
This is the folder most people skip, and it is the one that causes the most pain when it’s missing.
In a monorepo, your frontend and backend share more than you think:
- TypeScript types for API request/response shapes
- Validation schemas (Zod, Joi) that should match on both sides
- Error codes that the frontend needs to handle
- Constants that need to be consistent everywhere
Without a shared folder, these end up duplicated. The frontend defines a User type. The backend defines a slightly different User type. Six months later, a field name changes on one side and nobody updates the other, and you spend a Friday debugging a bug that should have been a type error.
shared/
├── types/
│ ├── api.types.ts # API request/response shapes
│ ├── user.types.ts
│ └── common.types.ts
├── constants/
│ ├── api-endpoints.ts
│ ├── error-codes.ts
│ └── app-config.ts
├── utils/
│ ├── validation.ts
│ ├── formatters.ts
│ └── date-utils.ts
└── schemas/ # Zod/Joi validation schemas
A shared API type looks like this:
// shared/types/api.types.ts
export interface ApiResponse<T> {
data: T;
message: string;
success: boolean;
timestamp: string;
}
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
Both frontend and backend import from the same source. One type to update, zero inconsistencies.
The Rule That Ties It All Together
If I had to reduce all of this to a single principle, it’s this:
A file should live where the person looking for it will look first.
Not where it technically belongs in some abstract taxonomy. Not where it’s easiest to put it right now. Where someone — including future-you, six months from now — will naturally go to find it.
Feature-based organization wins because that’s how humans think about software. You think “I need to change something about user management” — not “I need to find a component file.”
Layered backend organization wins because when something breaks, you think “this is a data problem” or “this is a logic problem” or “this is a routing problem” — and the layers let you go directly to the right place.
The folder structure is not the code. But it is the map. And a bad map costs you more time than almost anything else.
Get the map right first.