Skip to content
PickNTech
Spring Boot

How to Structure a Spring Boot Project (That Still Makes Sense in a Year)

Package-by-layer vs package-by-feature, where DTOs and mappers belong, and a folder structure that scales from one controller to fifty.

2 min read · Updated 25 Sept 2026

Every Spring Boot tutorial starts with one controller in one package. Six months later that package has 80 files and nobody can find anything. Structure is cheap to get right early and expensive to fix later.

Option 1: Package by layer

com.pickntech.hr
├── controller
│   ├── EmployeeController.java
│   └── DepartmentController.java
├── service
│   ├── EmployeeService.java
│   └── DepartmentService.java
├── repository
│   ├── EmployeeRepository.java
│   └── DepartmentRepository.java
└── model
    ├── Employee.java
    └── Department.java

This is what most tutorials show. It's fine for small apps, but as the app grows:

  • A single feature change touches four packages.
  • Everything must be public, because layers live in different packages.
  • Nothing stops DepartmentController from reaching straight into EmployeeRepository.
com.pickntech.hr
├── HrApplication.java
├── employee
│   ├── EmployeeController.java
│   ├── EmployeeService.java
│   ├── EmployeeRepository.java
│   ├── Employee.java              // JPA entity
│   ├── EmployeeRequest.java       // DTO (record)
│   ├── EmployeeResponse.java      // DTO (record)
│   └── EmployeeNotFoundException.java
├── department
│   └── ...
└── common
    ├── ApiExceptionHandler.java
    └── config
        └── SecurityConfig.java

Everything about employees lives together. The payoffs:

  • Locality — one feature, one folder.
  • Encapsulation — the repository and entity can be package-private; other features must go through EmployeeService.
  • Deletability — removing a feature is deleting a folder.

Tip

Only make a class public when another feature genuinely needs it. The compiler then enforces your architecture for free.

The layers still exist — inside each feature

Controller (HTTP)→Service (business rules)→Repository (data)→Database
LayerResponsible forShould not
ControllerHTTP mapping, validation, status codesContain business rules
ServiceBusiness logic, transactionsKnow about HTTP
RepositoryQueriesContain business logic

Where DTOs and mapping belong

Keep entities out of your API. Map in the service, with a small static method or constructor on the DTO:

public record EmployeeResponse(long id, String name, String email) {
    static EmployeeResponse from(Employee e) {
        return new EmployeeResponse(e.getId(), e.getName(), e.getEmail());
    }
}

For large projects a mapping library like MapStruct saves boilerplate, but plain methods are perfectly fine to start.

Configuration

src/main/resources
├── application.yml          # shared defaults
├── application-dev.yml      # local overrides
└── application-prod.yml     # production (no secrets!)

Activate with SPRING_PROFILES_ACTIVE=prod, and inject secrets through environment variables.

Tests mirror the structure

src/test/java/com/pickntech/hr/employee
├── EmployeeServiceTest.java       // plain unit test, no Spring
└── EmployeeControllerTest.java    // @WebMvcTest slice

Because the service depends on an interface-style repository via constructor injection, you can unit-test it with a fake — no Spring context needed.

Checklist

  • Package by feature; keep a small common package for cross-cutting concerns.
  • Controllers thin, services own the rules, repositories only query.
  • Records for DTOs; never expose entities.
  • Constructor injection everywhere.
  • Profiles for environments, environment variables for secrets.

Want to go deeper?

Continue your learning journey

This article covers Spring Boot on the Java Full Stack roadmap. Follow the path to know exactly what comes next.

  1. Java 8+
  2. Spring BootYou’re reading
  3. REST APIs
  4. SQL

Keep reading