DEV Community

kamlesh patil
kamlesh patil

Posted 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
  • 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 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

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 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 (0)