Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 023 / 100

Structure Spring Boot projects by feature for production

Package by feature, not by layer, and your codebase stays navigable.

Expona Admin8 min read

The problem with layer-based organization

Most Spring Boot tutorials teach you to organize code by technical layer: a controllers package, a services package, a repositories package. This feels intuitive at first, especially in small projects. But as your application grows, you end up with dozens of classes in each layer, and tracing a business feature across three packages becomes tedious. More importantly, you lose the ability to understand a feature in isolation. When you need to deprecate or refactor an order workflow, you're hunting through four different directories.

Structuring Spring Boot projects by feature for production means organizing your codebase around business domains, not technical patterns. Each feature gets its own package containing its controller, service, repository, and domain logic. This approach scales with teams, makes testing easier, and keeps related code together where it belongs.

One package per feature: orders, billing, users

Start by identifying the core features or bounded contexts in your application. In an e-commerce system, that might be orders, billing, users, inventory, and notifications. Each gets a top-level package under your application namespace.

Here's what a typical project structure looks like:

java

com.company.shop/
├── orders/
│   ├── OrderController.java
│   ├── OrderService.java
│   ├── OrderRepository.java
│   ├── Order.java
│   └── OrderStatus.java
├── billing/
│   ├── BillingController.java
│   ├── BillingService.java
│   ├── BillingRepository.java
│   ├── Invoice.java
│   └── PaymentMethod.java
├── users/
│   ├── UserController.java
│   ├── UserService.java
│   ├── UserRepository.java
│   ├── User.java
│   └── UserPreferences.java
└── shared/
    ├── config/
    ├── exception/
    └── util/

The shared package holds cross-cutting concerns: Spring configuration, global exception handlers, and utilities genuinely used by multiple features. If something belongs to only one feature, it belongs in that feature's package.

Why this structure works: when someone new joins your team and needs to understand the orders flow, they open the orders package and find everything they need. They don't need to puzzle through five different directories. Conversely, when you're adding a new feature, you know exactly where to put it and what neighboring code you might need to integrate with.

Controller, service and repository live together

In a feature-based package, keep the HTTP layer, business logic, and data access together. This is where the real power of this structure emerges.

Consider the orders feature:

java

package com.company.shop.orders;

import org.springframework.web.bind.annotation.*;
import org.springframework.stereotype.Service;
import org.springframework.data.jpa.repository.JpaRepository;

@RestController
@RequestMapping("/api/orders")
public class OrderController {
    private final OrderService orderService;
    
    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }
    
    @PostMapping
    public OrderResponse createOrder(@RequestBody CreateOrderRequest request) {
        Order order = orderService.createOrder(request);
        return new OrderResponse(order);
    }
    
    @GetMapping("/{id}")
    public OrderResponse getOrder(@PathVariable Long id) {
        Order order = orderService.findOrderById(id);
        return new OrderResponse(order);
    }
}

@Service
public class OrderService {
    private final OrderRepository orderRepository;
    
    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }
    
    public Order createOrder(CreateOrderRequest request) {
        Order order = new Order(request.getCustomerId(), request.getItems());
        order.calculateTotal();
        return orderRepository.save(order);
    }
    
    public Order findOrderById(Long id) {
        return orderRepository.findById(id)
            .orElseThrow(() -> new OrderNotFoundException(id));
    }
}

public interface OrderRepository extends JpaRepository {
    List findByCustomerId(Long customerId);
}

public class Order {
    private Long id;
    private Long customerId;
    private List items;
    private BigDecimal total;
    private OrderStatus status;
    
    // domain logic
    void calculateTotal() {
        this.total = items.stream()
            .map(OrderItem::getPrice)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

All four classes live in the same package. When you review the pull request for a new order feature, you see the complete picture: how the request enters, how the business logic works, how it persists. There are no surprises hiding in distant packages.

This structure also forces healthy dependencies. The controller depends on the service, the service depends on the repository, and the repository works with domain objects. You naturally avoid circular dependencies because the feature boundary makes them obvious.

Package-private classes hide internals

Java's package-private access modifier (the default) becomes a powerful tool when you organize by feature. Use it to hide implementation details that other features shouldn't know about.

For example, in the billing feature, you might have a PaymentProcessor that handles the actual credit card transactions. Only BillingService should use it. Mark PaymentProcessor as package-private:

java

package com.company.shop.billing;

// package-private - internal to billing feature only
class PaymentProcessor {
    private static final String GATEWAY_URL = "https://api.payment-gateway.com";
    
    PaymentResult process(Payment payment) {
        // Integration with external payment gateway
        // This complexity is hidden from the rest of the application
    }
}

@Service
public class BillingService {
    private final PaymentProcessor paymentProcessor;
    private final BillingRepository billingRepository;
    
    // Only BillingService exposes public methods
    public Invoice createInvoice(Order order) {
        Payment payment = new Payment(order.getTotal());
        PaymentResult result = paymentProcessor.process(payment);
        
        Invoice invoice = new Invoice(order, result);
        return billingRepository.save(invoice);
    }
}

Other features can call BillingService.createInvoice(), but they can't directly instantiate PaymentProcessor. This encapsulation prevents accidental coupling. If you need to refactor how payment processing works, you only touch code inside the billing package.

Use package-private for:

  • Internal domain classes that shouldn't be exposed
  • Helper utilities only used within the feature
  • Test doubles and mock implementations
  • Internal exceptions specific to the feature

Mark your public API explicitly with the public keyword on the service class. This makes the contract clear: the service is the entry point; everything else is implementation detail.

Cross-feature calls go through public services

When the orders feature needs to talk to billing, it does so through BillingService. It never reaches into the billing package's internal classes, repositories, or domain models.

Here's how orders triggers a billing workflow:

java

package com.company.shop.orders;

@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final com.company.shop.billing.BillingService billingService;
    
    public OrderService(
        OrderRepository orderRepository,
        com.company.shop.billing.BillingService billingService
    ) {
        this.orderRepository = orderRepository;
        this.billingService = billingService;
    }
    
    public Order createOrder(CreateOrderRequest request) {
        Order order = new Order(request.getCustomerId(), request.getItems());
        order.calculateTotal();
        Order saved = orderRepository.save(order);
        
        // Cross-feature call through public service only
        billingService.createInvoice(saved);
        
        return saved;
    }
}

The orders feature depends on the billing service interface, not on its internals. If billing adds a new PaymentProcessor or changes its data model, the orders feature isn't affected. This loose coupling scales as you add more features.

For complex inter-feature communication, consider using domain events. Instead of orders directly calling billing, the order service can publish an OrderCreatedEvent. The billing service listens for this event and creates an invoice. This decoupling makes the system more resilient and testable.

java

package com.company.shop.orders;

@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;
    
    public Order createOrder(CreateOrderRequest request) {
        Order order = new Order(request.getCustomerId(), request.getItems());
        Order saved = orderRepository.save(order);
        
        // Publish event instead of calling billing directly
        eventPublisher.publishEvent(new OrderCreatedEvent(saved.getId()));
        
        return saved;
    }
}

package com.company.shop.billing;

@Service
public class BillingService {
    private final BillingRepository billingRepository;
    
    @EventListener
    public void onOrderCreated(OrderCreatedEvent event) {
        Invoice invoice = new Invoice(event.getOrderId());
        billingRepository.save(invoice);
    }
}

Common mistakes

Putting everything in a shared utilities package. Resist the urge to create massive util packages. A formatting utility used only by orders belongs in the orders package, even if it's a small helper class. Shared should be genuinely shared: Spring config, security filters, global error handling.

Mixing features in the same package because "they're similar." Orders and returns might seem like they belong together, but they're distinct business features. They have different controllers, services, and domain models. Keep them separate.

Exposing internal classes as public. If you find yourself doing new BillingRepository() from the orders feature, you've broken the boundary. Go through the service instead.

Creating sub-packages for layers within a feature. Don't create orders/controllers, orders/services, orders/repositories. That defeats the purpose. Keep the feature flat.

Forgetting to test cross-feature interactions. When features communicate through services, write integration tests to verify the contract. A change in billing that breaks how orders calls it should fail a test.

Key takeaways

  • Organize Spring Boot projects by business feature, not by technical layer. Each feature gets its own package.
  • Collocate the controller, service, repository, and domain logic of a feature in the same package for discoverability and maintainability.
  • Use package-private classes to hide internal implementation details. Only the service is public.
  • Cross-feature communication happens through public service methods, not through direct access to repositories or internal classes.
  • Shared utilities, configuration, and exception handling live in a dedicated shared package. If something is specific to a feature, it belongs in that feature's package.
  • Consider domain events for complex inter-feature workflows to maintain loose coupling as your application grows.
  • Keep features at a similar level of granularity. Not every class needs its own package; not every feature should be a nested hierarchy.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles