DEV Community

Cover image for #6 Things Every Backend Engineer Should Know (That AI Won't Tell You)
Aayush Ghimire
Aayush Ghimire

Posted on

#6 Things Every Backend Engineer Should Know (That AI Won't Tell You)

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
Enter fullscreen mode Exit fullscreen mode

⚠️ Don't just bump max to 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

💡 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
}
Enter fullscreen mode Exit fullscreen mode

Or globally via config:

# application.yml
spring:
  jackson:
    default-property-inclusion: non_null
    serialization:
      write-dates-as-timestamps: false
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

⚠️ 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.
Enter fullscreen mode Exit fullscreen mode

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)