Junior dev thời AI: học gì để không bị thay thế
1. Tổng quan
Các công cụ sinh code giờ đã viết được controller, entity hay test mẫu trong vài giây. Vì vậy nhiều bạn mới vào nghề lo rằng vị trí junior sẽ biến mất. Bài này không dự đoán tương lai. Bài tập trung vào một câu hỏi thực tế hơn: khi gõ code không còn là phần khó, bạn nên luyện kỹ năng gì?
Đọc xong, bạn sẽ có danh sách kỹ năng cần ưu tiên. Mỗi kỹ năng đi kèm một ví dụ Java mà bạn có thể chạy ngay. Bài cũng nêu rõ khi nào không nên giao việc cho trợ lý code.
2. Vì sao câu hỏi này đang được quan tâm
Trước đây, junior thường được giao các việc lặp lại như viết CRUD, mapping DTO hay sửa lỗi nhỏ. Đó cũng là cách họ học nghề. Nay phần lớn các việc này có thể sinh tự động. Chi phí tạo ra code giảm mạnh.
Chi phí sai thì vẫn như cũ. Code sinh ra có thể biên dịch được, trông hợp lý và vượt qua một test đơn giản, nhưng vẫn sai ở những chỗ tinh vi: tiền tệ, concurrency, transaction, bảo mật. Người chịu trách nhiệm khi code lên production vẫn là bạn.
Vì vậy giá trị dịch chuyển từ “viết được code” sang ba việc:
- Đánh giá code đúng hay sai và vì sao.
- Kiểm chứng hành vi bằng test.
- Hiểu runtime, framework và hệ thống mà code chạy trong đó.
Các mục sau đi lần lượt qua từng kỹ năng.
3. Đọc và review code tốt hơn viết code nhanh
Kỹ năng đầu tiên là nhìn một đoạn code “trông ổn” và phát hiện ra nó sai. Giả sử bạn nhờ trợ lý code viết hàm tính tổng đơn hàng và nhận được đoạn sau:
import java.util.List;
record OrderLine(String product, double price, int quantity) {}
record Order(List<OrderLine> lines) {
double total() {
return lines.stream()
.mapToDouble(l -> l.price() * l.quantity())
.sum();
}
}
public class Main {
public static void main(String[] args) {
var order = new Order(List.of(
new OrderLine("Bút", 0.1, 1),
new OrderLine("Vở", 0.2, 1)));
System.out.println(order.total());
}
}
Code ngắn và dễ đọc, nhưng kết quả là:
0.30000000000000004
double biểu diễn số theo dạng nhị phân dấu phẩy động. Vì thế 0.1 và 0.2 không được lưu chính xác. Với tiền tệ, sai số này cộng dồn qua hàng nghìn giao dịch sẽ thành lỗi đối soát. Bản sửa dùng BigDecimal:
import java.math.BigDecimal;
import java.util.List;
record OrderLine(String product, BigDecimal price, int quantity) {
BigDecimal subtotal() {
return price.multiply(BigDecimal.valueOf(quantity));
}
}
record Order(List<OrderLine> lines) {
BigDecimal total() {
return lines.stream()
.map(OrderLine::subtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
Có ba điểm cần để ý:
pricecó kiểuBigDecimal. Bạn nên tạo giá trị bằngnew BigDecimal("0.1"), không dùngnew BigDecimal(0.1).subtotal()nhân giá với số lượng mà không quadouble.reducebắt đầu từBigDecimal.ZEROnên đơn hàng rỗng vẫn trả về0thay vì lỗi.
Đây chính là kỹ năng mà công cụ chưa thay bạn được. Bạn phải biết đặt câu hỏi về kiểu dữ liệu, giá trị biên và trường hợp rỗng. Khi review, hãy tự hỏi: “Input nào sẽ làm hàm này sai?”
4. Viết test như một bản đặc tả
Nếu không có test, bạn không biết đoạn code vừa nhận về có đúng hay không. Test cũng là cách rõ ràng nhất để mô tả bạn muốn gì. Mô tả càng rõ, code sinh ra hay do đồng nghiệp viết càng dễ kiểm chứng.
Với dự án Spring Boot, bạn chỉ cần một dependency để có JUnit 5 và AssertJ:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Test cho lớp Order ở trên:
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class OrderTest {
@Test
@DisplayName("Tổng tiền cộng chính xác các số thập phân")
void totalIsExactForDecimals() {
var order = new Order(List.of(
new OrderLine("Bút", new BigDecimal("0.1"), 1),
new OrderLine("Vở", new BigDecimal("0.2"), 1)));
assertThat(order.total()).isEqualByComparingTo("0.3");
}
@Test
@DisplayName("Đơn hàng rỗng có tổng bằng 0")
void emptyOrderTotalIsZero() {
var order = new Order(List.of());
assertThat(order.total()).isEqualByComparingTo(BigDecimal.ZERO);
}
@Test
@DisplayName("Tổng tiền nhân đúng theo số lượng")
void totalMultipliesByQuantity() {
var order = new Order(List.of(
new OrderLine("Sách", new BigDecimal("12.50"), 3)));
assertThat(order.total()).isEqualByComparingTo("37.50");
}
}
Một vài chi tiết đáng học:
@DisplayNameghi lại quy tắc nghiệp vụ bằng ngôn ngữ người đọc được. Đọc báo cáo test là hiểu hệ thống phải làm gì.isEqualByComparingToso sánh theo giá trị.new BigDecimal("0.3").equals(new BigDecimal("0.30"))trả vềfalsevì khác scale. Đây là lỗi rất hay gặp.- Mỗi test kiểm tra đúng một hành vi: trường hợp thường, trường hợp rỗng, trường hợp nhân số lượng.
Lưu ý: Đừng để công cụ sinh cả code lẫn test rồi chỉ nhìn thấy màu xanh là yên tâm. Nếu cả hai cùng hiểu sai yêu cầu, test vẫn pass. Bạn nên tự viết ít nhất các test cho quy tắc nghiệp vụ quan trọng.
5. Hiểu runtime: concurrency không thể đoán mò
Phần lớn lỗi khó chỉ xuất hiện khi có nhiều thread chạy cùng lúc. Trong Spring, mọi bean mặc định là singleton, nghĩa là mọi request dùng chung một instance. Đoạn sau trông vô hại:
import java.util.concurrent.Executors;
class ReportService {
private int processedCount = 0;
void process() {
processedCount++;
}
int processedCount() {
return processedCount;
}
}
public class CounterDemo {
public static void main(String[] args) {
var service = new ReportService();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(service::process);
}
}
System.out.println("Đã xử lý: " + service.processedCount());
}
}
Executors.newVirtualThreadPerTaskExecutor() của Java 21 tạo một virtual thread cho mỗi tác vụ. Khối try-with-resources chờ mọi tác vụ chạy xong rồi mới đóng. Ta kỳ vọng in ra 10000, nhưng thực tế thường thấy một con số nhỏ hơn, và mỗi lần chạy một khác:
Đã xử lý: 9871
Lý do là processedCount++ không phải một thao tác nguyên tử. Nó gồm ba bước: đọc, cộng và ghi. Hai thread có thể cùng đọc một giá trị, khiến một lần cộng bị mất. Bản sửa:
import java.util.concurrent.atomic.AtomicInteger;
class ReportService {
private final AtomicInteger processedCount = new AtomicInteger();
void process() {
processedCount.incrementAndGet();
}
int processedCount() {
return processedCount.get();
}
}
Lần này kết quả luôn là 10000. Bài học rộng hơn là: bean singleton không nên giữ trạng thái thay đổi theo request. Khi đọc một @Service, bạn nên tìm các field không final. Đó thường là nơi lỗi đang ẩn.
Bạn không cần thuộc lòng mọi API của java.util.concurrent. Nhưng bạn cần hiểu khái niệm như race condition, visibility hay lock để nhận ra lỗi khi nhìn thấy nó.
6. Hiểu framework: biết “phép thuật” hoạt động ra sao
Spring giúp bạn viết ít code hơn bằng annotation, đổi lại nhiều thứ xảy ra ngầm. Nếu không hiểu cơ chế bên dưới, bạn sẽ bị bất ngờ. Ví dụ kinh điển là self-invocation với @Transactional:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class PaymentService {
private final PaymentRepository paymentRepository;
public PaymentService(PaymentRepository paymentRepository) {
this.paymentRepository = paymentRepository;
}
public void payAll(List<Long> paymentIds) {
paymentIds.forEach(this::pay);
}
@Transactional
public void pay(Long paymentId) {
var payment = paymentRepository.findById(paymentId).orElseThrow();
payment.markPaid();
paymentRepository.save(payment);
}
}
Nhìn qua, mỗi lần gọi pay sẽ chạy trong một transaction. Thực tế thì không. Spring áp dụng @Transactional thông qua một proxy bọc bên ngoài bean. Khi payAll gọi this.pay(...), lời gọi đi thẳng vào object gốc và bỏ qua proxy. Vì vậy không transaction nào được mở.
Có hai cách sửa phổ biến:
- Đặt
@Transactionallên chính phương thức được gọi từ bên ngoài, tứcpayAll, nếu bạn muốn cả lô chạy trong một transaction. - Tách
paysang một bean khác, ví dụSinglePaymentProcessor, rồi inject vàoPaymentServiceđể lời gọi đi qua proxy.
Hiểu được cơ chế proxy giúp bạn tự giải thích nhiều hiện tượng khác: @Async không chạy bất đồng bộ, @Cacheable không cache, hay annotation trên phương thức private bị bỏ qua. Đây là kiến thức nền giúp bạn debug nhanh hơn bất kỳ công cụ gợi ý nào.
7. Quan sát hệ thống thay vì đoán
Code chạy đúng trên máy bạn chưa có nghĩa là chạy đúng trên production. Junior biết đọc log, metric và health check sẽ có giá trị hơn nhiều người chỉ biết viết tính năng. Với Spring Boot, bạn bắt đầu bằng Actuator:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Sau đó cấu hình các endpoint cần dùng:
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: when-authorized
logging:
level:
root: INFO
org.springframework.transaction: DEBUG
Ý nghĩa từng phần:
exposure.includechỉ mở ba endpoint cần thiết. Mở tất cả bằng"*"là lỗi bảo mật hay gặp.show-details: when-authorizedchỉ hiển thị chi tiết health cho người đã xác thực.- Bật
DEBUGchoorg.springframework.transactioncho phép bạn thấy trong log khi nào transaction được mở và commit. Đây là cách kiểm chứng trực tiếp lỗi self-invocation ở mục 6.
Kiểm tra nhanh:
curl http://localhost:8080/actuator/health
{"status":"UP"}
Thói quen cần rèn ở đây là: khi có lỗi, hãy thu thập bằng chứng từ log, metric và stack trace trước khi sửa. Đừng thử ngẫu nhiên từng đoạn code được gợi ý cho đến khi lỗi tự biến mất.
8. Dùng trợ lý code đúng cách và khi nào không nên dùng
Trợ lý code là công cụ tốt. Né tránh nó không giúp bạn tiến bộ hơn. Điều quan trọng là bạn giữ vai trò người quyết định. Quy trình hợp lý gồm bốn bước:
- Tự viết yêu cầu và các test chính trước, như mục 4.
- Nhờ công cụ đề xuất phần cài đặt.
- Đọc từng dòng. Nếu bạn không giải thích được một dòng, đừng merge nó.
- Chạy test, thêm test cho trường hợp biên mà bạn vừa nghĩ ra trong lúc review.
Không nên giao việc cho trợ lý code, hoặc phải đặc biệt cẩn trọng, trong các trường hợp sau:
- Code bảo mật: xác thực, phân quyền, mã hoá, xử lý token. Một lỗi nhỏ ở đây có thể gây hậu quả lớn, và code sai thường trông rất giống code đúng.
- Khi bạn đang học một khái niệm mới. Nếu công cụ viết hộ bạn bài tập concurrency đầu tiên, bạn sẽ bỏ lỡ đúng phần quan trọng nhất của việc học.
- Dữ liệu nhạy cảm: đừng dán secret, thông tin khách hàng hay code nội bộ vào công cụ chưa được công ty cho phép.
- Nghiệp vụ đặc thù mà công cụ không có ngữ cảnh, như quy tắc thuế hay chính sách giảm giá riêng. Hãy hỏi người nắm nghiệp vụ trước.
Lưu ý: Một dấu hiệu nguy hiểm là bạn merge code nhanh hơn tốc độ bạn hiểu nó. Khi đó bạn không còn làm kỹ sư nữa, bạn chỉ đang chuyển tiếp code.
9. Lộ trình gợi ý cho junior
Bạn không cần học tất cả cùng lúc. Một thứ tự hợp lý như sau:
- Nền tảng ngôn ngữ: kiểu dữ liệu, collection,
equals/hashCode, exception, concurrency cơ bản. - Testing: JUnit 5, AssertJ, cách đặt tên test theo hành vi, test trường hợp biên.
- Framework từ bên trong: dependency injection, vòng đời bean, proxy, transaction.
- Dữ liệu: SQL thật, index, transaction isolation, lỗi N+1 query khi dùng JPA.
- Vận hành: đọc log, metric, health check, debug trên môi trường gần giống production.
- Giao tiếp: biến yêu cầu mơ hồ thành tiêu chí chấp nhận rõ ràng, viết mô tả pull request dễ review.
Kỹ năng cuối thường bị xem nhẹ. Người biết hỏi đúng câu, ví dụ “đơn hàng rỗng thì sao?” hay “hai request đến cùng lúc thì sao?”, sẽ luôn cần thiết, dù code được viết bởi ai hay bởi công cụ nào.
10. Kết luận
Trong bài này, ta thấy giá trị của junior dev đang chuyển từ tốc độ gõ code sang khả năng đánh giá, kiểm chứng và hiểu hệ thống. Các ví dụ minh hoạ những lỗi mà code “trông ổn” vẫn mắc phải: sai số double với tiền tệ, race condition trong bean singleton, và @Transactional bị bỏ qua do self-invocation. Bạn cũng đã thấy cách dùng test và Actuator để tự kiểm chứng thay vì đoán.
Hãy dùng trợ lý code như một đồng nghiệp viết nhanh nhưng hay nhầm. Bạn tận dụng tốc độ của nó, nhưng luôn đọc lại, viết test và hiểu rõ mọi dòng trước khi merge. Đó là cách để bạn không bị thay thế và trở thành người mà nhóm khó thiếu.