DEV Community

kamlesh patil
kamlesh patil

Posted on • Edited on

Spring Boot Project Structure: Best Practices Used in Production🚀

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)));
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Response DTO:

@Getter
@Setter
@Builder
public class UserResponse {
    private Long id;
    private String name;
    private String email;
    private LocalDateTime createdAt;
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode
// Custom exception
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Your aspect classes live in their own package:

com.example.project
└── aspect
    ├── LoggingAspect.java
    ├── PerformanceAspect.java
    └── AuditAspect.java
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
rytis profile image
Rytis

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 ApiConstants class 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 Something interface, just to have a single SomethingImpl class. 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 UserService already has 2 distinct functions: getUserById and createUser. If you’re writing a function for createUser, you shouldn’t be concerned about getUser, unless you’re checking for uniqueness. A better approach is to create two interfaces UserRetriever and UserCreator and then implement those in your service. It can be a single UserService or 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 those UserRetriever and UserCreator interfaces, instead of the UserService implementation. 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 Impl or Service to 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 Entity for the classname. So your user entity becomes UserEntity. This way you will know which classes interface with the database, and if you see your controller returning something ending with Entity, you can simply on the glance know that you’re leaking your entities.

Good job on not appending DTO for the DTOs. A DTO suffix is useless, as DTO will most likely be your domain object (ie User).

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 RestControllerAdvice and 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.

Collapse
 
kamlesh_patil profile image
kamlesh patil

Hi everyone, I’d love to hear your feedback and suggestions.⭐

Collapse
 
angeltduran profile image
angel t. duran

pretty great tips, thanks