07 — EmployeeServiceImpl: one spec, two paths
Read this first: this chapter reads EmployeeServiceImpl, the class behind the employees grid,
and the one decision that organizes it: a single filter definition feeding two different query
strategies. Lesson 06 traced GET /api/employees down to the point
where the controller picks a service method; this is what each of those methods does on the other
side. Code is quoted inline so you can read this without the repository open.
Time: about 35 minutes. Assumes lesson 06.
The class frame
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class EmployeeServiceImpl implements EmployeeService {
@Service registers the bean, @RequiredArgsConstructor generates the constructor for the four
final repository and mapper fields — constructor injection without writing a constructor. The
third annotation is the repo-wide transaction convention: reads are the default, writes are the
exception you must declare. The class opens every method in a read-only transaction, and each
mutating method — create, update, archive, restore — overrides it with plain
@Transactional. Forget the override and Hibernate refuses to flush, which fails fast instead of
silently writing outside a transaction. The backend README states the full
convention; here you just recognize the shape.
One more field breaks the pattern of tidy injected repositories:
@PersistenceContext
private EntityManager entityManager;
Most services in this repo never touch EntityManager — Spring Data repositories and
specifications cover everything they need. When you see it injected, something lower-level lives in
the class. Here, that something is the second query path.
The shared spec
Every filter the grid can send — thirty-plus of them — funnels through one private method:
/** Shared by the DTO and projection paths so filter logic is never written twice. */
private Specification<Employee> filterSpec(EmployeeListFilter filter) {
return Specification
.where(EmployeeSpecifications.archived(filter.archived()))
.and(EmployeeSpecifications.lastNameSearch(filter.lastNameSearch(), filter.lastNameSearchType()))
.and(EmployeeSpecifications.firstNameSearch(filter.firstNameSearch(), filter.firstNameSearchType()))
...
// Columns hidden by default in the grid, filterable once revealed. All are scalar
// attributes of the employee row, so they go through the generic helpers rather
// than growing a bespoke specification each.
.and(EmployeeSpecifications.textFieldSearch("middleName",
filter.middleNameSearch(), filter.middleNameSearchType()))
That javadoc is the thesis of this chapter. A Specification is a portable WHERE clause — a
function that, given a query root, produces predicates (lesson 08
opens the box). Because it is data about a query rather than a query, the same object can be
handed to two completely different execution engines. Both paths below start with
filterSpec(filter), so a filter bug fixed in one path is fixed in the other, and the two paths
cannot disagree about which rows match — the predicates are never written twice.
Path A: whole entities, one statement
When the request has no fields= parameter, the controller calls list:
@Override
public Page<EmployeeDto> list(Pageable pageable, EmployeeListFilter filter) {
Specification<Employee> spec = filterSpec(filter)
.and(EmployeeSpecifications.fetchPositionAndDepartment());
Page<Employee> page = employeeRepository.findAll(spec, pageable);
This is the classic Spring Data path: findAll executes the specification and hydrates full
Employee entities. The one addition is fetchPositionAndDepartment(), a specification whose only
job is a fetch join:
root.fetch(POSITION, JoinType.LEFT).fetch(DEPARTMENT, JoinType.LEFT);
A plain lazy association would load position and department on first access — two extra statements
per row, after the page query returns. The fetch join hydrates the whole entity graph in the same
SELECT, so the mapper can walk employee.getPosition().getDepartment() for free.
The N+1 you didn't write
Position and department are mapped associations, so a fetch join covers them. The username is not:
it lives on User, a separate table with no mapped association back to Employee. The rest of
list resolves it in bulk:
Set<Integer> empNums = page.stream().map(Employee::getEmployeeNumber).collect(Collectors.toSet());
Map<Integer, String> usernameByEmpId = userRepository.findAllByEmployeeIdIn(empNums)
.stream()
.collect(Collectors.toMap(User::getEmployeeId, User::getUsername, (a, b) -> a));
return page.map(emp -> employeeMapper.toDto(
emp,
usernameByEmpId.get(emp.getEmployeeNumber())));
}
Collect the page's employee numbers, fetch all matching users in one IN query, index them
into a map, then map each entity to its DTO with the username looked up in memory.
page.map(...) hands each row to employeeMapper.toDto — the MapStruct boundary,
lesson 09's subject.
Predict: delete the bulk resolve and instead look up each row's username inside the mapping
lambda — userRepository.findAllByEmployeeId(emp.getEmployeeNumber()) per row. How many queries
does a 25-row page now issue for its data? Write your answer down before reading on.
Here is the resolver: 1 + 25. One statement fetches the page of employees (with its fetch joins), then every row pays one more round trip for its username — twenty-six queries where two would do. That is the N+1 problem, and this method is shaped the way it is so you never write it.
Path B: the projection
When fields= is present — the enterprise grid sends it, derived from its visible columns — the
controller calls listProjected instead. Its javadoc says exactly what changes and why:
/**
* Column-projection query: the same {@link #filterSpec} predicates run against a raw Criteria
* Tuple query instead of loading whole entities, so hiding columns in the grid genuinely
* narrows the SELECT rather than only hiding data the server already paid to fetch. The
* position/department joins here are plain joins, not fetch joins, since nothing is being
* hydrated into an entity graph.
*/
This is where the EntityManager earns its place. The method builds a Criteria Tuple query by
hand — cb.createTupleQuery(), a Root<Employee>, and then the reuse that makes the whole design
work:
Specification<Employee> spec = filterSpec(filter);
...
jakarta.persistence.criteria.Predicate predicate = spec.toPredicate(root, query, cb);
if (predicate != null) query.where(predicate);
spec.toPredicate(root, query, cb) asks the same specification to produce its predicates against
this hand-built query. Path A let findAll call it; Path B calls it directly. Same filters, no
entities: the result rows are Tuples, flattened into Map<String, Object> per row, and the total
count comes from a second query (countMatching) that reuses the spec a third time.
Note what the javadoc rules out: the joins here are plain joins, not fetch joins. A fetch join exists to fill an entity graph; there is no entity graph here, only a SELECT list.
The scalar list
Which columns can be projected is a declared list, not reflection:
/**
* Every projectable column that is a scalar attribute of the employee row, so the projection
* can select them by name in a loop. positionName and departmentName are deliberately absent —
* they resolve through joins and are handled separately.
*/
private static final List<String> SCALAR_FIELDS = List.of(
FIELD_LAST_NAME, FIELD_FIRST_NAME, FIELD_STATUS, FIELD_DATE_HIRED, FIELD_BASIC_SALARY,
"middleName", "nationality", "birthday", "address", "phoneNumber",
...
The selection loop is one line per requested field — if (fields.contains(field)) selections.add(root.get(field).alias(field)); — and the comment tells you the two exceptions
before you go looking for them. positionName and departmentName are, as a later comment puts
it, "The only two grid columns that are not attributes of the employee row."
Join once, share twice
Those two join-backed columns get their joins minted exactly once, before anything uses them:
// Joined once up front and shared by both the SELECT list and the ORDER BY: every
// root.join() call mints a separate join, so resolving them at each use site would emit
// the same association two or three times in one statement.
Set<String> referenced = new HashSet<>(fields);
pageable.getSort().forEach(order -> referenced.add(order.getProperty()));
Join<Employee, Position> positionJoin =
referenced.contains(FIELD_POSITION_NAME) || referenced.contains(FIELD_DEPARTMENT_NAME)
? root.join(POSITION, JoinType.LEFT) : null;
The Criteria API trap the comment names is real: root.join() is not idempotent. Call it in the
SELECT builder and again in the ORDER BY builder and the generated SQL joins the same table twice.
So the method computes the set of referenced names first — requested fields plus sort properties,
because sorting by a column you have hidden must still join it — and creates each join at most
once, or not at all. If nothing references position or department, the variables stay null and
the SQL contains no join whatsoever.
The two columns you always get
The SELECT list never shrinks below two entries:
selections.add(root.get(EMPLOYEE_NUMBER).alias(EMPLOYEE_NUMBER));
// Exposed to the grid as "isArchived"; the backing column is the pre-existing is_deleted.
selections.add(root.get("isDeleted").alias("isArchived"));
The class javadoc explains both: "employeeNumber and isArchived are always selected regardless
of fields: the grid needs the former for row identity and the latter to decide whether a row's
action menu offers Archive or Restore, even when neither is a visible column." Row identity is how
the grid tells row 3 from row 4 across refreshes; the archived flag drives a menu, not a column.
And note the alias quirk the inline comment documents — the API vocabulary says isArchived, the
legacy database column is is_deleted, and the alias is the one line where the two meet.
Predict: a user hides every column in the grid except Last Name, and the grid sends
fields=lastName. What does the projection's SELECT list contain? Write it down before reading on.
Here is the resolver: lastName, employeeNumber, isArchived — and nothing else. No position
join, no department join, no other scalars. Hiding columns genuinely narrowed the query, which is
the promise the javadoc made in its first sentence.
The mirror in recruitment
JobRequisitionServiceImpl.listProjected is the same shape grown independently first: its javadoc
opens "Column-projection query: same {@link #filterSpec} predicates … reused against a raw
Criteria API query so filtering logic never has to be duplicated," and it always selects id and
isArchived for the same two reasons. The pattern generalizes; once you can read one, you can read
both. These two projections are also two of the four sanctioned hand-written mapping sites in a
backend that otherwise maps by MapStruct — a Tuple whose shape changes per request has nothing
for a compile-time mapper to hold onto. Lesson 09 draws that boundary.
Where this shows up in MotorPH
- EmployeeServiceImpl.java —
this chapter's subject:
list,filterSpec,listProjectedand its helpers. - JobRequisitionServiceImpl.java — the mirror projection in recruitment.
- EmployeeSpecifications.java —
where
fetchPositionAndDepartmentand every filter helper live; next chapter's subject. - Frontend 101, lesson 07 — the client that keeps the promise: "Hidden columns are never fetched." This chapter is the code behind that sentence.
- The employees walkthrough — the same request traced end-to-end, browser to SQL.
- The backend README — the transaction convention in full.
Recap
- One
filterSpec, two consumers. Path A hands it tofindAll; Path B callsspec.toPredicateon a hand-built Tuple query. The filters are written once, so the paths can never disagree about which rows match. - Path A hydrates entities with a fetch join for position and department, then resolves
usernames in one bulk
INquery — the N+1 you didn't write is 1 + 25 on a 25-row page. - Path B narrows the SELECT. Hiding a grid column removes it from the query, not just the screen; plain joins, minted at most once, replace fetch joins because nothing is hydrated.
employeeNumberandisArchivedalways ride along — row identity and the Archive/Restore menu need them even when no visible column does — andisArchivedis an alias over the legacyis_deletedcolumn.- The pattern generalizes:
JobRequisitionServiceImplmirrors it, and the two tuple-to-map conversions are sanctioned hand-written exceptions to the MapStruct rule.
Next: 08 — Specifications, escaped LIKEs, and the 409 that could have been a 500.