I got lucky early in my career - I was handed a well-structured project to work on, and without even realizing it, I was absorbing good patterns just by reading the code. A few things clicked immediately, but honestly, most of it made sense only over time, as I started new projects from scratch and felt the pain of getting it wrong.
This article is what I wish I'd had before that first greenfield project.
Table of Contents
- Why Project Structure Matters
- The Layered Architecture
- Recommended Package Structure
- 1. Controller β Keep It Thin
- 2. Service β Interface + Implementation Pattern
- 3. Repository β CrudRepository vs JpaRepository
- 4. Entity β Map to Database Tables
- 5. DTOs β Don't Expose Your Entities
- 6. Custom API Response Wrapper
- 7. Global Exception Handling
- Bonus: Feature-Based Structure β The Path to Microservices
- The resources Directory
- Summary
Why Project Structure Matters
A well-organized project:
- Makes the codebase readable for new team members
- Separates concerns so each layer has one job
- Makes debugging faster - you always know where to look
- Scales without becoming a tangled mess
Without it, even a small team can turn a project into spaghetti within months.
The Layered Architecture
Spring Boot applications typically follow a layered architecture. Each layer has a single responsibility and should only talk to the layer below it.
HTTP Request
β
Controller Layer β handles requests/responses
β
Service Layer β business logic lives here
β
Repository Layer β database access
β
Database
Recommended Package Structure
com.example.project
βββ controller # REST API endpoints
βββ service
β βββ UserService.java # Interfaces
β βββ impl
β βββ UserServiceImpl.java # Implementations
βββ repository # Database operations (JPA)
βββ entity # Database table mappings
βββ dto
β βββ request # Incoming request models
β βββ response # Outgoing response models (incl. ApiResponse)
βββ constants # API paths and app-wide constants
βββ config # Security, Swagger, caching config
βββ exception # Global error handling
βββ util # Helper/utility classes
This maps directly to the layered architecture and makes it immediately obvious where any piece of code belongs.
Layer-by-Layer Breakdown
1. Controller β Keep It Thin
Controllers should only receive requests and return responses. No business logic here.
One small but impactful habit: store your API path strings in a constants file instead of hardcoding them directly. If you ever need to rename or version a route, you only change it in one place.
// constants/ApiConstants.java
public final class ApiConstants {
private ApiConstants() {}
// Base paths
public static final String API_BASE = "/api";
public static final String USERS_BASE = API_BASE + "/users";
public static final String ORDERS_BASE = API_BASE + "/orders";
// Path variables
public static final String PATH_ID = "/{id}";
public static final String PATH_EMAIL = "/{email}";
// Full combined paths (useful for @GetMapping)
public static final String USER_BY_ID = USERS_BASE + PATH_ID;
}
You can also store path variables like /{id} as constants. This avoids silent mismatches if the same variable name is used across multiple controllers.
@RestController
@RequestMapping(ApiConstants.USERS_BASE)
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping(ApiConstants.PATH_ID)
public ResponseEntity<ApiResponse<UserResponse>> getUser(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(userService.getUserById(id)));
}
@PostMapping
public ResponseEntity<ApiResponse<UserResponse>> createUser(@RequestBody @Valid CreateUserRequest request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(userService.createUser(request)));
}
}
The controller delegates everything to the service - it doesn't know or care how users are fetched or created.
2. Service β Interface + Implementation Pattern
A common production pattern is to split your service into an interface and an implementation class. This makes your code easier to test (you can mock the interface), supports multiple implementations if needed, and enforces a clean contract.
Service Interface:
// service/UserService.java
public interface UserService {
UserResponse getUserById(Long id);
UserResponse createUser(CreateUserRequest request);
}
Service Implementation:
// service/impl/UserServiceImpl.java
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
@Override
public UserResponse getUserById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id));
return userMapper.toResponse(user);
}
@Override
public UserResponse createUser(CreateUserRequest request) {
User user = userMapper.toEntity(request);
User savedUser = userRepository.save(user);
return userMapper.toResponse(savedUser);
}
}
The controller only depends on the UserService interface β it has no idea UserServiceImpl even exists. This is the Dependency Inversion Principle in action.
Your service package now looks like:
service/
βββ UserService.java β interface
βββ impl/
βββ UserServiceImpl.java β implementation
3. Repository β CrudRepository vs JpaRepository
Spring Data gives you two main options. Choosing the right one matters.
CrudRepository provides basic CRUD operations: save(), findById(), findAll(), delete(), etc. It's lightweight and suitable when you don't need pagination or sorting.
JpaRepository extends CrudRepository (and PagingAndSortingRepository) and adds JPA-specific features like flush(), saveAndFlush(), batch deletes, and built-in pagination via findAll(Pageable pageable).
In practice, prefer JpaRepository for most applications β the extra methods cost you nothing and save you from having to switch later.
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// Derived query β Spring generates the SQL automatically
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
// Pagination built-in β no extra code needed
Page<User> findByNameContaining(String name, Pageable pageable);
}
Use CrudRepository only when building a lightweight module where you want to intentionally limit the available operations.
Spring Data JPA handles the implementation. Your job is just to define the query interface.
4. Entity β Map to Database Tables
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
@CreationTimestamp
private LocalDateTime createdAt;
}
5. DTOs β Don't Expose Your Entities
This is one of the most important practices. Exposing entities directly leaks your database structure, makes refactoring painful, and can expose sensitive fields accidentally.
Request DTO:
@Getter
@Setter
public class CreateUserRequest {
@NotBlank(message = "Name is required")
private String name;
@Email(message = "Invalid email format")
@NotBlank(message = "Email is required")
private String email;
}
Response DTO:
@Getter
@Setter
@Builder
public class UserResponse {
private Long id;
private String name;
private String email;
private LocalDateTime createdAt;
}
Your entity can have 20 fields β the response DTO only exposes what the client actually needs.
6. Custom API Response Wrapper
Rather than returning raw objects or ResponseEntity with inconsistent shapes, wrap every response in a standard envelope. This gives every API consumer a predictable structure β always the same fields, whether it's a success or an error.
// dto/response/ApiResponse.java
@Getter
@Builder
public class ApiResponse<T> {
private final boolean success;
private final String message;
private final T data;
private final LocalDateTime timestamp;
public static <T> ApiResponse<T> success(T data) {
return ApiResponse.<T>builder()
.success(true)
.message("Operation successful")
.data(data)
.timestamp(LocalDateTime.now())
.build();
}
public static <T> ApiResponse<T> error(String message) {
return ApiResponse.<T>builder()
.success(false)
.message(message)
.data(null)
.timestamp(LocalDateTime.now())
.build();
}
}
Now every endpoint returns a consistent JSON shape:
{
"success": true,
"message": "Operation successful",
"data": {
"id": 1,
"name": "John Doe",
"email": "john@example.com"
},
"timestamp": "2024-01-15T10:30:00"
}
Your controller uses it cleanly:
@GetMapping("/{id}")
public ResponseEntity> getUser(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(userService.getUserById(id)));
}
Frontend teams will thank you β they never have to guess the response shape again.
7. Global Exception Handling
Rather than scattering try-catch blocks everywhere, use @ControllerAdvice to handle errors in one place.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
ErrorResponse error = new ErrorResponse("NOT_FOUND", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors()
.stream()
.map(FieldError::getDefaultMessage)
.findFirst()
.orElse("Validation failed");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("VALIDATION_ERROR", message));
}
}
// Custom exception
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
Bonus: Feature-Based Structure β The Path to Microservices
The layer-based structure works great for small-to-medium projects. But as your app grows, consider organizing by feature instead:
com.example.project
βββ user
β βββ UserController.java
β βββ UserService.java β interface
β βββ impl/
β β βββ UserServiceImpl.java
β βββ UserRepository.java
β βββ User.java
β βββ dto/
βββ order
β βββ OrderController.java
β βββ OrderService.java
β βββ impl/
β β βββ OrderServiceImpl.java
β βββ OrderRepository.java
β βββ Order.java
β βββ dto/
βββ shared
βββ config/
βββ constants/
βββ exception/
βββ util/
Everything related to a feature lives together. When you work on orders, you only touch the order package.
Why This Matters: Feature Packages Are Microservice Seeds π±
This is not just a style preference β it's a strategic architecture decision. When each feature is fully self-contained, splitting the monolith into microservices later becomes significantly less painful.
Monolith (today) Microservices (tomorrow)
βββββββββββββββββ ββββββββββββββββββββββββ
com.example.project user-service/
βββ user/ βββββββββββΊ βββ com.example.user/
βββ order/ βββββββββββΊ order-service/
βββ shared/ βββ com.example.order/
Each feature package already has its own controller, service, repository, and DTOs. Extracting it into its own Spring Boot service is mostly a matter of creating a new project and moving the package β not untangling shared code.
The key rules that make this work:
- Feature packages should never import from each other directly. If order needs user data, call an interface or an internal API β don't @Autowire a bean from the user package.
- Shared utilities, exception handlers, and config belong in a shared module that both features can depend on.
- Think of each feature as if it could be deployed independently tomorrow.
Starting with a well-structured monolith and evolving toward microservices is a proven pattern used by companies like Amazon, Netflix, and Uber. Feature-based packaging is what makes that transition manageable rather than catastrophic.
Quick Reference: Naming Conventions
| Layer | Class Name Example |
|---|---|
| Controller | UserController |
| Service Interface | UserService |
| Service Impl | UserServiceImpl |
| Repository | UserRepository |
| Entity | User |
| Request DTO | CreateUserRequest |
| Response DTO | UserResponse |
| API Wrapper | ApiResponse<T> |
| Constants | ApiConstants |
| Exception | ResourceNotFoundException |
Consistent naming means anyone on the team can find what they're looking for immediately.
Summary
| Practice | Why It Matters |
|---|---|
| Thin controllers | Easy to test and read |
| DTOs over entities | Decouples API from DB schema |
| Service interface + impl | Testable, swappable, follows DIP |
Custom ApiResponse<T> wrapper |
Consistent response shape for all clients |
| API paths + path vars in constants | Single source of truth, easy to refactor |
JpaRepository over CrudRepository
|
Pagination + batch ops built in |
| Global exception handler | Consistent error responses |
| Clear naming conventions | Speeds up onboarding and code reviews |
| Feature-based packages | Self-contained modules ready to extract as microservices |
| Environment-specific properties | Safe config management across dev/prod |
| No secrets in properties files | Security best practice for all environments |
The resources Directory β More Than Just Properties
Most developers know src/main/resources holds application.properties or application.yml. But in production apps it holds much more. Here's a complete picture:
src/main/resources/
βββ application.properties # Base config (active for all environments)
βββ application-dev.properties # Dev environment overrides
βββ application-prod.properties # Production overrides
βββ application-test.properties # Test environment overrides
β
βββ db/
β βββ migration/ # Flyway or Liquibase SQL scripts
β βββ V1__create_users.sql
β βββ V2__add_orders_table.sql
β
βββ static/ # Served as-is (images, JS, CSS for web apps)
β βββ ...
β
βββ templates/ # Thymeleaf or FreeMarker HTML templates
β βββ ...
β
βββ certs/ # SSL/TLS certificates and keystores
β βββ keystore.p12
β βββ truststore.jks
β
βββ i18n/ # Internationalization message bundles
β βββ messages.properties
β βββ messages_en.properties
β βββ messages_hi.properties
β
βββ logback-spring.xml # Logging configuration
A few practices worth highlighting:
Environment-specific properties are activated via Spring profiles. You never hardcode environment values β instead you switch profiles at startup:
java -jar app.jar --spring.profiles.active=prod
Never commit secrets. Database passwords, API keys, and JWT secrets should never live in application.properties in version control. Use environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault) and reference them like:
spring.datasource.password=${DB_PASSWORD}
jwt.secret=${JWT_SECRET}
Certificates and keystores belong in certs/ and are referenced in properties:
server.ssl.key-store=classpath:certs/keystore.p12
server.ssl.key-store-password=${KEYSTORE_PASSWORD}
server.ssl.key-store-type=PKCS12
Database migrations in db/migration/ keep your schema versioned alongside your code. Flyway picks these up automatically β no manual SQL scripts run on production servers.
Final Thoughts
Project structure isn't the most exciting topic, but it's one of the highest-leverage decisions you make early in a project. Getting it right means less refactoring, faster onboarding, and fewer arguments about where things should go.
Start layered, stay consistent, and migrate to feature-based packaging when the project demands it.
How do you structure your Spring Boot projects? Are you still on a monolith, or have you started extracting microservices? Let me know in the comments β I'd love to hear how your architecture has evolved! π
About the Author
Kamlesh Patil is a Software Engineer with experience building applications using Java, Spring Boot, and microservices.
He writes about backend engineering, scalable APIs, system design, and modern developer tools.
Top comments (0)