Package by Feature
Group code by business feature instead of by technical layer, and use Java's package-private access to hide everything a feature does not need to share.
com.example.app
App.java // @SpringBootApplication at the root
feature/
public FeatureService // the door other features use
public record FeatureView
Entity, Repository, Controller (package-private)Many projects start with one package per layer: controller, service, repository, dto. It looks tidy at ten classes. At two hundred, one change to orders touches five packages, and every class has to be public because its callers live in another package.
The alternative
Package by feature puts everything for one business capability together:
com.example.shop
ShopApplication.java
catalog/
Product.java ProductRepository.java CatalogService.java ProductController.java
orders/
Order.java OrderRepository.java OrderService.java OrderController.java OrderPlaced.java
customers/
...The layers still exist inside each package. What changes is that the package is now the unit of change: adding a field to orders touches one folder, and deleting a feature is deleting a folder.
Package-private is the point
Java's default access (no modifier) means visible inside the same package only. Inside orders, the entity, the repository and the controller can all be package-private. Only the types other features genuinely need, usually a service and a few records, are public. The compiler then stops catalog from reaching into OrderRepository and bypassing the rules in OrderService.
Spring copes with this fine. Component scanning finds package-private classes, constructor injection works with package-private constructors, Spring Data creates repositories for package-private interfaces, and controller methods do not need to be public.
Rules that keep it working
- Keep the main class with
@SpringBootApplicationin the root package, so component scanning and entity scanning cover every feature package below it. - Name packages after the business (
billing,shipping), not after technology (kafka,utils). - Talk to another feature through its public service or through events, never through its repository or entities.
- Avoid sub-packages per layer inside a feature (
orders.service,orders.web). Java has no access level for "this package and its children", so splitting forces things public again. - Shared technical code (a
Clockbean, security configuration, error handling) can sit in a smallconfigorsharedpackage. Keep it small: a growingcommonpackage is a layered architecture coming back.
When features need each other
Some coupling is real: an order needs a product price. Expose a narrow public method such as CatalogService.priceOf(sku) that returns a record, not the Product entity. If two packages keep calling each other in both directions, the boundary is probably in the wrong place. Spring Modulith, covered later in this stage, can verify these rules in a test so they do not erode quietly.
Example
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.util.Optional;
import org.springframework.http.ResponseEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
// Everything below would live in com.example.shop.catalog.
// Package-private: no other feature can touch the entity.
class Product {
private String sku;
private String name;
private long priceCents;
private boolean active;
protected Product() {
}
String getSku() { return sku; }
String getName() { return name; }
long getPriceCents() { return priceCents; }
boolean isActive() { return active; }
}
// Package-private: other features cannot bypass the service.
interface ProductRepository extends JpaRepository<Product, String> {
}
// Package-private controller: Spring still finds and maps it.
class ProductController {
private final CatalogService catalog;
ProductController(CatalogService catalog) {
this.catalog = catalog;
}
("/api/products/{sku}")
ResponseEntity<CatalogService.ProductView> get( String sku) {
return ResponseEntity.of(catalog.find(sku)); // 404 when the SKU is unknown or inactive
}
}
// The one public door into the catalog feature.
(readOnly = true)
public class CatalogService {
public record ProductView(String sku, String name, long priceCents) {
}
private final ProductRepository products;
CatalogService(ProductRepository products) {
this.products = products;
}
public Optional<ProductView> find(String sku) {
return products.findById(sku)
.filter(Product::isActive)
.map(p -> new ProductView(p.getSku(), p.getName(), p.getPriceCents()));
}
public long priceOf(String sku) {
return find(sku).orElseThrow(() -> new IllegalArgumentException("Unknown SKU " + sku))
.priceCents();
}
}
When to use it
- A team that keeps editing five layer packages for every change to invoices moves invoice code into one billing package.
- A developer makes OrderRepository package-private and the compiler finds three places in other features that skipped the order rules.
- Removing a retired loyalty feature becomes deleting one package and its migration instead of hunting through every layer.
More examples
Layered versus feature layout
On the left every type must be public, because each layer sits in a different package from its callers, so nothing stops <code>OrderService</code> from calling <code>ProductRepository</code> directly. On the right only two types per feature are public. A change to how orders are stored stays inside <code>orders</code>, and a code review can see the whole feature in one folder.
Package by layer Package by feature
---------------- ------------------
com.example.shop com.example.shop
controller/ ShopApplication
OrderController (public) orders/
ProductController (public) OrderController
service/ OrderService (public)
OrderService (public) Order
CatalogService (public) OrderRepository
repository/ catalog/
OrderRepository (public) ProductController
ProductRepository (public) CatalogService (public)
entity/ Product
Order (public) ProductRepository
Product (public)
One feature using another through its public service
The orders feature depends on one method of the catalog, not on <code>Product</code> or <code>ProductRepository</code>. If the catalog later moves prices into a separate table or a cache, orders does not change. Depending on a narrow method also makes the dependency easy to see in a review and easy to stub in a unit test.
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
// Stand-in for com.example.shop.catalog.CatalogService (public in its own package).
interface CatalogPrices {
long priceOf(String sku);
}
record OrderLineRequest(String sku, int quantity) {
}
record OrderTotal(long totalCents) {
}
@Service
public class OrderPricing {
private final CatalogPrices catalog;
OrderPricing(CatalogPrices catalog) {
this.catalog = catalog;
}
@Transactional(readOnly = true)
public OrderTotal total(List<OrderLineRequest> lines) {
long total = lines.stream()
.mapToLong(l -> catalog.priceOf(l.sku()) * l.quantity())
.sum();
return new OrderTotal(total);
}
}
Shared configuration in its own small package
Cross-cutting beans such as a <code>Clock</code>, security rules or a global <code>@RestControllerAdvice</code> do not belong to any one feature. A small <code>config</code> package below the root keeps them scanned without inventing a fake feature. If this package starts collecting business helpers, move each helper to the feature that owns the concept.
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
// com.example.shop.config: technical beans every feature may use.
@Configuration(proxyBeanMethods = false)
public class SharedConfig {
@Bean
Clock clock() {
return Clock.systemUTC();
}
}
Discussion