Loading learning content...
Every software project eventually faces a critical architectural decision that will shape developer productivity for years to come: How should we organize our codebase?
This isn't merely an aesthetic choice. The way you structure your packages and modules determines how easily developers navigate the code, how changes propagate through the system, how teams collaborate without stepping on each other's toes, and ultimately, how quickly your organization can deliver value.
Two dominant paradigms have emerged in the industry: organization by layer (horizontal slicing by technical concern) and organization by feature (vertical slicing by business capability). Each carries profound implications for your system's evolution and your team's effectiveness.
By the end of this page, you will understand both organizational paradigms in depth, recognize their trade-offs, and develop the analytical framework to choose the right approach for your specific context. More importantly, you'll see that this isn't an either/or decision—sophisticated systems often blend both approaches strategically.
Organization by layer groups code according to its technical responsibility. This approach, deeply rooted in traditional multi-tier architecture, creates a horizontal stratification where each layer contains all components serving a similar technical purpose.
In a typical layer-based structure, you might see:
src/
├── controllers/
│ ├── UserController.ts
│ ├── OrderController.ts
│ └── ProductController.ts
├── services/
│ ├── UserService.ts
│ ├── OrderService.ts
│ └── ProductService.ts
├── repositories/
│ ├── UserRepository.ts
│ ├── OrderRepository.ts
│ └── ProductRepository.ts
├── models/
│ ├── User.ts
│ ├── Order.ts
│ └── Product.ts
└── utilities/
├── validators/
├── mappers/
└── helpers/
This structure reflects the technical topology of the application. All controllers live together, all services live together, all repositories live together—regardless of which business domain they serve.
The conceptual model:
Layer-based organization treats the application as a pipeline of technical transformations. A request enters through the presentation layer, gets processed by the business logic layer, persists through the data access layer, and returns through the same path. Each layer transforms data and delegates to the next.
This model is intuitive for developers familiar with web frameworks and follows a clear mental model of 'data flowing through layers.' It's also deeply aligned with how many educational materials and tutorials structure examples, making it immediately recognizable to new team members.
Organization by feature (also called vertical slicing or domain-based organization) groups code according to the business capability it supports. This approach creates a vertical structure where each feature or domain contains all technical components needed to implement that capability.
In a typical feature-based structure, you might see:
src/
├── features/
│ ├── user-management/
│ │ ├── UserController.ts
│ │ ├── UserService.ts
│ │ ├── UserRepository.ts
│ │ ├── User.ts
│ │ └── user.routes.ts
│ ├── order-processing/
│ │ ├── OrderController.ts
│ │ ├── OrderService.ts
│ │ ├── OrderRepository.ts
│ │ ├── Order.ts
│ │ └── order.routes.ts
│ └── product-catalog/
│ ├── ProductController.ts
│ ├── ProductService.ts
│ ├── ProductRepository.ts
│ ├── Product.ts
│ └── product.routes.ts
├── shared/
│ ├── database/
│ ├── logging/
│ └── authentication/
└── config/
This structure reflects the business topology of the application. All components related to 'user management' live together, all components related to 'order processing' live together—regardless of their technical layer.
The conceptual model:
Feature-based organization treats the application as a collection of business capabilities. Each feature is a small, self-contained unit that handles everything needed to deliver a specific business value. Features may depend on shared infrastructure, but they minimize dependencies on each other.
This model aligns with modern software development practices like Domain-Driven Design (DDD), microservices thinking (even in monoliths), and cross-functional team structures. It's particularly powerful when the organization prioritizes feature delivery speed and team autonomy.
Understanding the trade-offs between these approaches requires analyzing them across multiple dimensions. Neither approach is universally superior—each optimizes for different concerns and creates different pressures on the development process.
| Dimension | Layer-Based | Feature-Based |
|---|---|---|
| Change Locality | Changes to a feature require touching multiple directories across the codebase | Changes to a feature are localized to a single directory or module |
| Understanding a Feature | Requires navigating multiple directories to piece together implementation | All code for a feature is co-located, enabling quick comprehension |
| Adding a New Feature | Requires adding files to multiple existing directories | Create a new self-contained directory with internal structure |
| Technical Consistency | Easy to enforce consistent patterns within each layer | Requires discipline to maintain consistency across features |
| Team Coordination | Layers become bottlenecks; specialist teams create handoffs | Features can be owned end-to-end by cross-functional teams |
| Shared Code Discovery | Easy to find all utilities, all validators, etc. in their respective directories | Shared code must be explicitly placed in a shared/common module |
| Framework Alignment | Often matches framework conventions and tutorials | May require deviation from framework defaults |
| Microservice Readiness | Extracting a service requires identifying and collecting scattered pieces | Features are natural candidates for service extraction |
Robert C. Martin (Uncle Bob) introduced the concept of Screaming Architecture to emphasize that a codebase's structure should immediately communicate its purpose. The question to ask is:
"When you look at the top-level directory structure of your project, what does it scream about?"
Layer-based organization screams: "I'm a web application! I have controllers, services, and repositories!"
Feature-based organization screams: "I'm an e-commerce system! I handle users, orders, products, and payments!"
The principle argues that a codebase should announce its business domain, not its technical infrastructure. You shouldn't need to read code to understand what business problem the application solves—the folder structure should tell you.
Consider architectural blueprints for a building. When you see blueprints for a hospital, they scream 'hospital'—you see patient rooms, operating theaters, emergency departments. The blueprints don't scream 'building'—highlighting that it has floors, walls, and HVAC. Similarly, your codebase should scream its domain, not its technical infrastructure.
A new developer should open your project and immediately understand: 'This is a banking application' or 'This handles inventory management'—not merely 'This is a Spring Boot / Express / Django application.'
Implications of Screaming Architecture:
This principle doesn't just affect navigation—it shapes how developers think about the system. When the structure emphasizes technical layers, developers tend to think in technical terms: 'I need to add a controller, then a service, then a repository.' When the structure emphasizes features, developers think in business terms: 'I need to add order cancellation capability to the order processing feature.'
The second mental model keeps developers connected to business value and helps them make better architectural decisions because they're always thinking about the 'why' behind their code, not just the 'what' and 'how.'
In practice, most sophisticated systems adopt a hybrid approach that combines the strengths of both paradigms. The key insight is that different levels of the architecture may benefit from different organizational strategies.
A common hybrid pattern:
src/
├── modules/ # Feature-based at the top level
│ ├── user-management/
│ │ ├── api/ # Layer-based within each feature
│ │ │ └── UserController.ts
│ │ ├── application/
│ │ │ └── UserService.ts
│ │ ├── domain/
│ │ │ ├── User.ts
│ │ │ └── UserRepository.ts (interface)
│ │ └── infrastructure/
│ │ └── PostgresUserRepository.ts
│ ├── order-processing/
│ │ ├── api/
│ │ ├── application/
│ │ ├── domain/
│ │ └── infrastructure/
│ └── product-catalog/
│ ├── api/
│ ├── application/
│ ├── domain/
│ └── infrastructure/
├── shared-kernel/ # Cross-cutting domain concepts
│ ├── money/
│ ├── address/
│ └── pagination/
└── infrastructure/ # Technical infrastructure
├── database/
├── messaging/
└── logging/
This structure provides feature isolation at the top level while maintaining architectural clarity within each feature through layering.
You don't need to start with the full hybrid structure on day one. Begin with feature-based organization at the top level. As features grow complex, introduce internal layering. As shared patterns emerge, extract them to shared modules. Let the structure evolve with the system's needs rather than front-loading complexity.
Regardless of which organizational approach you choose, one principle must remain inviolable: dependencies flow inward toward the core domain, never outward toward infrastructure or delivery mechanisms.
This is the Dependency Rule from Clean Architecture, and it applies at the package/module level just as it does at the class level.
What this means in practice:
123456789101112131415161718192021222324252627282930313233
// domain/UserRepository.ts (Interface - no external dependencies)export interface UserRepository { findById(id: UserId): Promise<User | null>; save(user: User): Promise<void>;} // infrastructure/PostgresUserRepository.ts (Implementation - depends on domain)import { UserRepository } from '../domain/UserRepository';import { User, UserId } from '../domain/User';import { Pool } from 'pg'; // Infrastructure dependency export class PostgresUserRepository implements UserRepository { constructor(private pool: Pool) {} async findById(id: UserId): Promise<User | null> { const result = await this.pool.query( 'SELECT * FROM users WHERE id = $1', [id.value] ); return result.rows[0] ? this.toDomain(result.rows[0]) : null; } async save(user: User): Promise<void> { await this.pool.query( 'INSERT INTO users (id, email, name) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET email = $2, name = $3', [user.id.value, user.email.value, user.name] ); } private toDomain(row: any): User { // Map database row to domain object }}Visualizing the dependency flow:
Imagine your modules as concentric circles. The domain is at the center. Application logic surrounds it. Infrastructure and delivery mechanisms form the outer rings. Dependencies always point inward, never outward.
This rule ensures that your core business logic remains pure, testable, and independent of the delivery mechanism (REST, GraphQL, CLI) and persistence technology (PostgreSQL, MongoDB, in-memory). You can swap infrastructure without touching domain code.
When deciding how to organize your codebase, use this decision framework to guide your choice:
| Project Characteristic | Recommended Approach |
|---|---|
| Small team, few features, rapid prototyping | Layer-based (simpler to start) |
| Growing application, multiple features | Feature-based with internal layering |
| Large team, many features, long-term product | Full hybrid with shared kernel |
| Brownfield migration from legacy | Gradual feature extraction from existing layers |
| Framework-heavy CRUD application | Layer-based following framework conventions |
| Domain-rich application with complex rules | Feature-based aligned with bounded contexts |
Don't over-engineer your structure upfront. A simple application with three features doesn't need a full hybrid architecture. Start simple, measure pain points, and evolve the structure as the system and team grow. The goal is to reduce friction, not to follow an architectural pattern for its own sake.
Let's consolidate the key insights from this exploration of code organization:
What's next:
Organization at the package level is only part of the picture. In the next page, we'll explore package coupling and cohesion—the metrics and principles that help you evaluate whether your package structure is healthy and identify when restructuring is needed.
You now understand the fundamental paradigms for organizing code at the package and module level. More importantly, you have a framework for making this decision based on your specific context rather than following dogmatic rules. Next, we'll dive into the metrics that help you evaluate and refine your package design.