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.

Syntaxcom.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 @SpringBootApplication in 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 Clock bean, security configuration, error handling) can sit in a small config or shared package. Keep it small: a growing common package 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

Example · java
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.
@Entity
class Product {
    @Id
    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.
@RestController
class ProductController {

    private final CatalogService catalog;

    ProductController(CatalogService catalog) {
        this.catalog = catalog;
    }

    @GetMapping("/api/products/{sku}")
    ResponseEntity<CatalogService.ProductView> get(@PathVariable String sku) {
        return ResponseEntity.of(catalog.find(sku));   // 404 when the SKU is unknown or inactive
    }
}

// The one public door into the catalog feature.
@Service
@Transactional(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.

Example · text
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.

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

Example · java
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

  • Be the first to comment on this lesson.