Loading learning content...
When we discuss Low-Level Design, we often focus on classes, interfaces, patterns, and principles. We draw UML diagrams, reason about dependencies, and craft elegant abstractions. But there's a dimension of design that's equally critical yet frequently overlooked: how do we organize all of this code in actual files and directories?
Project structure is not merely an administrative concern—it's a fundamental design decision that shapes developer experience, onboarding velocity, refactoring safety, and system evolution. A well-structured project communicates intent. It tells developers where to find things, where to add new features, and how different parts of the system relate. A poorly structured project, regardless of how elegant its internal classes are, becomes a maze that slows development and accumulates technical debt.
This page explores project structure as a first-class design concern—one that deserves the same rigor and intentionality we apply to class design and pattern selection.
By the end of this page, you will understand why project structure matters beyond mere convention, master multiple structural paradigms (layered, feature-based, hybrid), learn to make informed decisions about directory organization, and see how structure choices impact testability, maintainability, and team velocity.
Many developers treat project structure as a minor concern—something to set up once and forget. This perspective dramatically underestimates the impact of structure on software quality. Consider that developers spend more time reading code than writing it, and much of that reading involves finding the right code to read.
Project structure is the information architecture of your codebase. Just as a well-designed library has a logical catalog system that helps patrons find books, a well-structured project has a logical organization that helps developers find code. The alternative—a flat sea of files or an arbitrary nest of folders—forces developers to rely on global search, institutional knowledge, or trial and error.
| Aspect | Good Structure | Poor Structure |
|---|---|---|
| Onboarding | New developers can navigate independently within days | New developers need constant guidance for weeks |
| Feature Development | Clear location for new code; minimal decision fatigue | Confusion over where to add features; inconsistent placement |
| Code Reviews | Reviewers quickly understand context and impact | Reviewers struggle to trace changes across scattered files |
| Refactoring | Modular structure enables safe, isolated changes | Entangled structure makes any change risky |
| Testing | Test location is predictable; coverage is visible | Tests scattered randomly; gaps are hidden |
| Scaling | Structure accommodates growth naturally | Structure breaks down as codebase grows |
Poor structure imposes a hidden tax on every development activity. It's rarely dramatic enough to trigger an emergency, but it compounds daily. Teams working in poorly structured codebases are measurably slower—not because they're less capable, but because they're constantly fighting friction the structure creates.
The cognitive load dimension:
Every codebase has a mental model—how developers think about its organization. When the physical structure (files and directories) matches the mental model (how we think about the domain), cognitive load is minimized. When they diverge, developers must maintain a translation layer in their heads, consuming mental resources that could be applied to actual problem-solving.
Consider two approaches to organizing an e-commerce system:
Structure A (Mental Model Mismatch):
src/
controllers/
OrderController.ts
ProductController.ts
UserController.ts
services/
OrderService.ts
ProductService.ts
UserService.ts
repositories/
OrderRepository.ts
ProductRepository.ts
UserRepository.ts
Structure B (Mental Model Alignment):
src/
orders/
OrderController.ts
OrderService.ts
OrderRepository.ts
Order.ts
products/
ProductController.ts
ProductService.ts
ProductRepository.ts
Product.ts
users/
UserController.ts
UserService.ts
UserRepository.ts
User.ts
In Structure A, working on 'orders' requires navigating three different directories. In Structure B, everything related to orders lives together. Neither is universally correct—context matters—but this illustrates how structure affects the mental effort required to work with code.
Before examining specific structural patterns, we need to establish the principles that guide good project organization. These principles are language-agnostic and apply whether you're building in Java, TypeScript, Python, C#, or any other object-oriented language.
services/ in one module, they should go there in all modules. Inconsistency breeds confusion.Like a newspaper, your project structure should be readable at different levels of detail. The top-level directories tell you what the system is about (headlines). Drilling into a directory provides more detail (article sections). Individual files are the paragraphs. This progressive disclosure helps developers navigate at the appropriate level of abstraction.
The cost of reorganization:
Changing project structure after a codebase has grown is expensive. Unlike refactoring code—where IDE tools can safely rename and move—restructuring directories affects:
This high cost of change means that initial structure decisions carry long-term consequences. It's worth investing time upfront to establish a structure that will scale, rather than planning to 'fix it later' when the codebase is larger.
The layered structure organizes code by technical layer—controllers together, services together, repositories together. This is the most common pattern in enterprise applications and the default structure generated by most frameworks and scaffolding tools.
Characteristic structure:
src/
├── controllers/ # HTTP/API endpoints
│ ├── OrderController.ts
│ ├── ProductController.ts
│ └── UserController.ts
├── services/ # Business logic layer
│ ├── OrderService.ts
│ ├── ProductService.ts
│ └── UserService.ts
├── repositories/ # Data access layer
│ ├── OrderRepository.ts
│ ├── ProductRepository.ts
│ └── UserRepository.ts
├── models/ # Domain entities
│ ├── Order.ts
│ ├── Product.ts
│ └── User.ts
├── dtos/ # Data transfer objects
│ ├── CreateOrderDto.ts
│ ├── ProductResponseDto.ts
│ └── UserRegistrationDto.ts
└── utils/ # Shared utilities
├── DateHelper.ts
└── ValidationHelper.ts
Layered structure excels in small-to-medium projects where features are deeply interrelated and the team is accustomed to this organization. It's particularly effective when the primary organizational concern is layer separation (ensuring controllers don't bypass services) rather than feature isolation.
The feature-based structure (also called vertical slices, module-per-feature, or package-by-feature) organizes code by business capability. All code related to a feature—controller, service, repository, models, tests—lives in a single directory.
Characteristic structure:
src/
├── orders/ # Order feature
│ ├── OrderController.ts
│ ├── OrderService.ts
│ ├── OrderRepository.ts
│ ├── Order.ts
│ ├── CreateOrderDto.ts
│ ├── OrderResponseDto.ts
│ └── __tests__/
│ ├── OrderService.test.ts
│ └── OrderController.test.ts
├── products/ # Product feature
│ ├── ProductController.ts
│ ├── ProductService.ts
│ ├── ProductRepository.ts
│ ├── Product.ts
│ └── __tests__/
│ └── ProductService.test.ts
├── users/ # User feature
│ ├── UserController.ts
│ ├── UserService.ts
│ ├── UserRepository.ts
│ ├── User.ts
│ └── __tests__/
│ └── UserService.test.ts
└── shared/ # Cross-cutting concerns
├── middleware/
│ └── AuthMiddleware.ts
└── utils/
└── DateHelper.ts
Feature-based structure embodies 'screaming architecture'—the top-level directories immediately tell you what the application does. Looking at orders/, products/, users/ communicates 'e-commerce system' more clearly than controllers/, services/, repositories/ (which could be any application).
Guidelines for feature decomposition:
Deciding what constitutes a feature is often the most challenging aspect of feature-based structure. Here are heuristics that help:
Bounded contexts — If you're using Domain-Driven Design, each bounded context is a natural feature boundary.
Lifecycle independence — Features that can be developed, deployed, or replaced independently deserve separate directories.
Team ownership — If different teams own different parts of the system, structure by team ownership.
Change frequency — Code that changes together should live together; use git history to identify clusters.
Domain language — Features often align with nouns that domain experts use: orders, customers, payments, shipments.
Real-world projects rarely fit neatly into pure layered or pure feature-based patterns. Most successful projects use hybrid structures that combine elements of both, tailored to their specific needs.
Pattern A: Features with Internal Layers
Top-level organization is by feature, but each feature internally follows a layered structure:
src/
├── orders/
│ ├── api/ # HTTP layer for orders
│ │ └── OrderController.ts
│ ├── domain/ # Business logic for orders
│ │ ├── Order.ts
│ │ └── OrderService.ts
│ ├── infrastructure/ # Data access for orders
│ │ └── OrderRepository.ts
│ └── index.ts # Public exports for this feature
├── products/
│ ├── api/
│ ├── domain/
│ └── infrastructure/
└── shared/
├── kernel/ # Shared domain concepts
└── infrastructure/ # Shared technical concerns
Pattern B: Core vs. Features
Separate the stable core domain from frequently changing features:
src/
├── core/ # Stable domain model
│ ├── entities/
│ │ ├── Order.ts
│ │ └── Product.ts
│ ├── value-objects/
│ │ ├── Money.ts
│ │ └── Address.ts
│ └── domain-services/
│ └── PricingEngine.ts
├── features/ # Use cases and API
│ ├── checkout/
│ ├── catalog/
│ └── user-management/
└── infrastructure/ # Technical implementations
├── database/
├── messaging/
└── external-services/
Pattern C: Hexagonal/Ports and Adapters Layout
Aligns structure with hexagonal architecture, separating the domain from infrastructure adapters:
src/
├── domain/ # Pure business logic (no dependencies)
│ ├── orders/
│ │ ├── Order.ts
│ │ ├── OrderService.ts
│ │ └── OrderRepository.ts # Interface only
│ └── products/
├── application/ # Use cases, orchestration
│ ├── PlaceOrderUseCase.ts
│ └── ListProductsUseCase.ts
├── adapters/ # Implementation of ports
│ ├── inbound/ # Driving adapters (API, CLI, etc.)
│ │ ├── http/
│ │ │ └── OrderController.ts
│ │ └── graphql/
│ │ └── OrderResolver.ts
│ └── outbound/ # Driven adapters (DB, external services)
│ ├── persistence/
│ │ └── PostgresOrderRepository.ts
│ └── messaging/
│ └── RabbitMQEventPublisher.ts
└── config/ # Dependency injection, bootstrapping
└── container.ts
The right structure depends on your team size, domain complexity, deployment model, and organizational preferences. Small teams on small projects may over-engineer with hexagonal layouts. Large teams on complex domains may under-invest with flat layered structures. Match the complexity of your structure to the complexity of your problem and organization.
Regardless of your organizational pattern, certain directories appear in virtually every well-structured project. Understanding their purpose helps you apply them consistently.
| Directory | Purpose | Contents |
|---|---|---|
src/ or lib/ | Main source code | All production application code |
tests/ or __tests__/ | Test code | Unit tests, integration tests, test utilities |
config/ | Configuration files | Environment configs, dependency injection setup |
scripts/ | Automation scripts | Build scripts, database migrations, deployment scripts |
docs/ | Documentation | Architecture docs, API docs, runbooks |
types/ or @types/ | Type definitions | Shared types, external type declarations |
generated/ | Auto-generated code | ORM clients, API clients, code-gen outputs |
shared/ or common/ | Cross-cutting code | Utilities, constants, shared interfaces |
__tests__/ within feature directories) or mirrored structure (tests/orders/ for src/orders/) both work; consistency matters more than choice..gitignore entries if regenerated on build.config/ directory.docs/ in the repository ensures documentation stays version-controlled with the code it describes.A utils/ or helpers/ directory easily becomes a dumping ground for unrelated code. If your utils directory grows beyond 5-10 files, reconsider: can these utilities be placed closer to where they're used? Can they be grouped by purpose (date utils, validation utils)? A catch-all directory signals missing structure.
Test organization is a structural decision that significantly impacts development workflow. The two primary approaches—co-located tests and separate test directories—each have distinct advantages.
Co-located Tests Structure:
src/
├── orders/
│ ├── OrderService.ts
│ ├── OrderService.test.ts
│ ├── OrderRepository.ts
│ └── OrderRepository.test.ts
├── products/
│ ├── ProductService.ts
│ └── ProductService.test.ts
✅ Tests are immediately discoverable ✅ Encourages testing while developing ✅ Easy to see untested files ❌ Mixes test code with production code ❌ May complicate production builds
Separate Test Directory Structure:
src/
├── orders/
│ ├── OrderService.ts
│ └── OrderRepository.ts
tests/
├── orders/
│ ├── OrderService.test.ts
│ └── OrderRepository.test.ts
✅ Clean separation of concerns ✅ Simple build exclusion ✅ Room for test utilities ❌ Easy to forget tests when adding code ❌ Path distance between test and subject
Hybrid approach for different test types:
Many projects use different strategies for different test categories:
src/
├── orders/
│ ├── OrderService.ts
│ ├── OrderService.test.ts # Unit tests (co-located)
│ └── ...
tests/
├── integration/ # Integration tests (separate)
│ └── orders/
│ └── OrderWorkflow.test.ts
├── e2e/ # End-to-end tests (separate)
│ └── checkout.e2e.ts
└── fixtures/ # Shared test data
└── orders.fixtures.ts
This approach keeps fast, focused unit tests close to their subjects while housing slower, broader tests in a separate location.
Unit tests benefit from proximity to code—developers should see the test file when they modify the source file. Integration and end-to-end tests often span multiple features and naturally live in separate directories organized by scenario rather than by source file.
Project structure is not static. As systems grow, the optimal structure often changes. Understanding this evolution helps you plan for growth without over-engineering at the start.
| Project Phase | Typical Size | Recommended Approach |
|---|---|---|
| Prototype | < 20 files | Flat structure or minimal grouping. Focus on iteration speed; don't invest in elaborate organization. |
| Early Product | 20-100 files | Basic layered or feature structure. Establish patterns that will scale; document conventions. |
| Maturing Product | 100-500 files | Full feature-based or hybrid structure. Enforce boundaries; invest in documentation. |
| Enterprise Scale | 500+ files | Consider monorepo with packages or microservices. Tooling for cross-package changes. |
Signs that restructuring is needed:
Making structural changes safely:
Document the target state — Before changing anything, write down what the new structure should look like and why.
Move incrementally — Restructure one feature or module at a time, not the entire codebase at once.
Update tooling — Ensure build configurations, linting rules, and IDE settings support the new structure.
Re-export for compatibility — Use barrel files (index.ts) to maintain existing import paths during transition.
Communicate continuously — Keep the team informed about structural changes and the rationale.
Don't create abstraction or structure for one or two items. When you have three similar things (three features, three utilities, three adapters), that's when patterns emerge and organization provides value. Premature structure for potential future growth often creates complexity that never pays off.
Let's trace how a real project might evolve its structure from startup to scale, making the principles concrete.
Phase 1: MVP (3 months, 2 developers, 30 files)
src/
├── controllers/
│ ├── ProductController.ts
│ └── OrderController.ts
├── services/
│ ├── ProductService.ts
│ └── OrderService.ts
├── models/
│ ├── Product.ts
│ └── Order.ts
└── db.ts
Simple layered structure works well. The team knows every file, there's minimal navigation overhead, and the focus is on shipping features quickly.
Phase 2: Product-Market Fit (18 months, 8 developers, 200 files)
The layered directories have grown large. Services has 30+ files. New developers struggle to find code. Features are intertwined.
src/
├── catalog/ # Product catalog feature
│ ├── ProductController.ts
│ ├── ProductService.ts
│ ├── Product.ts
│ ├── Category.ts
│ └── CategoryService.ts
├── checkout/ # Checkout feature
│ ├── CartController.ts
│ ├── CartService.ts
│ ├── Cart.ts
│ ├── OrderController.ts
│ ├── OrderService.ts
│ └── Order.ts
├── payments/ # Payment integration
│ ├── PaymentController.ts
│ ├── PaymentService.ts
│ └── PaymentGateway.ts
├── users/ # User management
│ ├── UserController.ts
│ ├── UserService.ts
│ └── User.ts
└── shared/
├── middleware/
├── utils/
└── types/
The restructuring took 2 weeks of dedicated effort. Import paths changed across 150 files. But the result: clear feature ownership, reduced merge conflicts, faster onboarding.
Phase 3: Scale (4 years, 40 developers, 2000+ files)
The monolith is now too large for a single team to understand. Features need independent deployment. The decision: split into packages within a monorepo.
packages/
├── catalog-service/ # Independent deployable
│ ├── src/
│ │ ├── domain/
│ │ ├── application/
│ │ └── infrastructure/
│ ├── tests/
│ └── package.json
├── checkout-service/
│ ├── src/
│ │ ├── domain/
│ │ ├── application/
│ │ └── infrastructure/
│ ├── tests/
│ └── package.json
├── shared-kernel/ # Shared domain concepts
│ ├── src/
│ └── package.json
└── infrastructure-common/ # Shared infrastructure
├── src/
└── package.json
Each package has its own internal structure (hexagonal in this case). Shared code is extracted to dedicated packages. Teams own packages and deploy independently.
The team didn't try to build the Phase 3 structure at Phase 1—that would have been over-engineering. But they also didn't ignore structure entirely, which would have made restructuring at Phase 2 much harder. They invested appropriately at each stage, evolving structure in response to real growth and pain points.
We've covered extensive ground on project structure. Let's consolidate the essential takeaways:
What's next:
Now that we understand how to organize files and directories at the project level, the next page dives into package organization—how to group related components into coherent modules, manage dependencies between packages, and establish clear public APIs for each module.
You now understand project structure as a first-class design concern. You can evaluate and apply layered, feature-based, and hybrid structural patterns, and you know how to evolve structure as projects grow. Next, we'll explore package organization within these structures.