The Skills AI Made Cheap, and the Ones It Made Valuable
Java is fine and Spring Boot is fine. What is changing is which parts of knowing them still set you apart.
Try a small experiment. Ask an AI assistant for a Spring Boot service that lets guests reserve a table at a restaurant: endpoint, validation, persistence, tests. Start a stopwatch.
By the time your coffee cools you will have something that compiles, follows the usual conventions, and looks like what a solid mid-level developer would submit for review.
That is the part the “should I switch to Python?” debate keeps missing. AI is not a verdict on a language. It writes Python, Go and TypeScript just as fluently as Java. What it has done is reprice a set of skills.
Typing out well-known patterns got cheap. Knowing what happens to those patterns once real traffic, real concurrency and real failures reach them did not.
Below are six places where code that looks perfect still goes wrong, and where I would spend learning time before collecting another annotation.
The Easy Part Just Got Easy
The prompt from a moment ago deserves a closer look:
Build a Spring Boot endpoint for reserving a table.
Validate the input, persist the reservation, return 201,
and return clean errors for bad requests.The answer is not a sketch. It is code you could merge:
public record ReservationRequest(
@NotNull Long slotId,
@Min(1) @Max(12) int partySize,
@NotBlank String guestName) {}
@PostMapping("/reservations")
@ResponseStatus(HttpStatus.CREATED)
public ReservationResponse reserve(@Valid @RequestBody ReservationRequest request) {
return reservationService.reserve(request);
}The repository, the migration, an @RestControllerAdvice for errors and a handful of tests come along with it.
A few years ago, producing all of that without opening the docs was a sign of seniority. Today it is the entry fee.
Notice what the snippet does not tell you: what happens when two guests ask for the last seats at 8 p.m. in the same instant? Hold that thought, we will come back to it.
The Query That Was Fast on Your Laptop
Imagine an inbox screen that shows a user’s unread notifications:
SELECT *
FROM notifications
WHERE user_id = 42 AND read_at IS NULL
ORDER BY created_at DESC
LIMIT 20;With a few thousand rows it is instant. With 200 million rows, most of them old and already read, it can crawl even though user_id is indexed. The index finds all of that user's notifications, years of history included, and PostgreSQL then filters out the read ones and sorts what is left.
The plan tells the story if you know where to look:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM notifications
WHERE user_id = 42 AND read_at IS NULL
ORDER BY created_at DESC
LIMIT 20;A large Rows Removed by Filter number and an explicit Sort step are the clues. The fix is an index that contains only what the screen asks for:
CREATE INDEX CONCURRENTLY idx_notifications_unread
ON notifications (user_id, created_at DESC)
WHERE read_at IS NULL;This is a partial index. It stores unread rows only, so it stays small enough to live in memory, it is only touched when rows enter or leave the unread set, and it hands PostgreSQL the 20 rows already in order.
There is a catch: the planner only uses it when the query repeats the read_at IS NULL condition, so an innocent refactor of the repository method can quietly bring the slowdown back.
Paste the plan into an assistant and it will narrate it nicely. What it cannot give you is the instinct to look at the plan first, or the judgment to tell whether its explanation fits your data.
@Transactional Is a Contract With Fine Print
Most of us learn @Transactional as a safety blanket: put it on a service method and the data stays consistent.
It does not quite work like that. Consider:
@Transactional
public void placeOrder(Cart cart) throws OutOfStockException {
orderRepository.save(Order.from(cart));
inventory.reserve(cart); // throws OutOfStockException (checked)
}The exception propagates, the caller sees a failure, and everyone assumes nothing was saved. But by default Spring rolls back only for unchecked exceptions. For a checked one, it commits. You now have an order with no reserved stock.
The fix is one attribute, once you know it exists:
@Transactional(rollbackFor = OutOfStockException.class)There is more fine print of the same kind. The annotation works through a proxy, so calling a @Transactional method from another method of the same class bypasses the proxy and its settings are never applied. And the database connection stays checked out for the whole method, so slow work inside it is expensive.
An assistant will write the annotation every time. What protects your data is knowing what it actually promises and where that promise ends.
When Kafka Says It Twice
Kafka’s default promise is “at least once”, not “exactly once”. After a rebalance, a crash or a retry, your consumer can receive the same record a second time. Here is a listener that looks fine:
@KafkaListener(topics = "orders-shipped")
public void onShipped(OrderShipped event) {
emailService.sendShippingNotice(event.orderId());
invoiceRepository.save(Invoice.forOrder(event.orderId()));
}The offset is committed only after the method returns. If the instance dies right after the invoice is saved, the record is delivered again after the restart, and the customer gets two emails and two invoices.
The cure is to make the handler safe to run twice. Store the event id under a unique key in the same database transaction as the change it protects:
CREATE TABLE processed_events (event_id VARCHAR PRIMARY KEY);The primary key guarantees that the same id can be stored only once. The listener uses that to decide whether a message is new:
@KafkaListener(topics = "orders-shipped")
@Transactional
public void onShipped(OrderShipped event) {
int fresh = jdbc.update(
"INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING",
event.eventId());
if (fresh == 0) {
return; // seen before, skip quietly
}
invoiceRepository.save(Invoice.forOrder(event.orderId()));
}Here is what happens on each delivery:
- The listener inserts the event id.
ON CONFLICT DO NOTHINGmeans that if the id is already there, the insert quietly adds nothing, and the return value tells us whether a row was added. - One row added: the message is new, so the invoice is saved.
- Zero rows added: we handled this message before, so the method returns without doing anything.
Because both writes share one transaction, a crash cannot leave one without the other. If the pod dies after the commit but before Kafka records the offset, the redelivered message finds its id already stored and is skipped. If it dies before the commit, both writes roll back and the redelivery is processed normally.
Get Lampis Papantoniou’s stories in your inbox
Join Medium for free to get updates from this writer.
Side effects that cannot be rolled back, like that email, belong behind an outbox table or an after-commit hook, otherwise they will repeat anyway.
None of this is visible in a happy-path test. It only shows up on the day a pod restarts mid-batch.
Slow Is Worse Than Down
A service that is down fails fast and gets noticed. A service that answers in twenty seconds instead of forty milliseconds is much more dangerous.
Picture a checkout that calls an external shipping-rate API. Normally it replies in 40 ms. One afternoon it starts taking 20 seconds. Your client was created like this:
RestClient shipping = RestClient.create("https://rates.example.com");No explicit timeouts. Each checkout request now parks a Tomcat thread for 20 seconds. Tomcat defaults to 200 worker threads, so at 50 checkouts per second the pool is gone in about four seconds, and then every endpoint hangs, including pages that never touch shipping.
On the dashboards it looks strange: CPU around 20%, heap healthy, no exceptions, and still every request is slow. The tell is busy threads sitting at their maximum while the shipping client’s latency has gone vertical.
The fix starts with deadlines on every outbound call:
HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(500))
.build();
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(http);
factory.setReadTimeout(Duration.ofSeconds(2));
RestClient shipping = RestClient.builder()
.baseUrl("https://rates.example.com")
.requestFactory(factory)
.build();Then add a circuit breaker with a sensible fallback, such as a flat shipping rate, so one struggling dependency degrades a feature instead of the whole application.
Nothing in the original code would be flagged by a compiler or by a reviewer skimming the diff. You only see it if you have learned how systems behave under failure.
When the Cache Becomes a Dependency
Product pages are cached in Redis for five minutes. One line of Spring does it:
@Cacheable("product")
public ProductView find(Long id) {
return repository.loadView(id); // expensive joins
}Two things can go wrong, and both are invisible until traffic is high.
First, a hot product’s entry expires and 2,000 concurrent requests all miss at once. Every one of them runs the expensive query, and the database takes the hit the cache was supposed to absorb. Telling Spring to load each missing key only once helps a lot:
@Cacheable(value = "product", sync = true)With sync = true, concurrent callers on the same instance wait for a single load. Several instances still load once each, which is a much smaller stampede, and spreading out expiry times (for example with a custom TTL per entry) smooths it further.
Second, Redis itself becomes slow or unreachable. The Lettuce client waits up to 60 seconds for a command by default, so you are back in the previous section: threads pile up behind a dependency that was only meant to be an optimization. Set short timeouts and treat a failed cache read as a miss:
spring:
data:
redis:
connect-timeout: 200ms
timeout: 200ms@Configuration
class CacheConfig implements CachingConfigurer {
private static final Logger log = LoggerFactory.getLogger(CacheConfig.class);
@Override
public CacheErrorHandler errorHandler() {
return new SimpleCacheErrorHandler() {
@Override
public void handleCacheGetError(RuntimeException e, Cache cache, Object key) {
log.warn("Cache read failed, using the database", e);
}
};
}
}Both overrides matter. After a miss, Spring writes the freshly loaded value back to Redis, so without the second one a dead Redis would still fail the request on the way out.
The harder question is the one no annotation answers: if Redis disappears and every request falls through to PostgreSQL, does the database survive? If not, you need stale data, a rate limit or load shedding on top.Two Guests, One Last Table
Two Guests, One Last Table
Back to the reservation service. The method looks harmless:
@Transactional
public Reservation reserve(Long slotId, int party) {
TimeSlot slot = slots.findById(slotId).orElseThrow();
if (slot.getSeatsLeft() < party) {
throw new SlotFullException();
}
slot.setSeatsLeft(slot.getSeatsLeft() - party);
return reservations.save(new Reservation(slotId, party));
}Now two parties of four book the last four seats within the same few milliseconds, handled by two instances. Both read seatsLeft = 4. Both pass the check. Both write 0. Eight guests hold a reservation for four seats.
synchronized cannot help, because the two threads do not live in the same JVM. The only thing the instances share is the database, so that is where the decision must happen, in a single atomic statement:
@Modifying
@Query("UPDATE TimeSlot s SET s.seatsLeft = s.seatsLeft - :n " +
"WHERE s.id = :id AND s.seatsLeft >= :n")
int takeSeats(@Param("id") Long id, @Param("n") int n);@Transactional
public Reservation reserve(Long slotId, int party) {
if (slots.takeSeats(slotId, party) == 0) {
throw new SlotFullException(); // unchecked, so it rolls back
}
return reservations.save(new Reservation(slotId, party));
}The database checks and decrements in one step, so exactly one of the two requests gets the seats. If you prefer to keep the entity-style code, a @Version column with a retry achieves the same outcome.
The first version is what an assistant, or a tired developer, will often write first. It is readable, tidy and wrong under load.
What I’d Learn Now
If I had one free evening a week for the next three months, this is the order I would follow. Each item comes with a question, and if your answer is fuzzy, that is where to start.
- SQL and the PostgreSQL planner. Can you explain, from a query plan alone, why a query got slower as the table grew? Learn indexes, statistics and locks until
EXPLAINfeels like an everyday tool. - Transaction semantics. Do you know exactly which exceptions roll back a transaction in your code, and which ones don’t? Add isolation levels, propagation and what the connection pool does while you wait.
- Resilience basics. What is the timeout on every outbound call your service makes? Then retries with backoff, circuit breakers and idempotency keys.
- Concurrency beyond one JVM. What happens if two instances run the same method at the same instant? Atomic statements, optimistic locking and constraints as the source of truth.
- Observability. When latency doubles and nothing has been deployed, what is the first graph you open? Micrometer metrics, latency percentiles, thread and pool saturation, and tracing one request across services.
And one habit to go with it: whenever an assistant hands you code, ask what breaks under load, then check the answer yourself.
Keep Spring Boot, Raise the Bar
None of this is an argument against Spring Boot. It remains one of the best ways to meet serious backend problems. Learn the framework well enough that it stops being mysterious, then use it as a doorway rather than a destination.
Code is getting cheaper to write every month. Judgment about how it behaves in production is not. I would spend my hours there.







