Hook: Interviewers love this question because it checks whether you can turn a vague wish like 'search employees' into a real API that is fast, safe, and easy to use.
Question: Design an Employee Search API.
Answer: I would design a read-only REST API with query parameters for search text, department, active status, date filters, pagination, and sorting. The API should return a small page of employee summaries plus metadata like total count and next-page info, so the client does not fetch too much data at once. In Spring Boot, I’d bind the request with @RequestParam, validate defaults and limits, and push the filtering logic into a service layer.
Interview-Ready Answer: I’d expose a GET /api/employees/search endpoint with filters like q, department, active, date range, plus page, size, and sortBy. I’d return paged results with metadata, keep the response as lightweight employee summaries, and enforce limits like a default size of 20 and max size of 100 to protect performance. If the dataset is small I can search in memory, but in production I’d back it with a database index or search engine depending on scale.
Detailed Explanation: The clean design is a read-only GET endpoint because search should be cache-friendly and simple for clients. Pagination means returning results in chunks, and sorting means ordering them by one safe field chosen from a whitelist, which is a fixed allow-list of approved values. The API should return employee summary fields only, not every database column, so the payload stays small.
/api/employees/search?q=ann&department=Engineering&page=0&size=20&sortBy=lastName.2024-01-01 is converted automatically with @DateTimeFormat, which is a rule that tells Spring how to parse the input.sortBy must be one of the allowed fields.q is usually a case-insensitive contains match across several fields.LIMIT/OFFSET or a Spring Data Pageable.hasNext flag.GET when the search is a normal read operation and the filter list is not too huge.POST only when the filter body becomes large or nested, such as many optional fields and rule groups.| Approach | Best for | Trade-off |
|---|---|---|
| In-memory list | Demo, small data | Simple but O(n) |
| JPA Specification | DB-backed filters | Good balance, more setup |
| Elasticsearch | Text-heavy search | Fast search, extra ops |
O(n), then sorts the matched rows in O(k log k), where k is the number of matches.O(k) extra space.LIKE '%ann%' search is hard to speed up with a normal index, so use full-text search when users need that style of query.Memory Hook: Think like a librarian: first find the shelf with filters, then sort the books, then hand out only one cartful of results.
Real-World Story: Imagine an HR portal at a large staffing company. Recruiters need to search 200,000 employees by name, department, active status, and hire date before they assign someone to a project. The search page is opened all day long, so even a small mistake becomes expensive fast.
One outage happened because the team forgot to cap page size. A client asked for all employees, the API returned a huge JSON payload, the browser froze, and the gateway started timing out. Another bug made the department filter case-sensitive, so engineering returned zero rows while Engineering worked, which looked like lost data to the users. The logs showed long request times, large response sizes, and a flood of support tickets saying 'search is broken'.
The lesson is simple: search APIs are not just about finding data. They are about protecting the system from expensive queries, keeping the response small, and making the rules obvious enough that a tired recruiter can use them correctly.
package com.example.employeesearch;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
@SpringBootApplication
public class EmployeeSearchApplication {
public static void main(String[] args) {
SpringApplication.run(EmployeeSearchApplication.class, args);
}
@Bean
CommandLineRunner seed(EmployeeService service) {
return args -> service.addAll(List.of(
new Employee(1L, "Ava", "Patel", "ava.patel@example.com", "Engineering", "Backend Engineer", "Austin", true, LocalDate.of(2022, 4, 18)),
new Employee(2L, "Noah", "Kim", "noah.kim@example.com", "Engineering", "Frontend Engineer", "Seattle", true, LocalDate.of(2021, 9, 2)),
new Employee(3L, "Mia", "Chen", "mia.chen@example.com", "HR", "Recruiter", "Dallas", false, LocalDate.of(2020, 2, 10)),
new Employee(4L, "Liam", "Singh", "liam.singh@example.com", "Finance", "Analyst", "Chicago", true, LocalDate.of(2023, 1, 5)),
new Employee(5L, "Emma", "Brown", "emma.brown@example.com", "Engineering", "QA Engineer", "Austin", false, LocalDate.of(2019, 7, 22)),
new Employee(6L, "Ethan", "Garcia", "ethan.garcia@example.com", "Support", "Support Lead", "Phoenix", true, LocalDate.of(2024, 3, 14))
));
}
}
record Employee(Long id,
String firstName,
String lastName,
String email,
String department,
String title,
String city,
boolean active,
LocalDate hiredDate) {
}
record EmployeeSearchResponse(List<Employee> items,
int page,
int size,
long totalElements,
int totalPages,
boolean hasNext) {
}
record ApiError(String error, String message) {
}
@Service
class EmployeeService {
private final List<Employee> employees = new ArrayList<>();
public synchronized void addAll(Collection<Employee> data) {
employees.addAll(data);
}
public EmployeeSearchResponse search(String q,
String department,
Boolean active,
LocalDate hiredAfter,
int page,
int size,
String sortBy,
String sortDir) {
// The API must defend itself from giant pages and negative indexes.
if (page < 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "page must be >= 0");
}
if (size < 1 || size > 100) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "size must be between 1 and 100");
}
String normalizedQuery = q == null ? null : q.trim().toLowerCase(Locale.ROOT);
String normalizedDepartment = department == null ? null : department.trim().toLowerCase(Locale.ROOT);
Comparator<Employee> comparator = comparatorFor(sortBy);
if ("desc".equalsIgnoreCase(sortDir)) {
comparator = comparator.reversed();
} else if (!"asc".equalsIgnoreCase(sortDir)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sortDir must be asc or desc");
}
List<Employee> filtered = employees.stream()
.filter(employee -> matches(employee, normalizedQuery, normalizedDepartment, active, hiredAfter))
.sorted(comparator.thenComparing(Employee::id))
.toList();
long totalElements = filtered.size();
int totalPages = (int) Math.ceil(totalElements / (double) size);
int fromIndex = Math.min(page * size, filtered.size());
int toIndex = Math.min(fromIndex + size, filtered.size());
// Copy the page so callers cannot accidentally mutate the internal list view.
List<Employee> items = List.copyOf(filtered.subList(fromIndex, toIndex));
boolean hasNext = toIndex < filtered.size();
return new EmployeeSearchResponse(items, page, size, totalElements, totalPages, hasNext);
}
private boolean matches(Employee employee,
String query,
String department,
Boolean active,
LocalDate hiredAfter) {
if (active != null && employee.active() != active) {
return false;
}
if (department != null && !employee.department().equalsIgnoreCase(department)) {
return false;
}
if (hiredAfter != null && !employee.hiredDate().isAfter(hiredAfter)) {
return false;
}
if (query == null || query.isBlank()) {
return true;
}
String haystack = (employee.firstName() + " " + employee.lastName() + " " + employee.email() + " " + employee.department() + " " + employee.title() + " " + employee.city())
.toLowerCase(Locale.ROOT);
return haystack.contains(query);
}
private Comparator<Employee> comparatorFor(String sortBy) {
String key = sortBy == null ? "lastname" : sortBy.trim().toLowerCase(Locale.ROOT);
// Whitelisting sort fields prevents bad input and keeps the API predictable.
return switch (key) {
case "firstname" -> Comparator.comparing(Employee::firstName, String.CASE_INSENSITIVE_ORDER);
case "lastname" -> Comparator.comparing(Employee::lastName, String.CASE_INSENSITIVE_ORDER);
case "department" -> Comparator.comparing(Employee::department, String.CASE_INSENSITIVE_ORDER);
case "title" -> Comparator.comparing(Employee::title, String.CASE_INSENSITIVE_ORDER);
case "city" -> Comparator.comparing(Employee::city, String.CASE_INSENSITIVE_ORDER);
case "hireddate" -> Comparator.comparing(Employee::hiredDate);
case "id" -> Comparator.comparing(Employee::id);
default -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported sortBy: " + sortBy);
};
}
}
@RestController
@RequestMapping("/api/employees")
class EmployeeController {
private final EmployeeService service;
EmployeeController(EmployeeService service) {
this.service = service;
}
@GetMapping("/search")
public EmployeeSearchResponse search(
@RequestParam(required = false) String q,
@RequestParam(required = false) String department,
@RequestParam(required = false) Boolean active,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate hiredAfter,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "lastName") String sortBy,
@RequestParam(defaultValue = "asc") String sortDir) {
return service.search(q, department, active, hiredAfter, page, size, sortBy, sortDir);
}
}
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ApiError> handle(ResponseStatusException ex) {
String message = ex.getReason() == null ? "Bad request" : ex.getReason();
return ResponseEntity.status(ex.getStatusCode())
.body(new ApiError(ex.getStatusCode().toString(), message));
}
}Follow-up & Tricky Questions:
GET instead of POST? Use GET because search is read-only, cacheable, and easy to bookmark. Switch to POST only if the filter payload becomes too large or deeply nested for query parameters.Page with indexes on common filters such as department, active, and hire date.0 means the first page. If your product team wants 1-based pages, convert them at the controller boundary and keep the service consistent.hiredAfter include that exact date? In the sample code it does not; it returns employees hired strictly after the given date. If the business wants inclusive behavior, change the comparison to !isBefore(date) or rename the filter to hiredOnOrAfter.Common Mistakes:
Memory Hook: Filter, sort, page, return. Picture a librarian: first narrow the shelf, then order the books, then hand out just one cart.
Cheat Sheet:
GET /api/employees/search for normal search.q, structured filters, pagination, and sort fields.sortBy values and validate bad input.hasNext.Practice Tasks:
role filter and make it case-insensitive.hiredFrom and hiredTo, then test the empty-result case.