Skip to content
CalliCoder

Spring Boot, Spring Security and JWT with React — Part 3

Spring Boot 14 min read Part of Spring Boot JWT Authentication with React

The polling API: JPA auditing for created/updated columns, why the vote count belongs in a query rather than a counter column, keyset-safe pagination, and the unique constraint that stops a double vote.

Part 2 finished authentication: a user can sign up, sign in and receive a JWT. This part is the application the authentication protects. A polling API where the interesting problems are counting votes without a race and paginating without duplicates.

Written against Spring Boot 3.2, Hibernate 6.4 and Java 17.

Auditing: created and updated, once

Every entity needs created_at and updated_at, and writing them by hand in each service is how they drift. JPA auditing populates them:

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@JsonIgnoreProperties(value = {"createdAt", "updatedAt"}, allowGetters = true)
public abstract class DateAudit implements Serializable {

    @CreatedDate
    @Column(nullable = false, updatable = false)
    private Instant createdAt;

    @LastModifiedDate
    @Column(nullable = false)
    private Instant updatedAt;
}
@MappedSuperclass
public abstract class UserDateAudit extends DateAudit {

    @CreatedBy
    @Column(updatable = false)
    private Long createdBy;

    @LastModifiedBy
    private Long updatedBy;
}
@Configuration
@EnableJpaAuditing
public class AuditingConfig {

    @Bean
    public AuditorAware<Long> auditorProvider() {
        return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
                .filter(Authentication::isAuthenticated)
                .map(Authentication::getPrincipal)
                .filter(UserPrincipal.class::isInstance)
                .map(p -> ((UserPrincipal) p).getId());
    }
}

@EnableJpaAuditing is required: without it the annotations are inert and the columns are null on insert, which fails the nullable = false constraint with a message about the column rather than about the missing configuration.

The AuditorAware returning Optional.empty() for an unauthenticated context is deliberate: a migration or a scheduled job has no user, and throwing there would break both.

allowGetters = true lets the timestamps be serialised out while being ignored on the way in, so a client cannot set them.

The domain

@Entity
@Table(name = "polls")
public class Poll extends UserDateAudit {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 140)
    @NotBlank @Size(max = 140)
    private String question;

    @OneToMany(mappedBy = "poll", cascade = CascadeType.ALL, fetch = FetchType.EAGER,
               orphanRemoval = true)
    @Size(min = 2, max = 6)
    @Fetch(FetchMode.SELECT)
    private List<Choice> choices = new ArrayList<>();

    @NotNull
    private Instant expirationDateTime;

    public void addChoice(Choice choice) {
        choices.add(choice);
        choice.setPoll(this);
    }

    public boolean isExpired() {
        return expirationDateTime.isBefore(Instant.now());
    }
}

EAGER on a collection is normally the wrong default, and here it is a deliberate exception: a poll is never useful without its choices, and there are at most six. FetchMode.SELECT issues a second query rather than a join, which avoids multiplying the poll row by its choices, the alternative is @BatchSize on the collection, which is better once several polls are loaded at once.

@Entity
@Table(name = "votes",
       uniqueConstraints = @UniqueConstraint(columnNames = { "poll_id", "user_id" }))
public class Vote extends DateAudit {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Poll poll;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Choice choice;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private User user;
}

The unique constraint on (poll_id, user_id) is the only thing that reliably prevents a double vote. A findByPollIdAndUserId check before inserting is a check-then-act race: two concurrent requests both find nothing and both insert. Catch the violation instead:

try {
    vote = voteRepository.save(vote);
} catch (DataIntegrityViolationException e) {
    throw new BadRequestException("You have already cast your vote in this poll");
}

Let the database decide, and translate the exception. That is the general shape for any uniqueness rule under concurrency.

Counting votes in a query, not a column

The reflex is a voteCount column on Choice, incremented on each vote. That is a lost-update race and a second source of truth that can disagree with the votes table.

Count instead:

public interface VoteRepository extends JpaRepository<Vote, Long> {

    @Query("SELECT NEW com.example.polls.model.ChoiceVoteCount(v.choice.id, count(v.id)) " +
           "FROM Vote v WHERE v.poll.id IN :pollIds GROUP BY v.choice.id")
    List<ChoiceVoteCount> countByPollIdIn(@Param("pollIds") List<Long> pollIds);

    @Query("SELECT v FROM Vote v WHERE v.user.id = :userId AND v.poll.id IN :pollIds")
    List<Vote> findByUserIdAndPollIdIn(@Param("userId") Long userId,
                                       @Param("pollIds") List<Long> pollIds);
}

Both take a list of poll ids, which is what keeps the list endpoint at a fixed number of queries regardless of page size. Per-poll versions of the same two queries turn a 30-item page into 61 queries.

The NEW com.example... constructor expression projects straight into a small carrier type rather than returning Object[], which removes a cast and a lot of index arithmetic.

If counting ever becomes too slow, a materialised counter updated by a database trigger or an atomic UPDATE ... SET count = count + 1 is the escalation, not a field the application increments after reading.

Pagination that does not duplicate rows

@GetMapping
public PagedResponse<PollResponse> getPolls(
        @CurrentUser UserPrincipal currentUser,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "30") int size) {

    validatePageNumberAndSize(page, size);

    Pageable pageable = PageRequest.of(page, size, Sort.Direction.DESC, "createdAt", "id");
    Page<Poll> polls = pollRepository.findAll(pageable);
    ...
}

Sorting by createdAt alone is not deterministic: two polls created in the same millisecond can order differently between the query for page 1 and the query for page 2, so a row appears twice or never. Adding id as a tie-breaker makes the order total.

Cap the page size:

private void validatePageNumberAndSize(int page, int size) {
    if (page < 0) throw new BadRequestException("Page number cannot be less than zero.");
    if (size > 50) throw new BadRequestException("Page size must not be greater than 50.");
}

Without the cap, ?size=1000000 is a denial-of-service endpoint that requires no special access.

The service layer

public PagedResponse<PollResponse> getAllPolls(UserPrincipal currentUser, int page, int size) {
    validatePageNumberAndSize(page, size);

    Page<Poll> polls = pollRepository.findAll(
            PageRequest.of(page, size, Sort.Direction.DESC, "createdAt", "id"));

    if (polls.getNumberOfElements() == 0) {
        return new PagedResponse<>(List.of(), polls.getNumber(), polls.getSize(),
                polls.getTotalElements(), polls.getTotalPages(), polls.isLast());
    }

    List<Long> pollIds = polls.map(Poll::getId).getContent();

    Map<Long, Long> choiceVotes = voteRepository.countByPollIdIn(pollIds).stream()
            .collect(Collectors.toMap(ChoiceVoteCount::getChoiceId, ChoiceVoteCount::getVoteCount));

    Map<Long, Long> userVotes = voteRepository
            .findByUserIdAndPollIdIn(currentUser.getId(), pollIds).stream()
            .collect(Collectors.toMap(v -> v.getPoll().getId(), v -> v.getChoice().getId()));

    Map<Long, User> creators = userRepository.findByIdIn(
            polls.map(Poll::getCreatedBy).getContent().stream().distinct().toList())
            .stream().collect(Collectors.toMap(User::getId, Function.identity()));

    List<PollResponse> responses = polls.map(poll -> ModelMapper.mapPollToPollResponse(
            poll, choiceVotes, creators.get(poll.getCreatedBy()), userVotes.get(poll.getId())))
            .getContent();

    return new PagedResponse<>(responses, polls.getNumber(), polls.getSize(),
            polls.getTotalElements(), polls.getTotalPages(), polls.isLast());
}

Four queries for any page size: the polls, the vote counts, this user’s votes, the creators. The early return on an empty page matters because IN () with an empty list is invalid SQL on some databases and silently matches nothing on others.

@CurrentUser is the custom annotation from part 2, @AuthenticationPrincipal with a meta annotation, which is what keeps SecurityContextHolder out of the controllers.

Creating a poll, and the expiry

@PostMapping
@PreAuthorize("hasRole('USER')")
public ResponseEntity<?> createPoll(@Valid @RequestBody PollRequest request) {
    Poll poll = pollService.createPoll(request);

    URI location = ServletUriComponentsBuilder
            .fromCurrentRequest().path("/{pollId}")
            .buildAndExpand(poll.getId()).toUri();

    return ResponseEntity.created(location)
            .body(new ApiResponse(true, "Poll created successfully"));
}

@PreAuthorize needs @EnableMethodSecurity on a configuration class: without it the annotation is silently ignored and the endpoint is open to any authenticated caller, which is the same class of silent no-op as the missing @EnableJpaAuditing above.

Expiry is stored as an absolute Instant computed from a duration the client sends:

Instant now = Instant.now();
poll.setExpirationDateTime(now.plus(request.getPollLength().getDays(), ChronoUnit.DAYS)
                              .plus(request.getPollLength().getHours(), ChronoUnit.HOURS));

Storing the absolute moment rather than the duration means the deadline does not move if the row is later updated. Enforce it on the vote path, not only in the UI:

if (poll.isExpired()) {
    throw new BadRequestException("Sorry! This Poll has already expired");
}

A disabled button is a courtesy; the server check is the rule. The same applies to the choice — verify that the submitted choiceId belongs to this poll, or a caller can vote for a choice from a different one.

Responses, not entities

PollResponse carries the question, the choices with their counts, the creator’s name and this user’s selected choice. It is not the Poll entity, and that separation is doing three things: hiding fields a client should not see, avoiding lazy-loading exceptions during serialisation, and letting the response carry data, the per-choice count and the current user’s vote, that exists in no single entity.

Part 4 builds the React client against this API. More in the Spring Boot guides.

Frequently asked questions

Why are my createdAt and updatedAt columns null?

@EnableJpaAuditing is missing. The annotations do nothing without it, and the failure appears as a not-null constraint violation on the column.

What does AuditorAware do?

It supplies the value for @CreatedBy and @LastModifiedBy. Return Optional.empty() for unauthenticated contexts so migrations and scheduled jobs still work.

How do I stop a user voting twice?

A unique constraint on (poll_id, user_id) and a catch for DataIntegrityViolationException. A read-then-insert check is a race that two concurrent requests both pass.

Should I keep a voteCount column?

No. Incrementing it is a lost-update race and creates a second source of truth. Count with a GROUP BY query; escalate to a database-maintained counter only if that becomes slow.

Why does my list endpoint issue so many queries?

The vote counts are being fetched per poll. Query once with WHERE poll.id IN :pollIds so the count is fixed regardless of page size.

Why do rows repeat across pages?

The sort is not deterministic. createdAt alone ties; add id as a secondary sort so the ordering is total.

Is EAGER fetching ever right?

Here, yes, a poll is meaningless without its at-most-six choices. Use FetchMode.SELECT or @BatchSize so it does not multiply rows in a join.

Why cap the page size?

?size=1000000 is otherwise an unauthenticated way to exhaust memory. Validate and reject above a sensible maximum.

Why return a response object instead of the entity?

It hides internal fields, avoids lazy-initialisation errors during serialisation, and can carry computed data, the vote counts and the current user’s choice, that no single entity holds.

What is @CurrentUser?

A meta-annotation over @AuthenticationPrincipal from part 2. It injects the authenticated principal so controllers never touch SecurityContextHolder.