Back to Blog

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.java

This 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.java

Each 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.java

Package 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 in service/, DTOs in dto/
  • 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 contains UserService, OrderService, ProductService all together
  • Component-based: user/service/UserService.java and order/service/OrderService.java are 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.java

Create 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, or spi

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., order directly using user.repository.UserRepository)
  • Circular dependencies exist (user depends on order, and order depends on user)
  • 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:

  1. Open UserController.java in the controller package
  2. See it uses UserRequest from dto package
  3. Navigate to find UserService in the service package
  4. See it uses UserRepository in the repository package
  5. Check the model package for User.java entity

With component-based structure:

  1. Open the user package
  2. See all user-related code: api, dto, service, repository, domain
  3. 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:

  1. Copy the order package to a new project
  2. Add a @SpringBootApplication main class
  3. Replace internal calls to other modules with REST/messaging clients (they already use DTOs!)
  4. 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.java

Problems:

  • To understand the order flow, you need to look in 5 different packages
  • EmailService is 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
  • OrderDto sits in the same package as UserDto and ProductDto

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.java

Benefits:

  • The system's features are visible at a glance: user, product, order
  • To understand orders, open the order module—everything is there
  • DTOs are organized by feature: OrderDto lives 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:

  1. Add Spring Boot Modulith dependency
  2. Start with new features: Organize them by component from day one
  3. 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
  4. Use package-private visibility: Mark implementation classes (*Impl) as package-private
  5. Add Modulith tests: Verify the refactored module respects boundaries
  6. 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

Comments (0)