Why Component-Based Structure Beats Traditional Package-by-Layer in Java
Introduction
If you've worked on Java projects—especially Spring Boot applications—you've probably seen this structure:
src/main/java/com/company/app/
├── controller/
│ ├── UserController.java
│ ├── OrderController.java
│ └── ProductController.java
├── service/
│ ├── UserService.java
│ ├── OrderService.java
│ └── ProductService.java
├── repository/
│ ├── UserRepository.java
│ ├── OrderRepository.java
│ └── ProductRepository.java
└── model/
├── User.java
├── Order.java
└── Product.javaThis is package-by-layer (or package-by-technical-concern): organizing code by its role in the application architecture. It feels natural—controllers go with controllers, services with services.
But there's a better way: package-by-feature (or component-based structure). After years of building and maintaining Spring Boot microservices, I've learned that organizing by business capability instead of technical layer produces code that's easier to understand, modify, and scale.
Here's why.
What Is Component-Based Structure?
Instead of grouping by layer globally, you group by feature or business component, with layers inside each component:
src/main/java/com/company/app/
├── user/
│ ├── api/
│ │ └── UserController.java
│ ├── dto/
│ │ ├── UserDto.java
│ │ ├── UserRequest.java
│ │ └── UserResponse.java
│ ├── domain/
│ │ ├── User.java
│ │ └── UserRole.java
│ ├── service/
│ │ ├── UserService.java
│ │ └── UserServiceImpl.java
│ └── repository/
│ └── UserRepository.java
├── order/
│ ├── api/
│ │ └── OrderController.java
│ ├── dto/
│ │ ├── OrderDto.java
│ │ ├── OrderRequest.java
│ │ └── OrderResponse.java
│ ├── domain/
│ │ ├── Order.java
│ │ ├── OrderItem.java
│ │ └── OrderStatus.java
│ ├── service/
│ │ ├── OrderService.java
│ │ └── OrderServiceImpl.java
│ └── repository/
│ ├── OrderRepository.java
│ └── OrderItemRepository.java
└── product/
├── api/
│ └── ProductController.java
├── dto/
│ ├── ProductDto.java
│ └── ProductRequest.java
├── domain/
│ ├── Product.java
│ └── ProductCategory.java
├── service/
│ ├── ProductService.java
│ └── ProductServiceImpl.java
└── repository/
└── ProductRepository.javaEach top-level package is a self-contained module representing a business capability. Everything related to "user" lives in the user package, with internal layering for organization.
Internal Structure: Layered Sub-Packages
Within each component, organize classes by technical layer using sub-packages:
Recommended Component Structure
order/
├── api/ # REST controllers
│ ├── OrderController.java
│ └── OrderEventListener.java
├── dto/ # Data Transfer Objects, requests, responses
│ ├── OrderDto.java
│ ├── OrderRequest.java
│ ├── OrderResponse.java
│ ├── OrderItemDto.java
│ └── CreateOrderRequest.java
├── domain/ # Domain entities, value objects, enums
│ ├── Order.java
│ ├── OrderItem.java
│ ├── OrderStatus.java
│ └── OrderValidator.java
├── service/ # Business logic, orchestration
│ ├── OrderService.java
│ ├── OrderServiceImpl.java
│ ├── OrderPricingService.java
│ └── OrderNotificationService.java
├── repository/ # Data access layer
│ ├── OrderRepository.java
│ └── OrderItemRepository.java
└── exception/ # Component-specific exceptions
├── OrderNotFoundException.java
└── InvalidOrderException.javaPackage Responsibilities
- api/ - REST controllers, GraphQL resolvers, event listeners. Entry points for external requests.
- dto/ - Data Transfer Objects used for API requests/responses and inter-module communication. Separate from domain entities to avoid coupling.
- domain/ - Core business entities (JPA entities), value objects, enums, and domain validators. This is your business model.
- service/ - Business logic, orchestration, and use cases. Services coordinate between repositories, external services, and domain logic.
- repository/ - Data access layer (Spring Data JPA repositories, custom queries). Abstracts database operations.
- exception/ - Component-specific custom exceptions.
Why Separate DTOs from API Controllers?
- Reusability: DTOs are used by controllers but also by services when communicating with other modules
- Clear separation: API layer (controllers) is about HTTP/routing, DTOs are about data contracts
- Easier refactoring: Can change DTO structure without touching controllers
- Inter-module contracts: Other modules import DTOs to use the component's API
Why Layered Sub-Packages?
- Clear separation of concerns: API layer distinct from business logic distinct from data access
- Easier to navigate: Controllers always in
api/, services inservice/, DTOs indto/ - Better encapsulation: Can mark entire layers package-private (e.g.,
service/OrderServiceImpl.java) - Scalable: As components grow, the structure remains organized
- Team alignment: Developers know exactly where to find and place code
The Key Difference
This is fundamentally different from package-by-layer:
- Package-by-layer: Global
service/package containsUserService,OrderService,ProductServiceall together - Component-based:
user/service/UserService.javaandorder/service/OrderService.javaare isolated in their respective components
The layers exist within component boundaries, not across the entire application. This prevents unrelated services from accessing each other's internals.
Spring Boot Modulith: Enforcing Component Boundaries
Spring Boot Modulith is a framework that helps you build well-structured modular monoliths with component-based architecture. It validates and enforces architectural boundaries at compile time and runtime.
What Is Spring Boot Modulith?
Modulith treats each top-level package (e.g., user, order, product) as an application module with explicit boundaries. It ensures:
- Modules only communicate through defined APIs (typically public interfaces and DTOs)
- Internal implementation details remain hidden
- No circular dependencies between modules
- Clean architectural layers
Setting Up Spring Boot Modulith
Add the dependency:
<dependency>
<groupId>org.springframework.experimental</groupId>
<artifactId>spring-modulith-starter-core</artifactId>
<version>1.1.0</version>
</dependency>
<dependency>
<groupId>org.springframework.experimental</groupId>
<artifactId>spring-modulith-starter-test</artifactId>
<version>1.1.0</version>
<scope>test</scope>
</dependency>Defining Module Boundaries
Each component becomes a module. Only classes in certain packages are exposed externally:
user/
├── api/ # PUBLIC - controllers exposed as beans
├── dto/ # PUBLIC - exposed to other modules
│ ├── UserDto.java
│ └── UserRequest.java
├── domain/ # INTERNAL - not accessible outside
│ └── User.java
├── service/ # PUBLIC interfaces, INTERNAL implementations
│ ├── UserService.java (public interface)
│ └── UserServiceImpl.java (package-private)
└── repository/ # INTERNAL
└── UserRepository.javaCreate package-info.java to explicitly define what's public:
// user/package-info.java
@org.springframework.modulith.ApplicationModule(
allowedDependencies = "shared"
)
package com.company.app.user;By default, Modulith exposes:
- Classes in the module root package
- Classes in packages named
api,dto, orspi
Everything else (service, repository, domain) is internal and cannot be accessed by other modules.
Validating Architecture with Tests
@Modulithtest
class ModularityTests {
@Test
void verifyModularity(ApplicationModules modules) {
// Verifies that modules only access each other through APIs
modules.verify();
}
@Test
void ensureNoCycles(ApplicationModules modules) {
// Fails if circular dependencies exist
modules.verify();
}
@Test
void documentModules(ApplicationModules modules) {
// Generates documentation of module structure
new Documenter(modules)
.writeDocumentation()
.writeIndividualModulesAsPlantUml();
}
}These tests fail at build time if:
- A module accesses another module's internals (e.g.,
orderdirectly usinguser.repository.UserRepository) - Circular dependencies exist (
userdepends onorder, andorderdepends onuser) - Architectural rules are violated
Module Communication
Modules communicate through:
1. Direct API calls (synchronous):
// order/service/OrderServiceImpl.java
@Service
class OrderServiceImpl implements OrderService {
private final UserService userService; // from user.service package (public interface)
@Override
public OrderDto createOrder(CreateOrderRequest request) {
// Using UserDto from user.dto package
UserDto user = userService.getById(request.getUserId()); // OK - using public API
// Create order and return OrderDto (from order.dto package)
return orderMapper.toDto(order);
}
}2. Application Events (asynchronous):
// order/service/OrderServiceImpl.java
@Service
class OrderServiceImpl {
private final ApplicationEventPublisher events;
public OrderDto placeOrder(CreateOrderRequest request) {
Order order = // create order
// Publish event using DTO
events.publishEvent(new OrderPlacedEvent(
order.getId(),
order.getUserId(),
order.getTotalAmount()
));
return orderMapper.toDto(order);
}
}
// user/service/UserPointsService.java
@Service
class UserPointsService {
@ApplicationModuleListener
void on(OrderPlacedEvent event) {
// Award points to user
// This runs asynchronously in a separate transaction
// Event contains only necessary data, not full domain entities
}
}Modulith tracks these events and ensures they're delivered reliably, even across transaction boundaries.
Benefits of Spring Boot Modulith
- Enforced boundaries: Modules can't accidentally access each other's internals
- Public API through DTOs: Inter-module communication uses DTOs, not domain entities
- Documentation generation: Auto-generates module diagrams and dependency graphs
- Easier refactoring: Clear contracts make it safe to change internals
- Migration path to microservices: Each module can be extracted independently
- Event-driven architecture: Built-in support for asynchronous communication
Why Component-Based Structure Wins
1. High Cohesion, Low Coupling
In package-by-layer, related classes are scattered across multiple packages. To understand the "user" feature, you need to jump between controller, service, repository, model, and dto packages. This is low cohesion—things that change together aren't stored together.
In component-based structure, everything that changes together is in the same top-level package. When you modify user functionality, all relevant code is in the user package. High cohesion reduces cognitive load and makes changes faster.
2. Easier Navigation and Discoverability
Imagine you're new to a codebase and need to understand how user registration works. With package-by-layer:
- Open
UserController.javain thecontrollerpackage - See it uses
UserRequestfromdtopackage - Navigate to find
UserServicein theservicepackage - See it uses
UserRepositoryin therepositorypackage - Check the
modelpackage forUser.javaentity
With component-based structure:
- Open the
userpackage - See all user-related code:
api,dto,service,repository,domain - Navigate directly within the component
The second approach is objectively faster.
3. Better Encapsulation and Access Control
Java's package-private access modifier makes classes visible only within their package. This is a powerful encapsulation tool—but it's wasted in package-by-layer.
With component-based structure, you can mark implementation details as package-private:
// user/service/UserService.java (PUBLIC interface)
public interface UserService {
UserDto register(UserRequest request);
UserDto getById(Long id);
}
// user/service/UserServiceImpl.java (INTERNAL implementation)
@Service
class UserServiceImpl implements UserService { // package-private!
// Only accessible within the user module
@Override
public UserDto register(UserRequest request) {
// Uses UserDto from user.dto package (public)
// Uses User entity from user.domain package (internal)
}
}Other components can depend on user.service.UserService (public interface) and user.dto.UserDto (public DTO) but can't access user.service.UserServiceImpl or user.domain.User directly. This enforces cleaner boundaries.
4. Easier to Extract Microservices
If you decide to extract a feature into its own microservice, component-based structure makes it trivial. The order package is already self-contained—just:
- Copy the
orderpackage to a new project - Add a
@SpringBootApplicationmain class - Replace internal calls to other modules with REST/messaging clients (they already use DTOs!)
- Deploy independently
Since inter-module communication already uses DTOs rather than domain entities, converting to REST APIs is straightforward—just expose the DTOs over HTTP.
5. Reduced Merge Conflicts
In package-by-layer, multiple developers working on different features often touch the same packages. Developer A works on user registration (modifying UserController, UserDto, UserService) while Developer B works on order processing (modifying OrderController, OrderDto, OrderService). Both are changing files in the same controller, dto, and service packages.
With component-based structure, Developer A works entirely in user/ while Developer B works in order/. Cleaner separation, fewer conflicts.
6. Clearer Dependency Rules
Package-by-layer obscures dependencies between features. Does UserService depend on OrderService? You have to read the code to find out.
With component-based structure, cross-component dependencies are obvious:
import com.company.app.order.service.OrderService;
import com.company.app.order.dto.OrderDto;inside the user package immediately signals a dependency from user to order. With Spring Boot Modulith, you can even forbid such dependencies if they violate your architecture.
Real-World Example: E-Commerce Application
Let's compare both approaches for a simple e-commerce app with users, products, and orders.
Package-by-Layer (Traditional)
src/main/java/com/shop/
├── controller/
│ ├── UserController.java
│ ├── ProductController.java
│ └── OrderController.java
├── dto/
│ ├── UserDto.java
│ ├── ProductDto.java
│ └── OrderDto.java
├── service/
│ ├── UserService.java
│ ├── ProductService.java
│ ├── OrderService.java
│ └── EmailService.java
├── repository/
│ ├── UserRepository.java
│ ├── ProductRepository.java
│ └── OrderRepository.java
└── model/
├── User.java
├── Product.java
├── Order.java
├── OrderItem.java
└── Address.javaProblems:
- To understand the order flow, you need to look in 5 different packages
EmailServiceis buried alongside business services—unclear that it's infrastructure- Can't tell at a glance which features exist in the system
- Adding a new feature (e.g., "cart") means touching 5+ packages
- No enforcement of architectural boundaries
OrderDtosits in the same package asUserDtoandProductDto
Component-Based Structure with Spring Boot Modulith
src/main/java/com/shop/
├── user/
│ ├── api/
│ │ └── UserController.java
│ ├── dto/
│ │ ├── UserDto.java
│ │ ├── UserRequest.java
│ │ └── UserResponse.java
│ ├── domain/
│ │ ├── User.java
│ │ └── Address.java
│ ├── service/
│ │ ├── UserService.java
│ │ └── UserServiceImpl.java
│ └── repository/
│ └── UserRepository.java
├── product/
│ ├── api/
│ │ └── ProductController.java
│ ├── dto/
│ │ ├── ProductDto.java
│ │ └── ProductRequest.java
│ ├── domain/
│ │ ├── Product.java
│ │ └── ProductCategory.java
│ ├── service/
│ │ ├── ProductService.java
│ │ └── ProductServiceImpl.java
│ └── repository/
│ └── ProductRepository.java
├── order/
│ ├── api/
│ │ └── OrderController.java
│ ├── dto/
│ │ ├── OrderDto.java
│ │ ├── OrderRequest.java
│ │ ├── OrderResponse.java
│ │ └── OrderItemDto.java
│ ├── domain/
│ │ ├── Order.java
│ │ ├── OrderItem.java
│ │ └── OrderStatus.java
│ ├── service/
│ │ ├── OrderService.java
│ │ ├── OrderServiceImpl.java
│ │ └── OrderPricingService.java
│ └── repository/
│ ├── OrderRepository.java
│ └── OrderItemRepository.java
└── shared/
├── email/
│ └── EmailService.java
├── config/
│ └── SecurityConfig.java
└── exception/
└── GlobalExceptionHandler.javaBenefits:
- The system's features are visible at a glance: user, product, order
- To understand orders, open the
ordermodule—everything is there - DTOs are organized by feature:
OrderDtolives with order logic - Shared infrastructure (
EmailService, config) is clearly separated - Adding a "cart" feature means creating one new module
- Spring Boot Modulith validates boundaries at build time
- Auto-generated documentation shows module dependencies
Migration Strategy
If you have an existing package-by-layer codebase, here's how to migrate incrementally:
- Add Spring Boot Modulith dependency
- Start with new features: Organize them by component from day one
- Refactor one feature at a time: Pick a small, self-contained feature (e.g., "notification") and move all related classes into a component package with
api/,dto/,service/,repository/,domain/sub-packages - Use package-private visibility: Mark implementation classes (
*Impl) as package-private - Add Modulith tests: Verify the refactored module respects boundaries
- Gradually convert the rest: One module at a time
You don't need to refactor everything overnight. Even having 20% of your code organized by component is better than 0%.
When to Use Each Approach
Use Component-Based Structure When:
- Building business applications with distinct features (most cases)
- Working on modular monoliths or preparing to extract microservices
- You have a medium-to-large codebase with multiple developers
- You value modularity, testability, and ease of navigation
- You want enforced architectural boundaries
Use Package-by-Layer When:
- Building a very small application (< 10 classes)
- You have a genuinely technical library or framework (not a business application)
- Rapid prototyping where structure doesn't matter yet
For 90% of Java applications, component-based structure is the better choice.
The Bottom Line
Package-by-layer is a legacy habit from the early days of enterprise Java. It made sense when codebases were smaller and IDEs were weaker. But modern Java development—especially with Spring Boot and Modulith—benefits massively from component-based structure.
Grouping by feature instead of layer gives you:
- Higher cohesion: Related code is together
- Easier navigation: Features are self-contained modules
- Better encapsulation: Package-private visibility actually works
- Enforced boundaries: Spring Boot Modulith validates architecture
- Clearer architecture: Dependencies are visible and controlled
- Faster development: Less jumping between packages
- Better DTO organization: DTOs grouped by feature, not globally
- Migration path: Easy extraction to microservices using existing DTOs
If you're starting a new Java project, organize by component with layered sub-packages (including separate dto/) and use Spring Boot Modulith. If you're maintaining an existing one, start migrating—one module at a time.
Your future self (and your teammates) will thank you.
Further Reading
- Spring Boot Modulith - Official documentation
- ArchUnit - Enforce architectural rules in Java
- Hexagonal Architecture - Takes component-based structure further
- Robert C. Martin's Clean Architecture - Discusses organizing by use case
Comments (0)