Tính năng admin global search trong EzyPlatform được thiết kế theo hướng rất gọn, mở rộng tốt và phù hợp với kiến trúc plugin. Thay vì dồn toàn bộ logic tìm kiếm vào một service khổng lồ, hệ thống chia nhỏ trách nhiệm cho nhiều thành phần độc lập. Mỗi module hoặc plugin chỉ cần đăng ký một AdminSearchService là có thể tự đóng góp kết quả vào ô tìm kiếm chung của trang quản trị.
Cách làm này giúp global search không bị phụ thuộc cứng vào một domain cụ thể như sản phẩm, người dùng hay bài viết. Bất kỳ plugin nào cũng có thể tham gia vào luồng tìm kiếm miễn là triển khai đúng interface.
Screenshot 2026-03-08 at 20.41.40.png

Tổng quan kiến trúc

Ở mức khái quát, luồng xử lý của admin global search như sau:
flowchart TD
    A[Admin nhập keyword] --> B[GET /api/v1/search]
    B --> C[AdminApiSearchController]
    C --> D[Trim keyword và kiểm tra rỗng]
    D --> E[Lấy danh sách AdminSearchService từ singletonFactory]
    E --> F[Sắp xếp theo priority]
    F --> G[Duyệt từng AdminSearchService]
    G --> H[Gọi search adminId adminRoles keyword]
    H --> I[Service trả về List SearchResultModel]
    I --> J[Controller gộp toàn bộ kết quả]
    J --> K[Trả response cho frontend]
Kiến trúc này có một số đặc điểm nổi bật:
  • Controller đóng vai trò điều phối
  • Mỗi plugin tự chịu trách nhiệm tìm kiếm dữ liệu của mình
  • Kết quả được chuẩn hóa về chung một model là SearchResultModel
  • Hệ thống hỗ trợ mở rộng mà không cần sửa controller trung tâm

Controller trung tâm của global search

Đây là entry point của tính năng tìm kiếm toàn cục trong admin:
@Api
@Authenticated
@Controller("/api/v1")
public class AdminApiSearchController {

    private final EzyLazyInitializer<List<AdminSearchService>> searchServices;

    public AdminApiSearchController(
        EzySingletonFactory singletonFactory
    ) {
        searchServices = new EzyLazyInitializer<>(() -> {
            @SuppressWarnings("unchecked")
            List<AdminSearchService> searchServices = singletonFactory
                .getSingletonsOf(AdminSearchService.class);
            searchServices.sort(
                Comparator.comparingInt(AdminSearchService::priority)
            );
            return searchServices;
        });
    }

    @DoGet("/search")
    public List<SearchResultModel> searchGet(
        @AdminId long adminId,
        @AdminRoles AdminRolesProxy adminRoles,
        @RequestParam("keyword") String keyword
    ) {
        String trimmedKeyword = trimOrNull(keyword);
        if (trimmedKeyword == null) {
            return Collections.emptyList();
        }
        List<SearchResultModel> result = new ArrayList<>();
        for (AdminSearchService searchService : searchServices.get()) {
            try {
                result.addAll(
                    searchService.search(
                        adminId,
                        adminRoles,
                        trimmedKeyword
                    )
                );
            } catch (Exception e) {
                // do nothing
            }
        }
        return result;
    }
}
Nhìn vào controller này có thể thấy rõ tư tưởng thiết kế của hệ thống: controller không biết chi tiết từng domain cần tìm kiếm, nó chỉ biết rằng có nhiều AdminSearchService, và mỗi service có thể trả về một nhóm kết quả phù hợp với keyword.

Cách request được xử lý

Khi admin gọi endpoint:
GET /api/v1/search?keyword=...
controller thực hiện các bước sau:

Xác thực và lấy ngữ cảnh người dùng quản trị

Method searchGet nhận vào:
  • adminId
  • adminRoles
  • keyword
Điều này cho thấy global search không chỉ đơn thuần là tìm theo từ khóa, mà còn có khả năng tìm kiếm dựa trên ngữ cảnh phân quyền. Một plugin hoàn toàn có thể dựa vào adminId hoặc adminRoles để quyết định:
  • có cho phép trả kết quả hay không
  • trả loại kết quả nào
  • ẩn bớt dữ liệu nhạy cảm với một số nhóm quyền
Đây là điểm rất quan trọng trong môi trường admin, nơi mà cùng một keyword nhưng mỗi admin có thể nhìn thấy tập kết quả khác nhau.

Chuẩn hóa keyword đầu vào

Controller gọi:
String trimmedKeyword = trimOrNull(keyword);
if (trimmedKeyword == null) {
    return Collections.emptyList();
}
Mục đích là:
  • loại bỏ khoảng trắng thừa
  • tránh xử lý keyword rỗng
  • trả về rỗng sớm để giảm chi phí hệ thống

Nạp danh sách search service

Controller không hard-code danh sách service. Thay vào đó, nó lấy toàn bộ singleton triển khai AdminSearchService từ EzySingletonFactory.
List<AdminSearchService> searchServices = singletonFactory
    .getSingletonsOf(AdminSearchService.class);
Điều này có nghĩa là:
  • plugin nào đăng ký một bean/service implement AdminSearchService
  • thì bean đó sẽ tự động trở thành một phần của global search
Đây chính là nền tảng để global search hoạt động theo kiểu mở rộng động.

Sắp xếp service theo độ ưu tiên

Danh sách service được sort bằng:
searchServices.sort(
    Comparator.comparingInt(AdminSearchService::priority)
);
Nhờ đó, hệ thống có thể kiểm soát thứ tự chạy của các nguồn tìm kiếm. Service nào có priority() nhỏ hơn sẽ chạy trước.
Cơ chế này hữu ích khi:
  • muốn các kết quả quan trọng xuất hiện sớm hơn
  • muốn một số plugin lõi chạy trước plugin mở rộng
  • muốn kiểm soát trải nghiệm tìm kiếm mà không cần thay đổi controller

Duyệt toàn bộ service và gộp kết quả

Controller tạo một list kết quả chung:
List<SearchResultModel> result = new ArrayList<>();
Sau đó gọi lần lượt:
for (AdminSearchService searchService : searchServices.get()) {
    try {
        result.addAll(
            searchService.search(
                adminId,
                adminRoles,
                trimmedKeyword
            )
        );
    } catch (Exception e) {
        // do nothing
    }
}
Từng service sẽ tự xử lý domain của nó, ví dụ:
  • sản phẩm
  • đơn hàng
  • người dùng
  • bài viết
  • plugin cấu hình
  • menu quản trị
Các kết quả được gộp lại thành một danh sách thống nhất để trả về cho frontend.

Cơ chế mở rộng qua interface AdminSearchService

Interface này là hợp đồng chung để các plugin tham gia vào global search:
public interface AdminSearchService {
    default List<SearchResultModel> search(long adminId, AdminRolesProxy rolesProxy, String keyword) {
        return this.search(adminId, keyword);
    }

    default List<SearchResultModel> search(long adminId, String keyword) {
        return this.search(keyword);
    }

    default List<SearchResultModel> search(String keyword) {
        return Collections.emptyList();
    }

    default int priority() {
        return 0;
    }
}
Thiết kế này rất đáng chú ý vì nó cho phép nhiều mức độ cài đặt khác nhau.
Một plugin đơn giản có thể chỉ cần override:
search(String keyword)
Trong khi plugin phức tạp hơn có thể override:
search(long adminId, AdminRolesProxy rolesProxy, String keyword)
để xử lý theo quyền hạn.
Các default method ở đây đóng vai trò như một lớp tương thích ngược và giảm chi phí triển khai. Plugin developer không bị bắt buộc phải quan tâm đến toàn bộ context nếu họ không cần.

Ví dụ một service tìm kiếm sản phẩm

Một cài đặt cụ thể của AdminSearchService có thể trông như sau:
@Service
@AllArgsConstructor
public class AdminEcommerceAdminSearchControllerService
    implements AdminSearchService {

    private final AdminProductService productService;

    @Override
    public List<SearchResultModel> search(String keyword) {
        if (isBlank(keyword)) {
            return Collections.emptyList();
        }
        List<ProductModel> products = productService
            .getProductsByKeywordPrefix(
                keyword,
                LIMIT_30_RECORDS
            );
        return newArrayList(products, it ->
            SearchResultModel.builder()
                .title(it.getProductCode())
                .resultType("product")
                .content(it.getProductName())
                .url("/ecommerce/products/" + it.getId())
                .build()
        );
    }
}
Service này cho thấy rõ cách một plugin đóng góp kết quả vào global search.
Nó thực hiện ba việc chính:
  • tìm sản phẩm theo keyword prefix
  • ánh xạ ProductModel sang SearchResultModel
  • trả về danh sách kết quả thống nhất cho controller
Mỗi kết quả gồm các thông tin như:
  • title
  • resultType
  • content
  • url
Nhờ model chuẩn này, frontend không cần biết kết quả đến từ domain nào. Nó chỉ cần render theo một cấu trúc chung.

SearchResultModel là điểm chuẩn hóa của toàn hệ thống

Mặc dù đoạn code không đưa đầy đủ SearchResultModel, nhưng nhìn từ builder có thể suy ra nó là model chuẩn của global search.
Ví dụ trong trường hợp sản phẩm:
  • title: mã sản phẩm
  • content: tên sản phẩm
  • resultType: loại kết quả là product
  • url: đường dẫn đến trang admin chi tiết
Chính việc chuẩn hóa về một model duy nhất giúp hệ thống đạt được hai mục tiêu:
  • backend dễ gộp dữ liệu từ nhiều nguồn
  • frontend dễ hiển thị kết quả hỗn hợp trong cùng một dropdown hoặc search page
Đây là một kiểu thiết kế rất thực dụng và hiệu quả.

Các design pattern đang được sử dụng

Kiến trúc của admin global search không chỉ đơn giản là gọi nhiều service. Bên dưới nó đang áp dụng khá nhiều pattern quen thuộc trong thiết kế hệ thống plugin.

Strategy Pattern

AdminSearchService chính là một họ strategy cho hành vi tìm kiếm.
Mỗi implementation là một chiến lược tìm kiếm khác nhau:
  • search sản phẩm
  • search bài viết
  • search user
  • search order
Controller không cần biết chi tiết từng chiến lược, nó chỉ cần gọi chung một contract:
search(...)
Đây là biểu hiện rất rõ của Strategy Pattern.
classDiagram
    class AdminApiSearchController
    class AdminSearchService {
        <<interface>>
        +search(keyword)
        +search(adminId, keyword)
        +search(adminId, roles, keyword)
        +priority()
    }
    class ProductSearchService
    class UserSearchService
    class OrderSearchService

    AdminApiSearchController --> AdminSearchService
    AdminSearchService <|.. ProductSearchService
    AdminSearchService <|.. UserSearchService
    AdminSearchService <|.. OrderSearchService

Plugin / Extension Point Pattern

Global search được thiết kế như một extension point.
Bất kỳ plugin nào cũng có thể mở rộng hệ thống bằng cách:
  • implement AdminSearchService
  • đăng ký thành bean/service
Sau đó controller sẽ tự phát hiện và dùng service đó.
Đây là pattern rất phổ biến trong các hệ thống modular và plugin-based.

Chain of Responsibility theo nghĩa mở rộng

Controller duyệt lần lượt qua từng AdminSearchService và để mỗi service có cơ hội xử lý keyword. Dù kết quả không “pass tiếp” theo kiểu chặn luồng cổ điển, nhưng về bản chất vẫn có hơi hướng của Chain of Responsibility:
  • mỗi node trong chuỗi tự xử lý keyword theo domain của mình
  • kết quả được cộng dồn lại
  • lỗi ở một node không làm hỏng toàn bộ chuỗi
Kiểu chain này thiên về aggregation chain hơn là stop-on-first-match chain.

Lazy Initialization

Danh sách search service không được khởi tạo ngay trong constructor theo kiểu eager. Thay vào đó, nó được bọc bởi:
new EzyLazyInitializer<>(...)
Điều này cho thấy hệ thống trì hoãn việc load danh sách service cho đến khi thật sự cần dùng. Đây là một dạng Lazy Initialization.
Lợi ích:
  • giảm chi phí khởi tạo ban đầu
  • tránh load sớm khi feature chưa được dùng
  • phù hợp với hệ thống có nhiều plugin

Template Method theo kiểu interface default methods

AdminSearchService có các default method gọi chồng lên nhau:
search(adminId, roles, keyword)
-> search(adminId, keyword)
-> search(keyword)
Đây không phải Template Method theo hình thức class abstract cổ điển, nhưng về ý tưởng thì rất gần:
  • interface định ra luồng fallback mặc định
  • implementation chỉ cần override mức phù hợp nhất
Cách làm này giúp API mềm dẻo hơn cho developer của plugin.

Vì sao thiết kế này phù hợp với admin global search

Global search trong trang quản trị thường có một số yêu cầu đặc trưng:
  • dữ liệu đến từ nhiều domain khác nhau
  • mỗi domain có logic tìm kiếm riêng
  • phân quyền có thể ảnh hưởng đến kết quả
  • phải dễ mở rộng theo plugin
  • một plugin lỗi không được làm chết toàn bộ search
Kiến trúc hiện tại đáp ứng khá tốt các yêu cầu đó.
Controller rất mỏng, gần như chỉ làm orchestration. Toàn bộ hiểu biết về domain được đẩy xuống các AdminSearchService. Nhờ vậy:
  • code lõi gọn
  • plugin mới dễ thêm
  • rủi ro sửa một chỗ làm hỏng toàn hệ thống thấp hơn
  • frontend chỉ cần làm việc với một format kết quả thống nhất

Cơ chế chống ảnh hưởng dây chuyền khi một service lỗi

Một điểm kỹ thuật rất thực tế trong controller là:
try {
    result.addAll(searchService.search(...));
} catch (Exception e) {
    // do nothing
}
Nghĩa là nếu một plugin tìm kiếm gặp lỗi, hệ thống vẫn tiếp tục gọi các plugin khác.
Lợi ích là:
  • global search không bị sập toàn bộ chỉ vì một nguồn dữ liệu lỗi
  • trải nghiệm của admin vẫn được duy trì ở mức chấp nhận được
  • hệ sinh thái plugin trở nên bền hơn

Ý nghĩa của priority() trong trải nghiệm tìm kiếm

priority() là một điểm nhỏ nhưng có giá trị thiết kế cao.
Với hệ thống nhiều plugin, không phải kết quả nào cũng nên có vị trí ngang nhau. Ví dụ:
  • kết quả lõi của hệ thống có thể ưu tiên cao hơn
  • kết quả từ plugin phụ có thể xếp sau
  • search service nhẹ có thể chạy trước service nặng
Nhờ priority(), EzyPlatform có thể kiểm soát thứ tự đóng góp kết quả mà không làm controller phình to với quá nhiều if-else.
Đây là một giải pháp đơn giản nhưng rất “đúng chất framework”.

Minh họa quan hệ giữa controller và các plugin search

flowchart LR
    A[AdminApiSearchController] --> B[Product Search Service]
    A --> C[User Search Service]
    A --> D[Order Search Service]
    A --> E[Other Plugin Search Service]

    B --> F[SearchResultModel]
    C --> F
    D --> F
    E --> F

    F --> G[Combined Search Response]
Sơ đồ này cho thấy controller hoạt động như một bộ điều phối trung tâm. Các plugin không cần biết nhau tồn tại, chỉ cần cùng phát ra một định dạng kết quả chung.

Ví dụ tối giản để thêm một nguồn tìm kiếm mới

Nếu một plugin muốn bổ sung kết quả tìm kiếm cho “coupon”, về cơ bản chỉ cần làm như sau:
@Service
@AllArgsConstructor
public class AdminCouponSearchService implements AdminSearchService {

    private final AdminCouponService couponService;

    @Override
    public List<SearchResultModel> search(
        long adminId,
        AdminRolesProxy rolesProxy,
        String keyword
    ) {
        if (keyword == null || keyword.isEmpty()) {
            return Collections.emptyList();
        }

        return couponService.findByKeyword(keyword).stream()
            .map(it -> SearchResultModel.builder()
                .title(it.getCode())
                .resultType("coupon")
                .content(it.getName())
                .url("/coupons/" + it.getId())
                .build())
            .toList();
    }

    @Override
    public int priority() {
        return 100;
    }
}
Không cần sửa AdminApiSearchController. Đây chính là ưu điểm lớn nhất của kiến trúc hiện tại.

Kết luận

Nguyên lý hoạt động của EzyPlatform admin global search xoay quanh một tư tưởng rất rõ: controller trung tâm chỉ điều phối, còn từng plugin tự chịu trách nhiệm tìm kiếm dữ liệu của mình.
Kiến trúc này tạo ra một global search có các đặc điểm nổi bật:
  • mở rộng dễ qua plugin
  • ít phụ thuộc giữa các module
  • có hỗ trợ ngữ cảnh phân quyền
  • kết quả được chuẩn hóa qua SearchResultModel
  • bền hơn khi một nguồn tìm kiếm gặp lỗi
Về mặt design pattern, đây là một tổ hợp rất đẹp giữa:
  • Strategy Pattern
  • Plugin / Extension Point Pattern
  • Lazy Initialization
  • Template Method theo kiểu default interface methods
  • và một biến thể thực dụng của Chain of Responsibility
Đây là một thiết kế gọn, thực dụng và rất phù hợp với một nền tảng admin có kiến trúc module như EzyPlatform.