Lookups backed by query services
@Lookup fields let users search and select a related entity. The options and labels come from supplier beans — query services, remote APIs, or in-memory lists — without coupling the ViewModel to any particular data source.
Prerequisite: lookups are part of the read side. The same query-service pattern used for listings and UI rows applies here.
The pattern
Section titled “The pattern”@Lookup( search = ProductOptionsSupplier.class, label = ProductLabelSupplier.class)String productId;search— a class that returns a list ofOptionobjects given a search stringlabel— a class that resolves a stored id to a display label
Options supplier (search)
Section titled “Options supplier (search)”@Servicepublic class ProductOptionsSupplier implements LookupOptionsSupplier {
private final ProductQueryService productQueryService;
public ProductOptionsSupplier(ProductQueryService productQueryService) { this.productQueryService = productQueryService; }
@Override public List<Option> getOptions(String search, HttpRequest httpRequest) { return productQueryService.search(search, 20) .stream() .map(dto -> new Option(dto.id(), dto.name())) .toList(); }}Option(value, label) — the value is stored in the field; the label is what users see.
Label supplier (resolve stored id)
Section titled “Label supplier (resolve stored id)”@Servicepublic class ProductLabelSupplier implements LookupLabelSupplier {
private final ProductQueryService productQueryService;
public ProductLabelSupplier(ProductQueryService productQueryService) { this.productQueryService = productQueryService; }
@Override public String getLabel(String value, HttpRequest httpRequest) { return productQueryService.findById(value) .map(ProductDto::name) .orElse(value); }}This is called when an existing record is loaded to show the label of the stored id.
Remote lookup
Section titled “Remote lookup”The options supplier can call a remote service:
@Servicepublic class RemoteProductOptionsSupplier implements LookupOptionsSupplier {
private final ProductApiClient productApiClient;
@Override public List<Option> getOptions(String search, HttpRequest httpRequest) { String jwt = httpRequest.getHeaderValue("Authorization"); return productApiClient.search(search, jwt) .stream() .map(dto -> new Option(dto.id(), dto.name())) .toList(); }}The HttpRequest provides access to request headers (including the JWT) so the options supplier can forward authentication.
Bubble (nested lookup)
Section titled “Bubble (nested lookup)”For lookups inside nested list fields, add bubble = true to forward the parent’s context:
@Lookup( search = ComponentOptionsSupplier.class, label = ComponentLabelSupplier.class, bubble = true)String componentId;Why query services instead of repositories
Section titled “Why query services instead of repositories”| Approach | Problem |
|---|---|
| Inject JPA repository | ViewModel couples to persistence; cannot use non-JPA sources |
| Query service interface | Decoupled; implementation can be JPA, Elasticsearch, HTTP, in-memory |
The options and label suppliers are Spring beans, so they can inject any service. The ViewModel that uses @Lookup never sees the data source.
- Query services and UI rows — the same pattern applied to listing rows
- Mateu in hexagonal architecture — where supplier beans fit in the architecture
- Customizing CRUD and listings — how
@Lookupappears in CRUD editors