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
- 8. AOP — Cross-Cutting Concerns in One Place
- 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 that 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);
}
}
8. AOP — Cross-Cutting Concerns in One Place
As your application grows, certain behaviors need to be applied across multiple layers — logging every request, measuring execution time, and auditing sensitive operations. Copy-pasting this logic into every service method is error-prone and clutters your business code.
Aspect-Oriented Programming (AOP) lets you define this logic once and apply it declaratively — without touching the methods themselves.
First, add the dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
Your aspect classes live in their own package:
com.example.project
└── aspect
├── LoggingAspect.java
├── PerformanceAspect.java
└── AuditAspect.java
Logging — See What's Happening Without Touching Service Code
// aspect/LoggingAspect.java
@Aspect
@Component
@Slf4j
public class LoggingAspect {
@Pointcut("execution(* com.example.project.service..*(..))")
public void serviceLayer() {}
@Before("serviceLayer()")
public void logBefore(JoinPoint joinPoint) {
log.info("→ Calling: {}.{}() with args: {}",
joinPoint.getTarget().getClass().getSimpleName(),
joinPoint.getSignature().getName(),
Arrays.toString(joinPoint.getArgs()));
}
@AfterReturning(pointcut = "serviceLayer()", returning = "result")
public void logAfterReturning(JoinPoint joinPoint, Object result) {
log.info("← Returned: {}.{}() → {}",
joinPoint.getTarget().getClass().getSimpleName(),
joinPoint.getSignature().getName(),
result);
}
@AfterThrowing(pointcut = "serviceLayer()", throwing = "ex")
public void logException(JoinPoint joinPoint, Exception ex) {
log.error("✗ Exception in {}.{}() — {}",
joinPoint.getTarget().getClass().getSimpleName(),
joinPoint.getSignature().getName(),
ex.getMessage());
}
}
No try-catch, no log statements scattered across services. Every method entry, exit, and exception is captured automatically.
Performance Monitoring — Catch Slow Methods Early
// aspect/PerformanceAspect.java
@Aspect
@Component
@Slf4j
public class PerformanceAspect {
private static final long SLOW_THRESHOLD_MS = 500;
@Around("execution(* com.example.project.service..*(..))")
public Object measureExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object result = joinPoint.proceed();
long duration = System.currentTimeMillis() - start;
if (duration > SLOW_THRESHOLD_MS) {
log.warn("⚠ SLOW METHOD: {}.{}() took {}ms",
joinPoint.getTarget().getClass().getSimpleName(),
joinPoint.getSignature().getName(),
duration);
} else {
log.debug("⏱ {}.{}() took {}ms",
joinPoint.getTarget().getClass().getSimpleName(),
joinPoint.getSignature().getName(),
duration);
}
return result;
}
}
Any method exceeding the threshold gets flagged — giving you a performance signal without adding a single line to your business logic.
When to Use AOP — and When Not To
| Use AOP for | Keep in Service Code |
|---|---|
| Logging method entry/exit | Business rules and validations |
| Performance monitoring | Domain-specific error handling |
| Audit trails | Conditional logic based on data |
| Security checks (e.g. role checks) | Orchestration between services |
🤔 Rule of thumb: Ask yourself two questions before adding logic to a service method:
Does this apply to many methods? ✅
Is it unrelated to business logic? ✅
If both are yes, it belongs in an aspect, not your service. 🧩
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 that 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 (3)
Ufff, where do I even start? This article is bringing me some serious PTSD, because I inherited a “microservice” (actually distributed monolith) project with a similar structure. It is borderline impossible to make sense of and figure how it’s working.
The structure of the project depends on the size, and will most likely change overtime. A microservice with 4 endpoints will be structured completely different from a domain service with 100 endpoints. Your suggested structure will only work for a small single domain microservice, but it quickly becomes too flat. Structure that has 50 controller classes in a single controller package and 200 repos in the repository package quickly becomes difficult to navigate as separate domains get mixed into same packages. Group your functionality according to domain, and then you can subdivide the domain’s layers further down into technical packages such as controller, entities, and repos. Good job on mentioning that in the Bonus section.
DO NOT make an
ApiConstantsclass with your endpoints. This this completely useless and just spreads the required information thin. When I open the controller class, I want to see on the glance what endpoints it provides, I don’t want to go clicking deep into some sort of god constants class for each method. In my 15 years of programming, I can count on one hand the times where I needed to change the path of a production endpoint. When changing/versioning endpoints, you will most likely create an entirely new endpoint to retain backwards compatibility. Don’t obscure your functionality, inline all the paths into annotations. Do not create useless separate constants class as it couples multiple domains to a single god class. It is totally fine to have multiple classes with shared constants, but only in the scope of a single functionality/domain.Interface + Implementation pattern is also an antipattern the way that you describe it. There’s absolutely no point in making a
Somethinginterface, just to have a singleSomethingImplclass. Especially if your interface has more than 3 methods. This does not make tests easier to write, if anything it makes tests more difficult to write, because you will be forced to include all methods from the interface, which will cause you to look into implementation details under test, and only write test implementation for methods being used, leaving you with brittle tests, or even worse – using Mockito.In your example
UserServicealready has 2 distinct functions:getUserByIdandcreateUser. If you’re writing a function forcreateUser, you shouldn’t be concerned aboutgetUser, unless you’re checking for uniqueness. A better approach is to create two interfacesUserRetrieverandUserCreatorand then implement those in your service. It can be a singleUserServiceor if the logic gets complicated over time, it can easily be split into different implementation classes, while still retaining the interfaces. Then your controller should inject thoseUserRetrieverandUserCreatorinterfaces, instead of theUserServiceimplementation. This way you will on the glance see the true dependencies of your modules, and if you suddenly find yourself injecting 20 interfaces, you might just stop and think about breaking down the functionality even more.A simple rule is – if you have to append
ImplorServiceto the class name, you’re probably doing something wrong.When dealing with entities, if you’re not using them as your domain objects (and you shouldn’t), then it makes sense to append
Entityfor the classname. So your user entity becomesUserEntity. This way you will know which classes interface with the database, and if you see your controller returning something ending withEntity, you can simply on the glance know that you’re leaking your entities.Good job on not appending
DTOfor the DTOs. A DTO suffix is useless, as DTO will most likely be your domain object (ieUser).API Response wrappers are also mostly useless. A 200 response already signals about success, if it’s not successful, it will be a 400+ response, so success field is redundant. Message is part of the data. Timestamp is also useless, unless you want your API users to know the server time, but even then it’s better to put in the response header. So without redundant fields, your wrapper no longer has any useful fields, with the exception of data, so using raw data is the answer (and more RESTful if you wanna go that way).
Going further, your controllers should return your domain objects or response DTOs. If you use your custom API wrapper, or
ResponseEntity, you’re just obscuring your controller return types. It is further made worse because of Java’s type erasure, which will lose the wrapped type in runtime.If you insist on wrapping your responses, do so in
RestControllerAdviceand not explicitly in each controller method.With everything said – start introducing layers as your project gets bigger. Do not start with 8 layers for a single small crud feature, as it only complicates the readability, and there’s more plumbing code other than feature code.
If anything, learn proper OOP and Hexagonal approach and use your brain, and not some arbitrary pattern some guy on the internet said is a cure-all approach.
Hi everyone, I’d love to hear your feedback and suggestions.⭐
pretty great tips, thanks