Skip to main content

How /employees Works — A Walkthrough for Java Devs

Audience: you know Spring/JPA well, but this is your first lap around this repo's React frontend, JWT auth, and the AG Grid server-side grid pattern. This doc traces one real feature — the Employees list page at http://localhost:5173/employees — from the moment it was specified in the migration plan down to the SQL that runs when you type into a filter box.

Every file path below is real and clickable from the repo root. Read this with the files open; it's meant to be a guided tour, not a replacement for the code.


1. The big picture

┌─────────────────────────┐ HTTPS / JSON ┌──────────────────────────┐
│ React 19 + TypeScript │ ─────────────────────────────▶ │ Spring Boot 4 (Java) │
│ Vite dev server :5173 │ ◀───────────────────────────── │ Tomcat :8080 │
│ │ Authorization: Bearer │ │
│ AG Grid (Community) │ │ Spring Security (JWT) │
│ React Query │ │ Spring Data JPA │
│ Redux (auth state only) │ │ PostgreSQL + Flyway │
└─────────────────────────┘ └──────────────────────────┘

If you've only ever built Spring MVC apps with server-rendered Thymeleaf pages, the mental shift is: the browser is a separate single-page app that talks to your Spring Boot app purely as a JSON API. There's no server-side rendering, no sessions, no HttpSession. Every page is a React component; every piece of data on screen got there via an axios HTTP call to a @RestController.


2. How this feature was specified

This repo doesn't use Jira tickets — it uses a single living spec file, docs/archive/PLAN.md, broken into numbered Phases, each a checklist of concrete deliverables with - [x] boxes. A phase isn't "done" until every box is checked and there's a one-line note describing how it was verified (curl output, tsc -b clean, manual smoke test against a real demo account).

The Employees feature was two phases — backend first, frontend second — because the plan deliberately finishes a vertical slice of API before touching any UI:

Phase 3 — Employee Management Module (Backend)

- [x] Entities: Employee, Position, Department
- [x] DTOs: EmployeeDto (+ nested PositionSummaryDto), EmployeeCreateRequest, ...
- [x] EmployeeMapper (MapStruct)
- [x] EmployeeRepository extends JpaRepository, JpaSpecificationExecutor
- [x] EmployeeSpecifications — search (name + employee number...), status,
departmentId, positionId filters, plus fetchPositionAndDepartment() fetch-join
guarded against count queries; null filters use Specification.unrestricted()
- [x] EmployeeService / LookupService — list (paged+filtered), getById, create, ...
- [x] EmployeeController + LookupController:
- [x] GET /api/employees (paged/sorted/filtered) — hr.employees.view
- [x] GET /api/employees/{id} — hr.employees.view
- [x] POST /api/employees — hr.employees.create
- [x] PUT /api/employees/{id} — hr.employees.edit
- [x] PATCH /api/employees/{id}/status — hr.employees.delete
- [x] Swagger UI shows all endpoints; verified via curl with hr_demo: list/get/create/
update/status, search by name and employee number, status/department/position
filters, validation errors (400), not-found (404), and emp_demo correctly
receives 403 on /api/employees

Phase 5 — Employee Management Module (Frontend)

- [x] src/api/employees.ts, src/api/lookups.ts, src/hooks/api/useEmployees.ts
- [x] src/pages/Employees.tsx — AG Grid Infinite Row Model, server-side datasource
(page/size/sort/search/status), columns incl. status badge + actions
- [x] Toolbar — debounced search, status filter, "Add Employee" button (gated on
hr.employees.create), CSV export
- [x] EmployeeFormDrawer.tsx — create/edit form (React Hook Form + Zod)
- [x] EmployeeDetailDrawer.tsx — read-only detail view
- [x] Mutations invalidate employees query to refresh grid
(verified: tsc -b clean, eslint clean, smoke-tested /api/positions and
/api/employees against the running backend as hr_demo...)

The pattern to notice, since you'll repeat it for any future module:

  1. Write the entities/DTOs/repository/service/controller for one resource — fully, including security annotations — before any frontend exists.
  2. Verify the API directly (curl or Swagger UI) using two demo accounts with different permission sets (hr_demo = HR Administrator, emp_demo = plain Employee) to prove both the "happy path" and the 403 path work.
  3. Only then build the frontend page against the now-frozen API contract.
  4. Each phase's "done" checkbox is backed by an actual verification step, not just "I wrote the code."

The grid filtering on this page later grew beyond what Phase 5 originally shipped (see the column-filter work in docs/archive/daily/, e.g. 2026-06-28.md) — same pattern: small dated note describing what changed and how it was tested, rather than editing PLAN.md retroactively.


3. Backend: from HTTP request to SQL

3.1 The entity

backend/src/main/java/com/motorph/payroll/model/Employee.java

Nothing exotic here if you know JPA — a plain @Entity mapped to the employee table, identity-generated PK (employeeNumber), and a lazy @ManyToOne to Position:

@Entity
@Table(name = "employee")
public class Employee {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "employee_number")
private Integer employeeNumber;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "position_id", nullable = false)
private Position position;

@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted = false;
// ...salary, contact, government-ID fields
}

isDeleted is a soft-delete flag — there's no DELETE FROM employee anywhere in this flow; "deactivating" an employee is a status update, and isDeleted is filtered out of every query (see notDeleted() below). This is a design decision worth internalizing before you go looking for cascade-delete logic that doesn't exist.

3.2 The repository — JpaSpecificationExecutor

EmployeeRepository.java

public interface EmployeeRepository
extends JpaRepository<Employee, Integer>, JpaSpecificationExecutor<Employee> {

@EntityGraph(attributePaths = {"position", "position.department"})
Optional<Employee> findWithPositionByEmployeeNumber(Integer employeeNumber);

boolean existsByPosition_PositionId(Integer positionId);
}

Two things to flag if you've only used derived query methods (findByLastName, etc.):

  • JpaSpecificationExecutor<Employee> adds findAll(Specification<Employee> spec, Pageable pageable) to the repo for free. This is what lets the grid's "10+ optional filters that can be combined in any order" requirement be satisfied without writing 10! query method permutations.
  • @EntityGraph is Spring Data's annotation-driven way of saying "JOIN FETCH these associations in this one query" — used here so viewing a single employee doesn't trigger lazy-loading exceptions or N+1 queries for position/department.

3.3 The Specifications — dynamic WHERE clauses

EmployeeSpecifications.java

This is the heart of "how Spring Data works behind the scenes" for this page. A Specification<T> is just a functional interface:

public interface Specification<T> {
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}

— i.e., a lambda that, given the JPA Criteria API's Root (the table being queried), builds one Predicate (one SQL condition fragment). Spring Data JPA translates whatever Predicate your lambda returns into an actual WHERE clause at query time, via Hibernate's Criteria → SQL translation — there's no string concatenation, no risk of SQL injection, and it's fully type-safe against the entity's field names.

Each filter the grid supports is its own tiny Specification factory method:

public static Specification<Employee> status(String status) {
if (!StringUtils.hasText(status)) return Specification.unrestricted();
return (root, query, cb) -> cb.equal(root.get("status"), status);
}

public static Specification<Employee> departmentId(Integer departmentId) {
if (departmentId == null) return Specification.unrestricted();
return (root, query, cb) ->
cb.equal(root.get("position").get("department").get("departmentId"), departmentId);
}

Specification.unrestricted() is a Spring Data JPA 4.x API — it returns a no-op specification ("WHERE 1=1", roughly) when a filter wasn't supplied. (Older Spring Data code used to pass null into .and(...) for this; that stopped being legal in 4.x, which is why you'll see unrestricted() everywhere here instead of null.)

Text filters (last name, first name, position, department) all funnel through one helper that maps AG Grid's filter vocabulary onto Criteria API predicates:

private static Predicate textPredicate(Expression<String> field, String lower, String type, CriteriaBuilder cb) {
if (BLANK.equals(type)) return cb.or(cb.isNull(field), cb.equal(field, ""));
if (NOT_BLANK.equals(type)) return cb.and(cb.isNotNull(field), cb.notEqual(field, ""));
if (!StringUtils.hasText(lower)) return cb.conjunction();
return switch (type == null ? "contains" : type) {
case "equals" -> cb.equal(field, lower);
case "startsWith" -> cb.like(field, lower + "%");
case "notContains" -> cb.notLike(field, "%" + lower + "%");
default -> cb.like(field, "%" + lower + "%"); // "contains"
};
}

This is the backend half of AG Grid's "Contains / Equals / Starts With / Blank / Not blank" filter menu — every option the column filter UI offers maps 1:1 to a case here.

Finally, all the individual specs get composed with .and(...) in the service layer (next section) — this is plain functional composition, the same idea as chaining Predicate.and() in the java.util.function package, just specialized for JPA queries.

One more subtlety worth knowing about: the fetch-join guard.

public static Specification<Employee> fetchPositionAndDepartment() {
return (root, query, cb) -> {
if (Long.class != query.getResultType()) {
root.fetch("position", JoinType.LEFT).fetch("department", JoinType.LEFT);
query.distinct(true);
}
return cb.conjunction();
};
}

When findAll(spec, pageable) runs, Spring Data actually issues two queries under the hood: one SELECT ... LIMIT/OFFSET for the page of rows, and one SELECT COUNT(*) (with query.getResultType() == Long.class) to compute totalElements for the Page<T> object. A fetch() join is only legal on the row-selecting query — applying it to the count query throws at runtime — so this spec checks the result type and skips the fetch-join when Hibernate is building the count query. This is the kind of detail that's invisible until you hit the exception once.

3.4 The service layer

EmployeeServiceImpl.java

@Override
public Page<EmployeeDto> list(Pageable pageable, EmployeeListFilter filter) {
Specification<Employee> spec = Specification
.where(EmployeeSpecifications.notDeleted())
.and(EmployeeSpecifications.lastNameSearch(filter.lastNameSearch(), filter.lastNameSearchType()))
.and(EmployeeSpecifications.firstNameSearch(filter.firstNameSearch(), filter.firstNameSearchType()))
.and(EmployeeSpecifications.status(filter.status()))
.and(EmployeeSpecifications.departmentId(filter.departmentId()))
// ...one .and() per filter...
.and(EmployeeSpecifications.fetchPositionAndDepartment());

Page<Employee> page = employeeRepository.findAll(spec, pageable);

// Employees don't carry a username column — it lives on the User entity.
// One extra IN-query, then merged into the DTOs in memory (avoids N+1).
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 -> {
EmployeeDto dto = employeeMapper.toDto(emp);
dto.setUsername(usernameByEmpId.get(emp.getEmployeeNumber()));
return dto;
});
}

The class is annotated @Transactional(readOnly = true) at class level (mutating methods like create/update override it with their own @Transactional) — standard Spring Data transaction boundary, one per service method, exactly like you'd expect.

Pageable is a parameter the controller builds for you (see next section) — page, size, and sort query params get bound into it automatically by Spring Web.

3.5 The mapper

EmployeeMapper.java is a MapStruct interface — at compile time, MapStruct generates a plain Java implementation class (EmployeeMapperImpl) that does field-by-field assignment. No reflection at runtime, unlike ModelMapper/Dozer:

@Mapper(componentModel = "spring")
public interface EmployeeMapper {
@Mapping(target = "position", source = "position")
EmployeeDto toDto(Employee employee);
}

3.6 The controller — REST + method security

EmployeeController.java

@GetMapping
@PreAuthorize("hasAuthority('" + PermissionConstants.HR_EMPLOYEES_VIEW + "')")
public ResponseEntity<PageResponseDto<EmployeeDto>> list(
Pageable pageable,
@RequestParam(required = false) String lastNameSearch,
@RequestParam(required = false) String lastNameSearchType,
// ...14 more optional @RequestParams, one per filter...
) {
return ResponseEntity.ok(PageResponseDto.of(
employeeService.list(pageable, new EmployeeListFilter(/* ... */))));
}

Every endpoint on this controller carries its own @PreAuthorize — there's no class-level blanket rule, so each verb has its own permission string:

EndpointPermission required
GET /api/employeeshr.employees.view
GET /api/employees/{id}hr.employees.view
POST /api/employeeshr.employees.create
PUT /api/employees/{id}hr.employees.edit
PATCH /api/employees/{id}/statushr.employees.delete
GET /api/employees/me, PATCH /api/employees/meemployee.profile.view

PageResponseDto.of(Page<T>) is a small static factory that flattens Spring Data's Page<T> into a plain JSON-friendly shape (content, totalElements, totalPages, currentPage, pageSize) — see PageResponseDto.java. This is the exact shape the frontend's AG Grid datasource expects (more on that below).


4. Permissions — how hr.employees.view actually gets checked

Full reference: docs/security/authentication.md and docs/security/request-pipeline.md. Summary for this page:

4.1 Data model

role ──────────────────── parent_role_id (self-FK; child inherits parent's permissions)

└── role_permission ── permission (64 total, "category.subcategory.action")

users ─── role_id (primary role)

└── user_role (junction table) ── role_id (additional roles a user can hold)

Four seeded roles, each a superset of the one before it:

RoleInherits fromAdds
Employee11 self-service permissions
HR AdministratorEmployee+14 HR permissions, incl. hr.employees.view/create/edit/delete
Payroll AdministratorEmployee+17 payroll permissions
System AdministratorEmployeeall 64

hr_demo logs in as an HR Administrator and therefore holds hr.employees.view → sees the page. emp_demo only holds the Employee role's 11 permissions → gets a clean 403 from the same endpoint, which is exactly what Phase 3's verification step checked for.

4.2 Login → JWT issuance

AuthController (public, no token needed) → UserDetailsServiceImpl loads the user, walks the role hierarchy, and flattens it into a Set<GrantedAuthority> → password verified by DaoAuthenticationProvider (Argon2id, with legacy BCrypt hashes transparently upgraded on login) → JwtTokenManager signs a JWT (HMAC256, 15-minute expiry, paired with a rotating 14-day refresh token — see ../security/authentication.md) embedding username, userId, employeeId, roles[]. The response body returns the token and the user's full flattened permissions[] array — the frontend never has to compute role inheritance itself.

4.3 Every subsequent request

SecurityConfiguration.java sets up a stateless filter chain (SessionCreationPolicy.STATELESS — no HttpSession, ever) and registers a custom filter before Spring's default username/password filter:

.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(PUBLIC_ENDPOINTS).permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)

JwtAuthenticationFilter runs on every request: pulls Authorization: Bearer <token>, validates the signature/issuer/expiry, reloads the user from the DB (so a deactivated account is rejected even with a still-valid token), and populates SecurityContextHolder with a UsernamePasswordAuthenticationToken carrying the authorities. If the header is missing/invalid, the filter does nothing — the security context stays empty, and Spring later returns 401.

@EnableMethodSecurity on SecurityConfiguration is what makes @PreAuthorize on EmployeeController actually get evaluated — Spring wraps the controller bean in a proxy that checks hasAuthority(...) against the SecurityContext's authorities before the method body runs. No match → Spring throws AccessDeniedException → translated to 403.

4.4 Frontend mirrors the same permission strings

frontend/src/constants/permissions.ts is a hand-kept TypeScript mirror of the backend's PermissionConstants.java — same string values ('hr.employees.view', etc.) on both sides. This is not generated from the backend automatically; if a new permission is added in Java, it has to be added by hand in TS too, or the frontend route guard will silently never match it.

There are two enforcement points on the frontend, and neither replaces the backend check — they exist purely for UX (don't show a button the user can't use; the real gate is always the @PreAuthorize on the server):

// App.tsx — route-level: redirect away from /employees entirely if not permitted
<Route element={<RequirePermission permission={PermissionConstants.HR_EMPLOYEES_VIEW} />}>
<Route path={ROUTES.EMPLOYEES} element={page(Employees)} />
</Route>
// Employees.tsx — element-level: hide the "Add Employee" button
const canCreate = hasPermission(PermissionConstants.HR_EMPLOYEES_CREATE);
{canCreate && <Button onClick={...}>Add Employee</Button>}

hasPermission (from useAuth.ts) just does a Set.has() check against the permissions[] array that came back in the login response — no network call, purely client-side, purely cosmetic.


5. Frontend: from a page load to a JSON request

5.1 Routing — does this user even get to see the page?

App.tsx wraps every authenticated route in <ProtectedRoute> (redirects to /login if no JWT) and the /employees route specifically in <RequirePermission permission="hr.employees.view"> (require-permission.tsx):

export const RequirePermission = ({ permission }) => {
const { hasPermission } = useAuth();
const permissions = Array.isArray(permission) ? permission : [permission];
if (!permissions.some(hasPermission)) return <Navigate to={ROUTES.DASHBOARD} replace />;
return <Outlet />;
};

Think of this as the React equivalent of a Spring Security @PreAuthorize — except it only controls whether the component renders, not whether data is fetchable. The real authorization still happens server-side on every API call.

5.2 Axios client — the JWT gets attached automatically

api/client.ts is a single shared axios instance every API call goes through. A request interceptor reads the token out of the Redux store and attaches it to every outgoing call — equivalent to a Java HttpClient's request filter / ClientHttpRequestInterceptor:

apiClient.interceptors.request.use(config => {
const { token } = store.getState().auth;
if (token) config.headers.set('Authorization', `Bearer ${token}`);
return config;
});

apiClient.interceptors.response.use(
response => response,
async error => {
if (axios.isAxiosError(error) && error.response?.status === 401 && !config._retried) {
config._retried = true;
await refreshAccessToken(); // single shared in-flight refresh (rotating token)
return apiClient(config); // retry the original request once
}
// refresh failed or already retried → logout, boot to /login
store.dispatch(logout());
return Promise.reject(...);
}
);

(Simplified — the real interceptor in client.ts also skips the auth endpoints themselves and serializes concurrent refreshes through one shared promise; see ../frontend/api-layer.md.)

You write the call site (employees.ts, below) without ever touching headers — same pattern as a Spring RestTemplate/WebClient interceptor doing auth for you.

5.3 The typed API layer

api/employees.ts — this is the TypeScript equivalent of a thin REST client/Feign interface. One function per backend endpoint, typed request/response shapes:

export const listEmployees = async (params: ListEmployeesParams): Promise<PageResponse<EmployeeDto>> => {
const { data } = await apiClient.get<PageResponse<EmployeeDto>>('/api/employees', { params });
return data;
};

ListEmployeesParams (TS interface) lines up field-for-field with the controller's @RequestParam list, and EmployeeDto/PageResponse<T> (in api/types.ts) mirror the backend's EmployeeDto/PageResponseDto<T> JSON shape by hand — there's no codegen from the OpenAPI spec in this repo, so keeping these two in sync across a backend change is a manual step.

5.4 React Query — caching and mutation invalidation

hooks/api/useEmployees.ts wraps the raw API functions in TanStack Query hooks:

export const useCreateEmployee = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: EmployeeRequest) => createEmployee(payload),
onSuccess: () => { void queryClient.invalidateQueries({ queryKey: EMPLOYEES_QUERY_KEY }); },
});
};

React Query here plays a role similar to a second-level cache + write-through invalidation: after a successful POST, any component subscribed to the 'employees' query key is told to refetch. (The list grid itself doesn't use useQuery for its rows — see below — but the form drawers and detail views do.)

5.5 AG Grid's infinite row model — the actual mechanism behind "server-side paging/filtering/sorting"

This is the piece with no direct Spring analogue, so it's worth slowing down on. ServerDataGrid.tsx wraps AG Grid configured with rowModelType="infinite". Instead of holding all rows in memory, AG Grid asks you for each "block" of rows as the user scrolls/sorts/filters, via a datasource.getRows(params) callback you provide:

const datasource: IDatasource = {
getRows: params => {
const size = params.endRow - params.startRow;
const page = Math.floor(params.startRow / size);
const sortModel = params.sortModel.filter(m => sortableFieldsRef.current.has(m.colId));

const apiParams = buildParamsRef.current(params.filterModel, sortModel, page, size);

fetchPageRef.current(apiParams).then(res => {
params.successCallback(res.content, res.totalElements); // hand rows back to AG Grid
}).catch(() => { params.failCallback(); });
},
};

buildParams and fetchPage are injected as props — for the Employees page, fetchPage is listEmployees from api/employees.ts, and buildParams lives in pages/hr/Employees.tsx:

function makeBuildParams(fixedStatus?: EmployeeStatus) {
return (fm, sortModel, page, size): ListEmployeesParams => {
const lastNameF = extractTextFilter(fm.lastName); // AG Grid's filter model →
const eidR = extractNumberRange(fm.employeeNumber); // backend's flat query-param shape
return {
page, size,
sort: sortModel.map(m => `${m.colId},${m.sort}`),
lastNameSearch: lastNameF?.value, lastNameSearchType: lastNameF?.type,
employeeIdFilterType: eidR.filterType, employeeIdMin: eidR.min, employeeIdMax: eidR.max,
status: fixedStatus ?? statusF?.value?.toUpperCase(),
// ...
};
};
}

So the full round trip when you type into the "Last Name" column filter is:

  1. AG Grid's built-in filter UI fires onFilterChanged.
  2. AG Grid calls datasource.getRows() with its internal filterModel (AG Grid's own shape: { lastName: { filterType: 'text', type: 'contains', filter: 'cruz' } }).
  3. extractTextFilter() (filterUtils.ts) normalizes that into { value: 'cruz', type: 'contains' }.
  4. buildParams turns it into lastNameSearch=cruz&lastNameSearchType=contains.
  5. listEmployees() fires GET /api/employees?lastNameSearch=cruz&lastNameSearchType=contains&page=0&size=20.
  6. The Spring controller binds those to @RequestParams, builds an EmployeeListFilter, passes it to EmployeeServiceImpl.list(), which builds the Specification chain covered in §3.3–3.4 and runs it against Postgres.
  7. The JSON response (PageResponseDto) comes back; params.successCallback(res.content, res.totalElements) hands the rows to AG Grid, which renders them and updates the row count for its virtual scrollbar.

cacheBlockSize (defaults to the page size, 20) controls how many rows AG Grid asks for per getRows() call — i.e., it is the page size sent to Spring's Pageable.

5.6 Tying it together — Employees.tsx

pages/hr/Employees.tsx is the actual page component. It doesn't manage rows itself — it configures <ServerDataGrid> (via a <TabbedServerGrid> wrapper, since this page has All/Regular/Probationary/Inactive tabs, each its own grid instance with a fixedStatus baked into buildParams) and wires up the toolbar buttons to permission checks and React Query mutations:

const canCreate = hasPermission(PermissionConstants.HR_EMPLOYEES_CREATE);
const updateStatusMutation = useUpdateEmployeeStatus();
// ...
{canCreate && <Button onClick={() => setFormOpen(true)}>Add Employee</Button>}
<TabbedServerGrid tabs={tabs} defaultTab="all" />
<EmployeeFormDrawer open={formOpen} onSuccess={refreshGrid} />

refreshGrid calls gridApi.purgeInfiniteCache() — tells AG Grid to throw away its cached blocks and call getRows() again, which is how the grid picks up a row you just created/edited via the drawer (this is the manual equivalent of React Query's invalidateQueries, just AG Grid's own API instead).


6. Request/response contract cheat sheet

Frontend sendsBackend expects / returns
AuthAuthorization: Bearer <jwt> header (axios interceptor)JwtAuthenticationFilter reads it
List query paramspage, size, sort=lastName,asc, lastNameSearch, lastNameSearchType, status, departmentId, ... (flat query string, built by buildParams)Bound to Pageable + 16 @RequestParams on EmployeeController#list
List responsePageResponse<EmployeeDto> (TS, in api/types.ts)PageResponseDto<EmployeeDto> (Java) — content, totalElements, totalPages, currentPage, pageSize
Single employeeEmployeeDto — flat fields + nested position: PositionSummaryDto
Create/updateEmployeeRequest JSON body (React Hook Form + Zod-validated client-side)EmployeeCreateRequest/EmployeeUpdateRequest (Jakarta @Valid-validated server-side — the server validation is the one that actually matters; client-side is UX only)

7. Sequence diagram

End-to-end flow: logging in, then loading the Employees grid with a name filter applied.

If the user instead were emp_demo (no hr.employees.view), the @PreAuthorize check in the CTRL step fails, Spring throws AccessDeniedException, and the response is 403 Forbidden with no further DB queries — and on the frontend, the <RequirePermission> guard would already have redirected them to the dashboard before any request was even made.


8. Glossary — Spring concept → frontend equivalent

Java/Spring worldThis repo's frontend equivalent
@RestController / @RequestMappinga file in api/*.ts exporting one function per endpoint
DTO classTypeScript interface in api/types.ts, kept manually in sync
@PreAuthorize("hasAuthority(...)")<RequirePermission permission="..."> (UX-only — real gate is still the backend)
SecurityContextHolderRedux state.auth (token, user, activeRole)
HttpSessionnone — stateless JWT, re-sent on every request via axios interceptor
Bean validation (@Valid, Jakarta)React Hook Form + Zod schema (client-side UX; server is authoritative)
Specification<T> compositionbuildParams() translating AG Grid's filter model into query params
Hibernate first-level cache / @TransactionalReact Query's cache + invalidateQueries
MapStruct mappernone direct — DTO shapes are just asserted via TS types, no runtime mapping needed (JSON parses straight into the shape)
application.yml / @Value.env + import.meta.env.VITE_* (Vite env vars)

9. Where to go next

  • Build one yourself: build-a-module-page.md is the do-it-yourself companion to this walkthrough — the full recipe for shipping a new module page (migration → backend → permissions → enterprise grid with drawers, custom actions, and filters → tests) without supervision.
  • Trace the same pattern for a module you haven't touched yet — Department/Position (Phase 7 in PLAN.md) is the smallest possible version of this whole flow.
  • Read docs/security/authentication.md in full for the parts of the permission system this doc only summarized (role switching, the four-role inheritance tree in detail).
  • Read docs/security/request-pipeline.md for security concerns beyond authorization (CORS config, password hashing, rate limiting if present).
  • Open EmployeeSpecifications.java side-by-side with filterUtils.ts and match every AG Grid filter type (contains, equals, blank, ...) to its Criteria API case — that mapping is the single most reusable piece of knowledge for adding a filter to any other grid in this app.