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

20 min

Lesson 2 of 2

1d streak

The 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

Concept A

Package-by-Layer (Technical Silos)

Concept B

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.

CriteriaPackage-by-LayerPackage-by-Feature
Locality of ChangePoor (touches controllers, services, repositories folders)Excellent (all changes stay within the feature folder)
EncapsulationForced public visibility on almost all classesPackage-private (default) hides repositories and domain internals
Microservice Extraction SeamVery difficult (scattered throughout codebase)Trivial (isolate or extract the feature package)
Navigation Cognitive LoadHigh (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.

  1. 01Declare the feature service public if it needs to be invoked by other feature modules.
  2. 02Keep the repository and internal entity package-private (e.g., class OrderRepository extends JpaRepository).
  3. 03Spring component scanning and dependency injection work seamlessly with package-private classes.
  4. 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

OrderManagement.java
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.

ArchitectureTest.java
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.

CRITERION 01

Package-by-layer

Ask how each option changes this guarantee, its operational cost, and its failure mode.

CRITERION 02

Package-by-feature

Ask how each option changes this guarantee, its operational cost, and its failure mode.

CRITERION 03

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.

PackageByFeatureLab.java
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);
        }
    }
}
Package-by-feature enables Java package-private visibility, eliminating illegal cross-domain dependencies at compile time.
Package-by-layer forces all entities and repositories to be public, leaking internals across unrelated modules.
Refactoring or deleting a feature requires modifying only a single cohesive package.

Synthesis & Production Verification

Deliberate Failure Condition

  1. Break: Add order processing classes beside task classes under generic service and repository packages
  2. Observe: Developers couple orders to tasks because nothing visible separates the features
  3. 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

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.

Take Module Quiz

Primary Documentation & Specifications