Foundation · Build
Organizing Packages: Feature vs Layer
Package-by-layer groups all controllers together; package-by-feature groups one capability end to end—each scales differently as the codebase grows.
Primary source: Spring Boot 3.5: Structuring Your Code & Packaging
Lesson 2 of 2
1d streakThe Architectural Question
Layer-first packages make every feature edit cross five directories, and nothing marks where one capability ends and another begins.
What this guide proves
- Package-by-layer
- Package-by-feature
- Module boundaries
Package-by-Feature vs Package-by-Layer: The architectural trade-off
How you organize packages is one of the earliest decisions in a Spring Boot application, and it determines how your codebase scales. Traditional package-by-layer groups classes by technical role (all controllers together, all services together, all repositories together). Package-by-feature groups classes by cohesive domain capability (everything related to orders in one place).
Architectural Boundary · Do Not Confuse
Package-by-Layer (Technical Silos)
Package-by-Feature (Vertical Slices)
Under package-by-layer, modifying a single feature (e.g. adding a field to Order) forces you to navigate 4-5 different top-level folders. Worse, classes must be declared public, destroying Java package encapsulation. Package-by-feature colocates the controller, service, repository, and DTOs within com.example.orders, allowing package-private visibility to protect internal collaborators.
| Criteria | Package-by-Layer | Package-by-Feature |
|---|---|---|
| Locality of Change | Poor (touches controllers, services, repositories folders) | Excellent (all changes stay within the feature folder) |
| Encapsulation | Forced public visibility on almost all classes | Package-private (default) hides repositories and domain internals |
| Microservice Extraction Seam | Very difficult (scattered throughout codebase) | Trivial (isolate or extract the feature package) |
| Navigation Cognitive Load | High (must search across the entire project tree) | Low (one directory contains the entire capability) |
Leveraging Java package-private visibility as a boundary tool
In Java, the default visibility (no modifier) is package-private: the class or method is accessible only to classes within the exact same package. Package-by-feature allows you to make your Repository and internal domain classes package-private, guaranteeing that neighboring features cannot bypass the public Service facade.
- 01Declare the feature service public if it needs to be invoked by other feature modules.
- 02Keep the repository and internal entity package-private (e.g., class OrderRepository extends JpaRepository).
- 03Spring component scanning and dependency injection work seamlessly with package-private classes.
- 04Other packages cannot import or instantiate the repository directly, enforcing that all data access goes through reviewed service boundaries.
Structure of a vertical feature package
package com.example.orders;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;
import org.springframework.data.jpa.repository.JpaRepository;
import jakarta.persistence.*;
import java.util.UUID;
// 1. Public HTTP Entry Point
@RestController
@RequestMapping("/api/orders")
class OrderController {
private final OrderService service;
OrderController(OrderService service) { this.service = service; }
@GetMapping("/{id}")
OrderResponse get(@PathVariable UUID id) { return service.find(id); }
}
// 2. Public Service Boundary
@Service
public class OrderService {
private final OrderRepository repository;
OrderService(OrderRepository repository) { this.repository = repository; }
public OrderResponse find(UUID id) {
return repository.findById(id)
.map(e -> new OrderResponse(e.getId()))
.orElseThrow();
}
}
// 3. Package-Private Persistence (Hidden from other packages)
interface OrderRepository extends JpaRepository<OrderEntity, UUID> {}
@Entity
@Table(name = "orders")
class OrderEntity {
@Id private UUID id;
UUID getId() { return id; }
}
public record OrderResponse(UUID id) {}Enforce package boundaries with ArchUnit tests
Architecture rules drift over time unless validated by automated tests. ArchUnit allows you to verify that controllers never access repositories directly, and features do not violate layer directions.
package com.example.architecture;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
@AnalyzeClasses(packages = "com.example")
public class ArchitectureTest {
@ArchTest
static final ArchRule controllersMustNotDependOnRepositories =
noClasses().that().haveSimpleNameEndingWith("Controller")
.should().dependOnClassesThat().haveSimpleNameEndingWith("Repository")
.because("Controllers must delegate through Service facades.");
}Active Recall Checkpoint
How does package-by-feature simplify future microservice decomposition?
Hint: Think about extracting a directory versus untangling classes across 5 directories.
Show answer & explanation
All classes, DTOs, persistence mappings, and repository contracts belonging to that business capability live inside a single self-contained package. Extracting it to an independent service or Gradle/Maven module requires moving only one directory rather than collecting scattered classes.
When to adopt feature packages in teams
Use it when
Adopt package-by-feature for almost all real-world applications where business capabilities grow over time and multiple developers contribute simultaneously.
Reconsider when
Package-by-layer is acceptable only for tiny throwaway prototypes or simple CRUD applications with fewer than 5 classes.
Runtime Architecture & Flow
Decision canvas
Evaluate the choice across three constraints
These are comparison criteria, not sequential steps. A strong answer states which constraint dominates in the current system.
Package-by-layer
Ask how each option changes this guarantee, its operational cost, and its failure mode.
Package-by-feature
Ask how each option changes this guarantee, its operational cost, and its failure mode.
Module boundaries
Ask how each option changes this guarantee, its operational cost, and its failure mode.
Decision prompt: Reorganize five task classes from layer-first into feature-first folders and measure how many packages one new field touches.
Working Implementation
Code Walkthrough
Reorganize five task classes from layer-first into feature-first folders and measure how many packages one new field touches.
package com.example.featurepkg.billing;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;
/**
* Package-by-Feature Design:
* All billing classes live in com.example.featurepkg.billing.
* Internal classes (BillingRecord, BillingRepository) are package-private.
* Only BillingController and BillingService are public API.
*/
public class PackageByFeatureLab {
// Package-private entity: impossible to import from outside 'billing' package!
record BillingRecord(String invoiceId, long amountCents, String status) {}
// Package-private repository: hidden implementation detail
static class BillingRepository {
BillingRecord findInvoice(String invoiceId) {
return new BillingRecord(invoiceId, 5000L, "PAID");
}
}
// Public service contract
@Service
public static class BillingService {
private final BillingRepository repo = new BillingRepository();
public record InvoiceDto(String invoiceId, long amountCents) {}
public InvoiceDto getInvoice(String id) {
BillingRecord r = repo.findInvoice(id);
return new InvoiceDto(r.invoiceId(), r.amountCents());
}
}
// Public HTTP entry point
@RestController
@RequestMapping("/api/v1/invoices")
public static class BillingController {
private final BillingService service;
public BillingController(BillingService service) { this.service = service; }
@GetMapping("/{id}")
public BillingService.InvoiceDto get(@PathVariable String id) {
return service.getInvoice(id);
}
}
}Synthesis & Production Verification
Deliberate Failure Condition
- Break: Add order processing classes beside task classes under generic service and repository packages
- Observe: Developers couple orders to tasks because nothing visible separates the features
- Repair: Feature packages draw the boundary you actually reason about; layer subfolders inside a feature preserve both views.
Production Acceptance Criteria
- Justify a package layout for a two-feature project
- Locate every class of one feature in under ten seconds
- Explain when team conventions outweigh personal preference
Key Takeaway
Package-by-feature groups cohesive domain capabilities into vertical slices, enabling Java package-private encapsulation and natural microservice extraction seams.
Architectural & Technical Review
Core Interview Questions
Q01What is the primary encapsulation advantage of package-by-feature over package-by-layer?Show answer
It allows repositories, entities, and internal helpers to remain package-private, exposing only the public Service interface to neighboring modules.
Q02How do package-private boundaries protect application architecture?Show answer
Classes without public modifiers cannot be imported by external packages, preventing other teams from bypassing service facades.
Q03How can teams prevent architectural drift and illegal dependencies automatically?Show answer
By running ArchUnit tests in CI to enforce rules such as controllers never directly depending on repositories.
Production Pitfalls
Critical Anti-Patterns
- Pitfall: Making every class public by default — Declaring all classes public destroys module boundaries and allows arbitrary coupling across unrelated domain areas.
- Pitfall: Splitting tightly coupled domain classes across technical layer packages — Spreading one feature across controllers, services, and repositories folders increases cognitive overhead during feature modifications.
Module Certification Quiz
Prove Module Mastery
Comprehensive question set covering all lessons in this module.
Primary Documentation & Specifications
Lesson Primary References