Common Mistakes & Best Practices
A capstone lesson on package-by-feature vs package-by-layer, why entities should never be returned directly as API responses, and common configuration mistakes.
Introduction
Individually, most Spring Boot features are simple. What separates a maintainable application from one that becomes painful to work in is a handful of structural and habitual decisions made early and rarely revisited. This lesson steps back from individual features to look at project structure, the DTO pattern, and configuration habits that consistently separate well-run Spring Boot codebases from troubled ones.
- Package-by-layer vs package-by-feature project structure.
- Why returning JPA entities directly from a controller is a mistake.
- How the DTO pattern solves that problem.
- Common configuration mistakes seen in real projects.
- A broader checklist to sanity-check any Spring Boot codebase.
Package-by-Layer vs Package-by-Feature
Package-by-layer groups classes by their technical role - all controllers together, all services together, all repositories together. Package-by-feature groups classes by business capability instead, keeping everything related to "tasks" in one package regardless of its technical role.
# Package-by-layercom.programinds.demo├── controller│ ├── TaskController.java│ └── UserController.java├── service│ ├── TaskService.java│ └── UserService.java└── repository ├── TaskRepository.java └── UserRepository.java
# Package-by-featurecom.programinds.demo├── task│ ├── TaskController.java│ ├── TaskService.java│ └── TaskRepository.java└── user ├── UserController.java ├── UserService.java └── UserRepository.java| Structure | Strength | Weakness |
|---|---|---|
| Package-by-layer | Familiar, easy for small projects | As the app grows, related classes scatter across many packages |
| Package-by-feature | Everything for one capability lives together; easier to reason about or extract into a microservice later | Less familiar to developers used to the layered style |
Package-by-layer is fine for small applications. As a codebase grows past a handful of features, package-by-feature tends to scale better because it keeps related code physically close together instead of spread across the whole project.
Never Expose Entities Directly
Returning a @Entity class directly from a REST controller is one of the most common mistakes in Spring Boot projects. It looks convenient at first, but it tightly couples your public API contract to your internal database schema.
// Avoid: entity returned directly from the controller@GetMapping("/{id}")public Task getTask(@PathVariable Long id) { return taskRepository.findById(id).orElseThrow();}- Adding a database column instantly changes your public API response, whether you intended that or not.
- Lazy-loaded JPA relationships can trigger a LazyInitializationException when Jackson tries to serialize them outside a transaction.
- Sensitive columns (like a password hash) can leak into the JSON response with no explicit code to catch.
- Client applications become coupled to your internal persistence model instead of a stable public contract.
The DTO Pattern
A DTO (Data Transfer Object) is a plain class - a record is a natural fit - that defines exactly what a client should see, independent of the entity's internal shape. The controller maps between entity and DTO explicitly.
public record TaskResponse(Long id, String title, boolean done) {
public static TaskResponse from(Task task) { return new TaskResponse(task.getId(), task.getTitle(), task.isDone()); }}
@RestController@RequestMapping("/api/tasks")public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) { this.taskService = taskService; }
@GetMapping("/{id}") public TaskResponse getTask(@PathVariable Long id) { Task task = taskService.findById(id); return TaskResponse.from(task); }}Click Run to see what this code prints.
Now the entity can gain new columns, change internal relationships, or store sensitive data, and the public API contract stays exactly as defined by TaskResponse until you deliberately change it.
Common Configuration Mistakes
A surprising share of production incidents trace back to configuration, not code. A few patterns show up repeatedly.
| Mistake | Consequence |
|---|---|
| spring.jpa.hibernate.ddl-auto=update (or worse, create) in production | Schema can drift silently or, in the worst case, tables can be wiped |
| Secrets committed directly in application.properties | Credentials leak into version control history permanently |
| No spring.profiles.active set explicitly per environment | Dev settings can accidentally run in production, or vice versa |
| Default connection pool size left unexamined | Database connections exhausted under real load |
A Broader Checklist
Before considering a Spring Boot application production-ready, it is worth running through a short checklist covering everything this course has built up to.
- Controllers stay thin and delegate to services; services hold business logic, not controllers.
- Every entity has a corresponding DTO for anything that crosses the API boundary.
- Validation is applied on every request body with Bean Validation.
- Exceptions are handled centrally with @ControllerAdvice, not scattered try/catch blocks per endpoint.
- Environment-specific configuration is separated by Spring Profiles, not by editing files before each deploy.
- Actuator health and metrics endpoints are enabled and secured.
- Spring Security protects every endpoint that should require authentication.
- Tests cover services with unit tests and critical flows with integration or MockMvc tests.
Common Mistakes
- Returning JPA entities directly from REST controllers.
- Using ddl-auto=update or create against a production database.
- Committing secrets or credentials into application.properties in version control.
- Letting business logic creep into controllers instead of the service layer.
- Never revisiting project structure as the codebase grows well past its original size.
Best Practices
- Choose package-by-feature once a project grows beyond a handful of controllers.
- Map every entity to a DTO at the API boundary, in both directions.
- Use ddl-auto=validate (or a migration tool like Flyway/Liquibase) in production, never update or create.
- Keep secrets in environment variables or a secret manager, never in committed files.
- Revisit this checklist periodically as a project matures, not just once at the start.
Frequently Asked Questions
For a tiny internal tool or a throwaway prototype, maybe. For anything with a real API contract or more than one consumer, mapping to a DTO is worth the small amount of extra code from the very start.
For small numbers of DTOs, writing the mapping by hand (as in the from() factory method example) is perfectly fine. As the number of entity-DTO pairs grows, a mapping library can remove a lot of repetitive boilerplate.
validate, which checks the schema matches your entities without modifying it, paired with a dedicated migration tool like Flyway or Liquibase that manages schema changes explicitly and under version control.
Key Takeaways
- Package-by-feature scales better than package-by-layer as a project grows.
- Never return JPA entities directly from a controller - map to a DTO instead.
- The DTO pattern decouples your public API contract from your internal persistence model.
- ddl-auto=update or create in production is a serious risk; use validate with a migration tool instead.
- A short, periodic checklist review catches structural drift before it becomes a real problem.
Summary
The features covered throughout this course - auto-configuration, JPA, validation, security, testing - are only half the story. How you structure a project, whether you expose entities directly, and how carefully you configure each environment determine whether an application stays maintainable as it grows. With these habits in place, the final lesson brings everything together into one complete project.