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.javaThis 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
DepartmentControllerfrom reaching straight intoEmployeeRepository.
Option 2: Package by feature (recommended)
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.javaEverything 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
| Layer | Responsible for | Should not |
|---|---|---|
| Controller | HTTP mapping, validation, status codes | Contain business rules |
| Service | Business logic, transactions | Know about HTTP |
| Repository | Queries | Contain 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 sliceBecause 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
commonpackage 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.
- Java 8+
- Spring BootYou’re reading
- REST APIs
- SQL