As an experienced developer proficient in Object-Oriented Programming (OOP), you understand the foundational principles of encapsulation, inheritance, polymorphism, and abstraction. Spring is, at its core, the most ambitious application of OOP principles ever built at scale.
You know how to designing clean, decoupled classes, apply SOLID principles and managing object lifecycles.
However, transitioning that expertise to the Java Spring Framework and Spring Boot environment often presents a unique set of pitfalls. Spring introduces its own powerful paradigms, primarily Inversion of Control (IoC) and Dependency Injection (DI), which fundamentally alter how objects are created, managed, and interact. The "Spring Magic" of auto-configuration, annotations, and conventions can sometimes lead to shortcuts that violate core OOP tenets or ignore the framework's best practices.
This blog post explores the most common mistakes developers, particularly those with a strong OOP background, make when diving into the Spring ecosystem. We will cover areas from misusing annotations to neglecting performance and security, providing actionable advice to write cleaner, more maintainable, and robust Spring applications.
The most frequent source of errors stems from a conceptual clash between manual OOP object creation and Spring's automated lifecycle management.
Mistake 1: Ignoring Dependency Injection (DI) and Manually Instantiating Objects
A fundamental OOP habit is using the new keyword to create an object when you need it. In Spring, this is a major anti-pattern for framework-managed components.
The Mistake:
Instead of letting Spring inject dependencies into a component developers might manually instantiate the service:
@Service
public class OrderService {
private final PaymentService paymentService = new PaymentService(); // ← Death sentence
}
You’ve been trained for years to create instance from class. So you do it instinctively. The result?
- The paymentService is not a Spring bean → no transaction management, no AOP, no proxying.
- You cannot mock it in unit tests without PowerMockito or ugly workarounds.
- You cannot swap implementations per profile (e.g., MockPaymentService in tests or SandboxPaymentService in staging).
The Fix:
Always rely on Spring's DI mechanism. Use Constructor Injection (the preferred method), Setter Injection, or Field Injection (using @Autowired).
Constructor Injection 1
@Service
public class OrderService {
private final PaymentService paymentService;
public OrderService(PaymentService paymentService) {
this.paymentService = paymentService;
}
}
Constructor Injection 2
@Service
@RequiredArgsConstructor // Lombok, or write constructor yourself
public class OrderService {
private final PaymentService paymentService; // Constructor injection FTW
}
Setter Injection
@Service
public class OrderService {
private PaymentService paymentService;
@Autowired
public void setPaymentService(PaymentService paymentService) {
this.paymentService = paymentService;
}
}
Field Injection
@Service
public class OrderService {
@Autowired
private PaymentService paymentService;
public void placeOrder() {
paymentService.processPayment();
}
}
Misstake 2: Using @Autowired Without Qualifiers When Multiple Beans Exist
The mistake:
@Autowired
private PaymentService paymentService; // NoUniqueBeanDefinitionException at startup
Or the even worse variant:
@Autowired
private List<PaymentService> paymentServices; // You get all implementations in unknown order
Suddenly adding a new payment provider (say, Zalo Pay, Apple Pay) breaks production because the list order changed or the wrong one was injected.
The Fix (choose one):
- Primary bean + @Primary
- @Qualifier("stripePaymentService")
- Better: Strategy pattern with a Map<String, PaymentProvider> injected and qualified by name
- Best: Small focused interfaces instead of one fat PaymentService interface
Mistake 3: Misusing @Configuration and @Bean
Developers often struggle with when and how to define a "bean" outside of the standard component scanning.
The Mistake:
Defining a configuration method as @Bean within a class that isn't annotated with @Configuration (or an equivalent like @SpringBootApplication). Furthermore, they might accidentally create multiple instances of a singleton bean when it should be managed by the container.
The Fix:
A method annotated with @Bean is designed to be executed by a class annotated with @Configuration. This combination tells Spring, "When the application starts, run this method and register its return value as a singleton object (a bean) in the IoC container." Understand that most beans default to the singleton scope, aligning with the OOP practice of having a single point of control for certain resources.
Mistake 4: Logic Overload in Controllers
This is arguably the most common mistake that violates the Single Responsibility Principle (SRP).
The Mistake:
Developers, in an effort to speed up development, place business logic, complex data validation, or even direct database access logic inside the @RestController methods.
// Bad Practice: Controller doing too much
@RestController
public class UserProfileController {
@Autowired private UserRepository repository;
@PostMapping("/users")
public ResponseEntity<User> createUser(@RequestBody User user) {
// Business logic/validation here instead of Service layer
if (user.getAge() < 18) {
throw new InvalidUserException();
}
// Direct repository call
repository.save(user);
// ...
}
}
The Fix:
Enforce strict layering.
-
Controller (
@RestController): Only handles HTTP request/response mapping, request validation (e.g., using JSR-303 annotations like@Valid), and calling the appropriate Service layer method. It acts as the "gatekeeper." -
Service (
@Service): Holds all the business logic, transaction boundaries (@Transactional), and orchestrates calls to multiple Repositories. It's the "brain" of the application. - Repository (
@Repository): Only handles direct data access operations (CRUD) against the persistence store. @Repository enables exception translation from SQLException → DataAccessException hierarchy. If you slap @Component on your JPA repositories, you lose that translation and end up with raw, unchecked exceptions bubbling up.
Mistake 5: Missing or Misplaced Transactional Boundaries
The Mistake:
Two opposite extremes I see constantly:
A. Forgetting @Transactional entirely → no rollback on exceptions B. Putting @Transactional on every service method → huge transactions, table locks, deadlocks
Worse: self-invocation bypasses the proxy.
@Service
public class OrderService {
public void createOrder() {
validateOrder(); saveOrder();
// No transaction here because of self-call!
}
@Transactional
public void saveOrder() {
...
}
}
The Fix:
- Put @Transactional only on the public method that orchestrates the use case
- Never call @Transactional methods from within the same class
- If you must, extract to another Spring bean or use @Transactional(propagation = REQUIRES_NEW) carefully
- Always set readOnly = true for query methods
- Use @Transactional on class level only if literally every method needs it (rare)
Mistake 6: Not Understanding N+1 Queries and Fetch Strategies
The Mistake:
In MyBatis, the N+1 problem commonly happens when a developer defines a parent-child mapping where the child collection is loaded using another SQL query inside the <collection> tag.
<resultMap id="userResultMap" type="User">
<id property="id" column="id"/>
<result property="name" column="name"/>
<!-- Causes N+1 queries -->
<collection property="orders"
ofType="Order"
select="findOrdersByUserId"
column="id"/>
</resultMap>
If you call
List<User> users = userMapper.findAllUsers();
MyBatis will execute: 1 query to retrieve all users + N queries to retrieve orders for each user
1 (parents) + N (children) = N+1 queries
The fix:
The most efficient way to avoid the N+1 problem in MyBatis is to replace nested selects with a single JOIN query, and let MyBatis map the flattened results.
<select id="findAllUsersWithOrders" resultMap="userOrderResultMap">
SELECT u.id AS user_id,
u.name,
o.id AS order_id,
o.product_name
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
</select>
<resultMap id="userOrderResultMap" type="User">
<id property="id" column="user_id"/>
<result property="name" column="name"/>
<collection property="orders" ofType="Order">
<id property="id" column="order_id"/>
<result property="productName" column="product_name"/>
</collection>
</resultMap>
-
Only one SQL call is executed.
-
MyBatis maps the parent-child relationships in memory.
-
No performance penalties from multiple round-trips to the database.
Mistake 7: Storing Sensitive Data in application.properties / application.yml
This is a critical security vulnerability.
The Mistake:
Hardcoding sensitive information such as database passwords, API keys, or cloud credentials directly into the application's configuration files. This is easily exposed if the source code is compromised or accidentally checked into a public repository.
The Fix:
Use Environment Variables or Externalized Configuration. Spring Boot is designed to read configuration properties from multiple sources, with environment variables taking precedence.
# Instead of:
# spring.datasource.password=myhardcodedsecret
# Use this in application.properties/yml:
spring.datasource.password=${DB_PASSWORD}
Then, set the DB_PASSWORD environment variable on the server. For production, consider dedicated tools like Spring Cloud Config or secrets managers (e.g., AWS Secrets Manager, HashiCorp Vault).
Mistake 8: Leaking Entity Objects Out of the Service Layer
This violates encapsulation and can lead to unintended state changes.
The Mistake:
Returning the raw JPA @Entity objects directly (or mybatis) from a Service layer method to the Controller, and then exposing them as the JSON response. This breaks the domain boundary. Furthermore, lazy-loaded collections on the Entity might be accessed outside of the transaction scope (e.g., in the Controller or during JSON serialization), leading to a dreaded LazyInitializationException.
The Fix:
Implement the Data Transfer Object (DTO) pattern. The Service layer should map the internal @Entity objects to an external DTO before returning it. The Controller only works with DTOs. This ensures encapsulation (internal data structure is protected) and prevents serialization errors.
Even though you don't have JPA’s LazyInitializationException or persistence-context issues, you still get:
| Problem | JPA | MyBatis |
|---|---|---|
| LazyInitializationException | ✔ Yes | ❌ No |
| Dirty checking / automatic persistence | ✔ Yes | ❌ No |
| Leaking domain model | ✔ Yes | ✔ Yes |
| Coupling DB schema to API | ✔ Yes | ✔ Yes |
| Exposing sensitive/internal fields | ✔ Yes | ✔ Yes |
| Serialization recursion issues | ✔ Yes | ✔ Yes |
The DTO pattern is still best practice for MyBatis as well as JPA
Mistake 9: Misusing @Async and Thread Pools
Simply adding @EnableAsync and annotating a method with @Async without configuring a thread pool.
// Bad Practice: Relying on default behavior
@Service public class EmailService{ @Async public void sendEmail(String recipient){ // Expensive IO operation } }
Until you realize:
- The Configuration (Avoiding OOM)
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
// Fix: Default SimpleAsyncTaskExecutor creates a new thread per call → OOM in production.
// We configure a proper ThreadPoolTaskExecutor to limit resource usage.
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("AsyncThread-");
executor.initialize();
return executor;
}
}
- The Service (Handling Proxy & Exceptions)
@Service
public class EmailService {
private static final Logger logger = LoggerFactory.getLogger(EmailService.class);
// Rule 1: @Async methods must be public (for AOP proxying) and called from another bean.
@Async("taskExecutor")
public CompletableFuture<String> sendEmail(String recipient) {
// Rule 2 & 3: Always return CompletableFuture<?>.
// If we return 'void', exceptions are swallowed and lost.
try {
logger.info("Sending email to " + recipient);
Thread.sleep(2000);
if (recipient.contains("fail")) {
throw new RuntimeException("Email server timeout!");
}
return CompletableFuture.completedFuture("Email Sent Successfully");
} catch (Exception e) {
// Properly capture the error so the caller can handle it.
// Otherwise, the exception is swallowed by the void return type.
return CompletableFuture.failedFuture(e);
}
}
}
- The Usage (Calling from another Bean)
@RestController
public class EmailController {
@Autowired
private EmailService emailService; // Injecting the bean (External call)
@PostMapping("/send")
public void send(@RequestParam String email) {
// This is a valid call because it comes from the Controller bean to the Service bean.
// If we called this.sendEmail() inside EmailService, the @Async would be ignored.
emailService.sendEmail(email);
}
}
Mistake 10: Inadequate or Generic Exception Handling
Poor exception handling leads to cryptic HTTP 500 errors and a terrible user experience.
The Mistake:
Catching generic exceptions (catch (Exception e)) in the service layer, suppressing specific exceptions, or allowing application exceptions to bubble up to the client, exposing internal implementation details.
The Fix:
Implement proper Global Exception Handling using the @ControllerAdvice and @ExceptionHandler annotations. This allows you to centralize error handling, map your custom, specific application exceptions (e.g., ResourceNotFoundException, InvalidInputException) to the correct HTTP status codes (e.g., 404, 400), and return a standardized, clean error response object (e.g., JSON payload).
Mistake 11: Ignoring Spring Security Basics
Security is often an afterthought, and developers fail to understand how Spring Security integrates with the application context.
The Mistake:
Not understanding the basics of the Spring Security Filter Chain, relying solely on annotations like @PreAuthorize without a configured authentication provider, or storing passwords in plaintext.
The Fix:
Every Spring Boot application should implement security from the start. Use a modern, strong password encoder (like BCryptPasswordEncoder), configure a custom UserDetailsService, and ensure you understand the flow: Request -> Filter Chain -> Authentication -> Authorization -> Dispatcher Servlet -> Controller. Even for non-critical endpoints, you should explicitly define them as public (e.g., a whitelist) and secure everything else by default.
Conclusion
The Java Spring Framework and Spring Boot offer immense power, allowing you to build scalable and robust applications rapidly. For developers with a strong OOP background, mastery lies in shifting control from manual object management to embracing Spring's core mechanisms: IoC, DI, and AOP.
To transition successfully and write clean, professional Spring code, focus on these non-negotiable best practices:
- Stop Using
new: Fully rely on Constructor Injection for all managed components, letting Spring manage object lifecycle and provide essential features (transactions, security, AOP). - Maintain Layer Discipline: Enforce strict SRP by keeping Business Logic strictly in the Service Layer and ensuring Controllers only handle request mapping.
- Protect Your Domain: Never leak Entities; always use the DTO pattern for communication between the Service and Controller layers to maintain encapsulation and prevent persistence context issues.
- Secure and Optimize: Prioritize security by externalizing sensitive secrets and optimize performance by resolving N+1 query issues using JOIN FETCH or appropriate mapping strategies.
- Handle Errors Globally: Implement Global Exception Handling (
@ControllerAdvice) to provide clean, standardized error responses and avoid exposing internal stack traces.
Mastering Spring is about understanding where the framework takes over. By embracing these idioms, you transform from a developer who uses Spring to one who thinks in Spring, resulting in applications that are cleaner, more maintainable, and built for scale.
Whether you need scalable software solutions, expert IT outsourcing, or a long-term development partner, ISB Vietnam is here to deliver. Let’s build something great together—reach out to us today. Or click here to explore more ISB Vietnam's case studies.
[1] Spring Framework course. Top 10 Mistakes in Spring Boot Microservices and How to Avoid Them . Ramesh Fadatare . https://www.javaguides.net/2025/01/top-10-mistakes-in-spring-boot-microservices.html
[2] Spring Framework Documentation. (n.d.). 3.3. Dependency Injection. Retrieved November 30, 2025, from https://docs.spring.io/spring-framework/reference/core/beans/dependencies/factory-collaborators.html
[3] Mihalcea, V. (2023, August 11). The best way to use DTOs with JPA and Hibernate. Vlad Mihalcea. https://vladmihalcea.com/the-best-way-to-use-dtos-with-jpa-and-hibernate/
[4] Baeldung Team. (2024, March 25). The @Transactional Pitfall. Baeldung. https://www.baeldung.com/transactional-spring-proxies
[5] Top 10 Most Common Spring Framework Mistakes. Retrieved November 30, 2025, from https://www.geeksforgeeks.org/java/top-10-most-common-spring-framework-mistakes/
[6] Support from Chat GPT and Gemini









