The Hidden Cost of Abstraction in Spring Boot

I used to think Spring Boot was removing complexity. Now I realize it was just relocating it.
From my code to places I wasn’t looking.
Every abstraction removes code you write and adds code you don’t see.
Let’s expose that hidden code 👇No fluff. Just practical examples.
1️⃣ The N+1 Query You Didn’t Write
You wrote this:
@GetMapping("/orders")
public List<Order> getOrders() {
return orderRepository.findAll();
}Entity:
@Entity
public class Order {
@Id
private Long id;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items;
}Looks innocent 😇
Now enable SQL logging:
spring.jpa.show-sql=true
logging.level.org.hibernate.SQL=DEBUGHit the endpoint.
What actually runs:
select * from orders;
select * from order_items where order_id=1;
select * from order_items where order_id=2;
select * from order_items where order_id=3;
select * from order_items where order_id=4;
select * from order_items where order_id=5;⚠️ 1 + N queries.
Why?
Because:
- Default fetch = LAZY
- Serialization triggers lazy loading
- Hibernate fires queries per order
✅ Fix (Explicit Fetch)
@Query("SELECT o FROM Order o JOIN FETCH o.items")
List<Order> findAllWithItems();Now it executes:
select o.*, i.*
from orders o
join order_items i on o.id = i.order_id;🧠 Lesson:
- Abstraction hides SQL.
- Logging exposes truth.
2️⃣ @Transactional Isn’t Magic
You wrote:
@Service
public class PaymentService {
@Transactional
public void process() {
save();
}
@Transactional
public void save() {
repository.save(new Payment());
}
}You think:
✔ Both methods transactional
Reality:
❌ save() is NOT transactional here.
Why?
Spring uses proxies (AOP).
Internal calls bypass proxy.
Under the hood:
PaymentService proxy = new TransactionalProxy(new PaymentService());
proxy.process(); // transaction startsBut:
this.save(); // direct call → no proxy✅ Correct Pattern
Split services:
@Service
public class PaymentInternalService {
@Transactional
public void save() {
repository.save(new Payment());
}
}Inject it:
@Service
public class PaymentService {
private final PaymentInternalService internal;
public PaymentService(PaymentInternalService internal) {
this.internal = internal;
}
public void process() {
internal.save();
}
}💡 Abstraction cost:
- Proxy-based logic
- Hidden execution path
- Surprising bugs
3️⃣ Reflection Overhead (Yes, It Exists)
Spring builds beans using reflection.
Let’s compare.
Direct call
calculator.add(1, 2);Reflection call
Method method = Calculator.class
.getMethod("add", int.class, int.class);
method.invoke(calculator, 1, 2);⏱ Rough benchmark (1M calls):
- Direct → ~5ms
- Reflection → ~50ms
Now multiply that by:
- Bean initialization
- AOP proxies
- Validation interceptors
Abstraction = startup time tax.
4️⃣ Dirty Checking: Hibernate’s Hidden Work
Code:
@Transactional
public void update(Long id) {
Order order = orderRepository.findById(id).get();
order.setCustomerName("Updated");
}You didn’t call save().
Still works.
Why?
Hibernate tracks entity snapshot:
- Stores original state
- Compares before commit
- Generates UPDATE query
Behind the scenes:
update orders set customer_name = 'Updated' where id = 1;⚠ Hidden costs:
- Extra memory
- Comparison CPU cycles
- Harder debugging
If you load 10,000 entities:
💣 Dirty checking becomes expensive.
✅ Explicit Update Alternative
@Modifying
@Query("UPDATE Order o SET o.customerName = :name WHERE o.id = :id")
void updateName(@Param("id") Long id,
@Param("name") String name);No comparisons.
Direct SQL.
5️⃣@Autowired Everywhere = Invisible Dependencies
Field injection:
@Autowired
private UserRepository repository;Hidden dependency.
Constructor injection:
private final UserRepository repository;
public UserService(UserRepository repository) {
this.repository = repository;
}
-------------------- OR ---------------------
@RequiredArgsConstructor
private final UserRepository repository;Why better?
✅ Explicit
✅ Immutable
✅ Easy unit testing
✅ No reflection field injection
Abstraction convenience often reduces clarity.
6️⃣ Thread Pool Bottleneck Nobody Talks About
Spring MVC = blocking. Each request consumes a thread.
Default Tomcat threads ≈ 200.
If your DB is slow, adding more app instances may just amplify DB pressure:
- Threads block
- New requests wait
- CPU idle
You didn’t configure it. Abstraction did.
👉 Check:
server.tomcat.threads.max=200If you don’t understand this, horizontal scaling won’t fix a saturated blocking resource.
7️⃣ Fat JAR & Memory Reality
Simple REST app:
mvn clean packageResult:
- 20MB+ jar
- 120–180MB RAM usage at runtime
Because Spring Boot includes:
- Embedded server
- Auto configs
- Class path scanning
👉 Compare minimal HTTP server using:
- Netty
Memory can drop below 50MB.
Abstraction trades:
🟢 Dev speed
🔴 Runtime efficiency
8️⃣ What I Do in Real Projects
Here’s my practical checklist:
🔎 Always:
- Enable SQL logs in dev
- Profile memory under load
- Inspect generated queries
- Use DTOs instead of exposing entities
🧱 Avoid:
- Returning lazy entities from controller
- Overusing
@Transactional - Blindly trusting defaults
- FetchType.EAGER everywhere
⚡ Optimize:
- Use projections
- Use explicit queries
- Measure before caching
🎯 Final Reality
Spring Boot is powerful.
But:
- It hides SQL.
- It hides memory cost.
Abstraction isn’t free. It’s just prepaid.
You pay later:
- In performance debugging
- In production surprises
Thanks for reading. As always, feel free to drop a comment, be sure to clap 👏
Follow the author for update: https://medium.com/@pramod.er90
I keep my writing free for anyone who wants to read it. If this helped you in any way, you can fuel my next idea with a coffee ☕ Buy me a coffee? — It’s completely Optional — but always Appreciated.

Writer : Swaraj Verma
— Bhuwan Chettri
Editor, CodeToDeploy
CodeToDeploy Is a Tech-Focused Publication Helping Students, Professionals, And Creators Stay Ahead with AI, Coding, Cloud, Digital Tools, And Career Growth Insights.