← Back to Blog

Why Component-Based Structure Beats Traditional Package-by-Layer in Java

Why organizing Java code by feature instead of by layer (controller/service/repository) gives you better modularity, easier navigation, and real encapsulation.

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: organizing code by its role in the application architecture. Controllers go with controllers, services with services. Feels natural at first.

But there's a better way. After years of building and maintaining Spring Boot services, I'm convinced that organizing by business capability beats organizing by technical layer. Easier to understand, easier to modify, easier to 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 represents 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 for API requests/responses and inter-module communication. Kept separate from domain entities to avoid coupling.
  • domain/: Business entities (JPA entities), value objects, enums, and domain validators.
  • service/: Business logic, orchestration, and use cases. Coordinates calls across repositories, external services, and domain logic.
  • repository/: Data access layer (Spring Data JPA repositories, custom queries).
  • exception/: Component-specific custom exceptions.

Why Separate DTOs from API Controllers?

  • Controllers use DTOs, but services do too when communicating with other modules
  • API layer handles HTTP and routing. DTOs define data contracts. Different concerns.
  • DTO structure can change without touching controllers
  • Other modules import DTOs to use the component's API

Why Layered Sub-Packages?

  • API layer stays distinct from business logic, which stays distinct from data access
  • Controllers are always in api/, services in service/, DTOs in dto/. No guessing.
  • You can mark entire layers package-private (e.g., service/OrderServiceImpl.java)
  • As components grow, the structure stays organized
  • Everyone on the team knows 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 helps you build modular monoliths with clear component boundaries. It validates and enforces those 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 enforces:

  • Modules only communicate through defined APIs (usually 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 delivers them reliably, even across transaction boundaries.

Benefits of Spring Boot Modulith

  • Modules can't accidentally access each other's internals
  • Inter-module communication uses DTOs, not domain entities
  • Auto-generates module diagrams and dependency graphs
  • Clear contracts make it safe to change internals
  • Each module can be extracted into a microservice independently
  • Built-in support for asynchronous event-driven 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. Things that change together aren't stored together.

In component-based structure, everything that changes together lives in the same top-level package. When you modify user functionality, all the relevant code is in the user package. Less jumping around, less cognitive load.

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. It's a great 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 easy. The order package is already self-contained:

  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 instead of domain entities, converting to REST APIs is just a matter of exposing those 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). Each is 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, making it 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 purely 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.

Start Organizing by Feature

Package-by-layer is a leftover 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, works much better with component-based structure.

Grouping by feature instead of layer gives you:

  • Related code lives together
  • Features are self-contained modules
  • Package-private visibility actually works
  • Spring Boot Modulith can validate your architecture
  • Dependencies are visible and controlled
  • Less jumping between packages
  • DTOs grouped by feature, not thrown into a global bucket
  • Clean extraction to microservices when you need it

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

Share
X LinkedIn HN
UI

Umur Inan

Principal Software Engineer

Backend engineer focused on JVM systems, distributed architecture, and the failure modes that only show up in production. I write about what I learn building and breaking things at scale.

👁 0 10 min read

Comments (0)