AI can write code. Honestly, it can write it faster and with fewer syntax errors than most of us. But here's what it can't do: it doesn't know your system's traffic patterns, your database's growth trajectory, your team's ops maturity, or why that one service falls over every Tuesday at 2 AM.
Architecture is still yours to own.
This post is aimed at backend engineers — especially those in the Java/Spring Boot world — who want to go beyond CRUD and understand what actually makes systems hold up under pressure.
1. 🧵 Thread Pool Sizing — Bigger Is Not Better
"more threads = more throughput.? " It doesn't work that way.
CPU-bound vs. IO-bound — why it matters
Your app's tasks fall into two categories:
- CPU-bound — number crunching, encoding, compression. The thread needs the CPU the whole time.
- IO-bound — database calls, HTTP requests, file reads. The thread spends most of its time waiting.
If your app is CPU-bound and you run 200 threads on a 6-core machine, each core is constantly switching between ~33 threads. That context-switching overhead adds latency — it doesn't reduce it.
In Spring Boot with an embedded Tomcat server:
# application.yml
server:
tomcat:
threads:
max: 200 # default — fine for IO-heavy apps
min-spare: 10
accept-count: 100 # queue size when all threads are busy
max-connections: 8192
⚠️ Don't just bump
maxto 500. Profile your app first. Use tools like JVisualVM or async-profiler to see whether your threads are actually running or just waiting.
2. 🏊 DB Connection Pool — HikariCP Done Right
Spring Boot ships with HikariCP as the default connection pool, and it's excellent. But "excellent" doesn't mean "correctly configured for your app." The defaults are conservative placeholders — not production settings.
Why connection pools matter
Every DB connection is expensive: it holds memory on both the app and DB side, and opening one takes time. A pool keeps a set of connections warm and ready. Too few → threads queue up waiting for a connection. Too many → you overwhelm the database.
The HikariCP sizing formula
This comes directly from the HikariCP maintainer:
pool_size = (core_count * 2) + effective_spindle_count
For a 4-core app server talking to a standard SSD-backed Postgres instance:
pool_size = (4 * 2) + 1 = 9 — round up to 10.
# application.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: ${DB_USER}
password: ${DB_PASS}
hikari:
maximum-pool-size: 10 # max active connections
minimum-idle: 5 # keep 5 warm at all times
idle-timeout: 600000 # remove idle connections after 10 min
connection-timeout: 30000 # fail fast if no connection in 30s
max-lifetime: 1800000 # recycle connections every 30 min
pool-name: MyAppHikariPool
auto-commit: false # let Spring manage transactions
The @Transactional trap
Every @Transactional method holds a connection for its entire duration. This is the most common way connection pools get exhausted.
// ❌ BAD — holds a connection for the entire slow external call
@Transactional
public OrderResult processOrder(OrderRequest request) {
Order order = orderRepository.save(new Order(request));
// This HTTP call could take 2 seconds
// Your DB connection is locked the entire time
PaymentResult payment = paymentGateway.charge(request.getCardToken(), order.getTotal());
order.setStatus(payment.isSuccess() ? CONFIRMED : FAILED);
orderRepository.save(order);
return new OrderResult(order);
}
// ✅ GOOD — transaction wraps only the DB work
public OrderResult processOrder(OrderRequest request) {
Order order = createOrder(request); // short transaction
PaymentResult payment = paymentGateway.charge( // outside transaction
request.getCardToken(), order.getTotal()
);
return finalizeOrder(order.getId(), payment); // short transaction
}
@Transactional
private Order createOrder(OrderRequest request) {
return orderRepository.save(new Order(request));
}
@Transactional
private OrderResult finalizeOrder(Long orderId, PaymentResult payment) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.setStatus(payment.isSuccess() ? CONFIRMED : FAILED);
return new OrderResult(orderRepository.save(order));
}
3. 🔍 The N+1 Query Problem
If you've ever looked at your logs and seen 100+ queries firing for what should be a single list fetch — you've hit N+1. It's silent, it's common, and it absolutely destroys performance at scale.
What causes it
When JPA lazily loads associations, it fires one query to get N parent records, then one query per parent to fetch the child. N parents = N+1 total queries.
// Entity setup
@Entity
public class Order {
@OneToMany(fetch = FetchType.LAZY) // default — lazy
private List<OrderItem> items;
}
// This looks innocent...
List<Order> orders = orderRepository.findAll(); // 1 query
for (Order order : orders) {
System.out.println(order.getItems().size()); // N queries — one per order
}
// If orders has 100 rows: 1 + 100 = 101 queries. Ouch.
Fix 1: @EntityGraph — declarative and clean
// Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"items", "items.product"})
List<Order> findAllWithItems();
@EntityGraph(attributePaths = {"items"})
Optional<Order> findWithItemsById(Long id);
}
Fix 2: JOIN FETCH in JPQL — for custom queries
@Query("SELECT DISTINCT o FROM Order o " +
"LEFT JOIN FETCH o.items i " +
"LEFT JOIN FETCH i.product " +
"WHERE o.status = :status")
List<Order> findByStatusWithItems(@Param("status") OrderStatus status);
Fix 3: Batch fetching (when JOIN FETCH causes cartesian products)
# application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100 # batch N lazy loads into one IN() query
💡 Enable SQL logging in dev to catch N+1 early — before it hits production:
spring: jpa: show-sql: true properties: hibernate: format_sql: true logging: level: org.hibernate.SQL: DEBUG org.hibernate.type.descriptor.sql: TRACE
4. 📦 Response Payload Size — Serialize Less, Go Faster
Serialization and deserialization are not free. Every null field you send gets serialized by the server, transmitted over the wire, and deserialized on the client — for nothing.
This compounds fast: 1000 requests/second × 20 unnecessary null fields = measurable CPU and bandwidth overhead.
Exclude nulls globally
// Apply to your DTO base class or globally
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {
private T data;
private String message;
private String errorCode; // null in success cases — won't be serialized
private List<String> errors; // null in success cases — won't be serialized
}
Or globally via config:
# application.yml
spring:
jackson:
default-property-inclusion: non_null
serialization:
write-dates-as-timestamps: false
Use DTOs — never return entities directly
// ❌ BAD — sends the whole entity including sensitive fields
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
return userRepository.findById(id).orElseThrow();
}
// ✅ GOOD — return only what the client needs
@GetMapping("/users/{id}")
public UserSummaryDTO getUser(@PathVariable Long id) {
return userRepository.findById(id)
.map(UserSummaryDTO::from)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
}
// The DTO
public record UserSummaryDTO(Long id, String username, String email) {
public static UserSummaryDTO from(User user) {
return new UserSummaryDTO(user.getId(), user.getUsername(), user.getEmail());
}
}
Projection queries — skip the entity entirely for reads
// Interface-based projection — JPA only selects these two columns
public interface UserSummary {
Long getId();
String getUsername();
}
public interface UserRepository extends JpaRepository<User, Long> {
List<UserSummary> findAllProjectedBy();
}
5. ⚡ Event-Driven Architecture with Kafka — Decouple or Die
As your system grows, REST calls between services become a liability. One slow service stalls the caller. One down service causes cascades. Every consumer adds coupling.
Kafka flips this model: producers don't care who consumes. Consumers don't block the producer.
The snapshot pattern — stop making REST calls for validation
Classic problem: Order Service needs to validate that an inventory item exists before placing an order. Naive solution: REST call to Inventory Service on every order.
Better: Inventory Service publishes events. Order Service consumes them and keeps a local snapshot.
// Inventory Service — publish event when inventory is created/updated
@Service
public class InventoryService {
@Autowired
private KafkaTemplate<String, InventoryEvent> kafkaTemplate;
public Inventory createInventory(CreateInventoryRequest request) {
Inventory inventory = inventoryRepository.save(new Inventory(request));
kafkaTemplate.send("inventory.events", inventory.getSku(),
new InventoryEvent(
inventory.getId(),
inventory.getSku(),
inventory.getQuantity(),
EventType.CREATED
)
);
return inventory;
}
}
// Order Service — consume and snapshot
@Service
public class InventorySnapshotConsumer {
@Autowired
private InventorySnapshotRepository snapshotRepo;
@KafkaListener(topics = "inventory.events", groupId = "order-service")
public void onInventoryEvent(InventoryEvent event) {
InventorySnapshot snapshot = snapshotRepo
.findBySku(event.getSku())
.orElse(new InventorySnapshot());
snapshot.setSku(event.getSku());
snapshot.setAvailableQuantity(event.getQuantity());
snapshot.setLastUpdated(Instant.now());
snapshotRepo.save(snapshot);
}
}
// Now validation is just a local DB lookup — no REST call
@Service
public class OrderService {
public void validateInventory(String sku, int quantity) {
InventorySnapshot snapshot = snapshotRepo.findBySku(sku)
.orElseThrow(() -> new InventoryNotFoundException(sku));
if (snapshot.getAvailableQuantity() < quantity) {
throw new InsufficientInventoryException(sku);
}
}
}
⚠️ Trade-off: Events give you eventual consistency. If the snapshot hasn't caught up yet, validation may pass on stale data. Design your system to handle this — use idempotent consumers, compensating transactions, or re-validation at fulfillment time.
6. 🏗️ Know How to Architect — AI Doesn't Know Your Requirements
This one isn't a config snippet. It's the meta-skill.
AI can generate a microservice scaffold in seconds. It cannot answer:
- "Should this be a separate service or stay in the monolith?"
- "Will this DB schema survive 10x traffic?"
- "Is this the right consistency model for this transaction?"
These require you to understand the system you're building.
The questions you should be asking
Before designing a service:
- What's the expected read/write ratio?
- What's the consistency requirement? Can we tolerate stale data for 500ms?
- What fails if this service goes down? Who's the caller? Can they retry?
Before adding a database:
- Is this data relational or document-shaped?
- What are the query patterns? Point lookups, range scans, aggregations?
- Do we need ACID guarantees, or is BASE acceptable?
Before scaling horizontally:
- What's the actual bottleneck? CPU? DB? Network? Memory?
- Is state being stored in-process? (That's a problem when you add instances.)
- Can we cache this instead of scaling?
Use AI right
AI is a force multiplier when you're in the driver's seat:
# Good use of AI
"Here's my schema and access patterns.
Write me a JPA repository with an EntityGraph for this use case."
# Bad use of AI
"Build me a scalable microservice for inventory management."
# You'll get something that looks right but may be completely wrong for your needs.
The engineers who thrive in the AI era aren't the best prompt writers. They're the ones who can review, challenge, and take ownership of what AI produces.
Summary
| # | Concept | Key Takeaway |
|---|---|---|
| 1 | Thread Pool | Size to your workload type — CPU-bound ≠ IO-bound |
| 2 | HikariCP | Configure pool size explicitly; keep transactions short |
| 3 | N+1 Queries | Use @EntityGraph or JOIN FETCH; log SQL in dev |
| 4 | Payload Size | Exclude nulls; use DTOs and projections |
| 5 | Kafka Events | Snapshot remote data locally; remove synchronous coupling |
| 6 | Architecture | Own your design decisions — AI doesn't know your constraints |
If you're working with Java and Spring Boot and want to go deeper on any of these topics, drop a comment. Happy to do a follow-up on any specific area — Kafka exactly-once semantics, HikariCP tuning for read replicas, or virtual threads in Java 21 are all on the list.
#java #springboot #backend #softwaredevelopment #architecture
Top comments (0)