engineering

@Component, @Service, @Repository khác gì, thay được nhau không?

@Service, @Repository, @Controller đều là @Component bên dưới — nhưng chỉ @Repository dịch exception, nên đổi nhầm annotation có thể lặng lẽ làm hỏng app.

OLHub Team7 tháng 7, 2026 · 7 phút đọc

Một buổi chiều tôi ngồi ở ghế người phỏng vấn. Ứng viên là một bạn backend Java, CV kín Spring Boot, vài dự án thật kể ra rành mạch. Tôi hỏi một câu vẫn hay dùng để mở màn vì tưởng là dễ: "@Component, @Service, @Repository, @RestController khác nhau chỗ nào? Có thay thế nhau được không?"

Bạn ấy trả lời trơn tru nửa đầu: @Service cho tầng service, @Repository cho tầng data, @Controller cho web. Rồi tới vế thứ hai thì khựng lại: "Còn thay thế nhau... chắc được anh, tại em thấy để cái nào thì bean cũng chạy mà." Đúng một nửa — và đúng cái nửa còn lại mới là chỗ khiến câu này đáng hỏi.

Tôi không trừ điểm bạn ấy nặng, vì thú thật, gần như mọi ứng viên tôi từng hỏi đều dừng ở đúng chỗ đó — kể cả người đã viết Spring vài năm. Bốn cái annotation này bị dùng như thể chỉ khác nhau ở cái tên, nên chẳng mấy ai tra xuống bên dưới. Nên tôi viết bài này: để lần tới ai gặp câu đó đều trả lời được — không phải nhờ học thuộc, mà vì hiểu mỗi cái thật ra bật lên điều gì.

Tiết lộ. @Service, @Repository, @Controller, @RestController bên dưới đều chỉ là @Component được đặt tên theo tầng — về mặt đăng ký bean, chúng gần như thay thế nhau được. Nhưng mỗi cái mang thêm một hành vi ẩn: @Repository bật exception translation (dịch lỗi JPA/Hibernate thành DataAccessException), còn @Controller/@RestController mới được Spring MVC định tuyến. Đổi tên annotation vì thế không bao giờ chỉ là đổi tên — bạn có thể gỡ mất một hành vi mà không hay. Riêng @Bean là một cơ chế khác hẳn: khai báo bean bằng method, dành cho class bạn không sửa được source.

Bốn cái tên, một gốc rễ

Mở source của Spring ra, bí ẩn tan ngay. @Service, @Repository, @Controller không phải bốn thứ độc lập — cả ba đều được đánh dấu bằng chính @Component. Trong thế giới Java, một annotation đặt trên một annotation khác gọi là meta-annotation: @Service "là một" @Component, y như OrderService "là một" class được Spring quản. @RestController đi thêm một bậc — nó là @Controller cộng thêm @ResponseBody.

flowchart TB
    Component["@Component<br/>(goc: 'class nay la Spring bean')"]
    Service["@Service<br/>(tang business)"]
    Repository["@Repository<br/>(tang data)"]
    Controller["@Controller<br/>(tang web MVC)"]
    Rest["@RestController<br/>= @Controller + @ResponseBody"]

    Component --> Service
    Component --> Repository
    Component --> Controller
    Controller --> Rest

Khi khởi động, @ComponentScan quét classpath và gom mọi class mang @Component — hoặc mang một stereotype kế thừa từ nó — rồi đăng ký thành bean. Nó tra cả chuỗi annotation, không chỉ cái dán trực tiếp. Đó là lý do bạn đổi @Repository thành @Component mà app vẫn chạy, bean vẫn được tạo, khởi động vẫn êm: về mặt đăng ký, cả hai nói đúng một câu với container. Ứng viên hôm ấy không sai hoàn toàn — ở tầng này chúng thật sự thay nhau được, nên câu "để cái nào bean cũng chạy" nghe có vẻ đúng. Cơ chế scan biến class thành bean ra sao, bài Stereotype & @ComponentScan mổ tới tận lớp đọc bytecode.

Vấn đề là "đăng ký bean" không phải toàn bộ công việc của mấy cái tên đó.

Thay thế được tới đâu?

Đây là bảng tôi ước gì đã có sẵn để đưa bạn ấy xem — bốn dòng trả lời gọn câu "đổi cái này thành cái kia thì mất gì":

Đổi từ → sangBean vẫn đăng ký?Cái mất đi
@Service@ComponentKhông mất gì — chỉ mất ý nghĩa tầng cho người đọc và cho AOP
@Repository@ComponentException translation — lỗi DB không còn được dịch thành DataAccessException
@Controller/@RestController@ServiceĐịnh tuyến — Spring MVC bỏ qua, endpoint biến mất, gọi vào là 404
Bất kỳ stereotype → @Bean cho class third-partyKhông dán được: bạn không sửa source class thư viện

Nhìn bảng là thấy: @Service với @Component gần như hoán đổi tự do, khác biệt chỉ là ý định — bạn đang tuyên bố "đây là tầng nghiệp vụ", vừa cho người đọc code vừa cho các AOP aspect muốn nhắm đúng tầng đó. Nhưng hai dòng giữa mới là chỗ "đổi tên thuần" hoá ra cắt cụt mất một hành vi — và cũng là chỗ câu trả lời "để cái nào bean cũng chạy" bắt đầu trả giá.

Vì sao mất @Repository là mất luôn cỗ máy dịch lỗi

@Repository là stereotype duy nhất có hành vi lúc chạy vượt ngoài việc làm bean. Cơ chế thế này: khi thấy một bean gắn @Repository, Spring bọc nó trong một proxy — cùng họ với cái proxy đứng sau @Transactional mà tôi đã kể trong bài @Transactional không rollback. Proxy đó rình mọi lời gọi method: nếu bên trong ném ra một lỗi runtime đặc thù của JPA/Hibernate — PersistenceException của JPA, ConstraintViolationException của Hibernate — nó chặn lại và dịch sang một exception trong họ DataAccessException của Spring. (Lỗi từ JDBC thuần đi một đường khác: chính JdbcTemplate mới là chỗ dịch SQLException, không phải proxy @Repository.)

Nhờ lớp dịch đó, tầng service chỉ cần bắt một thứ trừu tượng, không phải bắt đúng exception của từng công nghệ lưu trữ. Hình dung một tầng đối soát cần chạy lại được nhiều lần mà không ghi trùng:

@Repository
public class ReconEntryRepository {
    @PersistenceContext
    private EntityManager em;

    public void insert(ReconEntry e) {
        em.persist(e);
        em.flush();   // ep INSERT chay NGAY -> trung khoa nem loi ngay tai day, trong method
    }
}

@Service
public class ReconService {
    private final ReconEntryRepository repo;

    @Transactional
    public void replay(ReconEntry e) {
        try {
            repo.insert(e);
        } catch (DataAccessException ex) {
            // ghi trung khi replay la binh thuong -> nuot gon, bo qua de job chay tiep
        }
    }
}

Chừng nào ReconEntryRepository còn mang @Repository, lỗi ghi trùng nổi lên sẽ được proxy dịch thành DataIntegrityViolationException — một nhánh của DataAccessException — nên khối catch bắt trúng và nuốt gọn — luồng replay chạy lại được như ý đồ idempotent.

Bỏ @Repository, thay bằng @Component, cái proxy dịch lỗi không còn được lắp. Lỗi persistence native bay thẳng ra ngoài, không đội lốt DataAccessException nữa — nên catch (DataAccessException) trượt, exception xuyên qua service, và cả luồng replay gãy ngay bản ghi trùng đầu tiên. Không một dòng logic nào sai; chỉ là cái tên annotation vừa rút mất một mắt xích runtime.

// SAI: mat exception translation -> loi DB native khong bi bat boi catch(DataAccessException)
@Component
public class ReconEntryRepository { /* ... */ }

// DUNG: @Repository bat proxy dich PersistenceException (JPA/Hibernate) -> DataAccessException
@Repository
public class ReconEntryRepository { /* ... */ }

Với tầng data, luôn dùng @Repository — không phải vì "quy ước cho đẹp", mà vì nó bật một hành vi runtime thật. Chi tiết cơ chế PersistenceExceptionTranslationPostProcessor: xem Stereotype & @ComponentScan §3.1.

Hai cái bẫy "thay thế" còn lại

Trả lời được tới đây là đã qua phần khó nhất của câu hỏi. Nhưng còn hai cái bẫy "thay thế" nữa mà tôi luôn mong nghe ứng viên nhắc tới:

Đổi @RestController (hay @Controller) thành @Service thì endpoint biến mất. DispatcherServlet — trái tim của Spring MVC — chỉ xét các method @GetMapping/@PostMapping trên class mang @Controller hoặc @RestController. Dán @Service lên một web controller thì bean vẫn tạo bình thường, nhưng không một route nào được đăng ký; gọi vào URL đó chỉ nhận 404, và chẳng có lỗi khởi động nào để bạn lần theo. @RestController thêm phần @ResponseBody, nghĩa là giá trị method trả về được tuần tự hoá thẳng thành JSON thay vì bị hiểu là tên view. Cơ chế định tuyến này nằm ở bài DispatcherServlet@RestController mapping.

@Bean không cùng loại với đám stereotype. Stereotype là annotation bạn dán lên class mình viết, để Spring tự quét. Nhưng khi cần một bean từ class thư viện third-party — một DataSource, một ObjectMapper — bạn không sửa được source của nó để dán @Component. Lúc này bạn khai báo bean bằng một method @Bean trong class @Configuration: tự tay dựng object rồi trả về cho container quản. Đó là ranh giới sạch giữa hai cơ chế — @Component cho class của bạn, @Bean cho class bạn chỉ có quyền dùng. Bài @Bean & @Configuration đào sâu chỗ này.

Bạn ứng viên hôm ấy không trả lời được không phải vì kém — mà vì bốn cái tên này bị dạy và dùng như thể chỉ khác nhau ở tầng chữ nghĩa, nên gần như ai cũng dừng ở "để cái nào bean cũng chạy". Nhưng giờ thì bạn — người đang đọc — trả lời được tới tận cơ chế: bốn stereotype nhìn thì thay nhau được, nhưng mỗi cái cõng theo một hành vi ẩn, nên đổi tên annotation không bao giờ là đổi tên thuần. Muốn chắc mình đang tuyên bố đúng điều với container, đừng đoán theo cái tên; hiểu cái mỗi tên bật lên bên dưới.

Muốn đi tới tận cơ chế scan, proxy và meta-annotation, khoá Spring Core & Boot bóc từng lớp một — bắt đầu từ Stereotype & @ComponentScan. Đây là bài đầu của series Spring bản chất; bài sau ta mổ vì sao @Configuration cần một CGLIB proxy mà @Component thì không.

Sẵn sàng học sâu hơn?

Biến những gì vừa đọc thành kỹ năng thật với khoá học của OLHub, hoặc mang câu hỏi của bạn ra thảo luận cùng cộng đồng.

Đọc tiếp

Bài viết liên quan