So many great answers already, but I specifically implemented this using the answer from @Pankaj Garg (Using the Spring Specification API). There are a few  use cases I am adding to my answer
- 4 parameters that may or may not be null.
- Paginated response from the repository.
- Filtering by a field in a nested object.
- Ordering by a specific field.
First I create a couple of entities, specifically Ticket, Movie and Customer. Nothing fancy here:
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
import java.util.UUID;
@Entity
@Table(name = "ticket", schema = "public")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder(toBuilder = true)
public class Ticket implements Serializable  {
    @Id
    @Basic(optional = false)
    @NotNull
    @Column(name = "id", nullable = false)
    private UUID id;
    @JoinColumn(name = "movie_id", referencedColumnName = "id", nullable = false)
    @ManyToOne(fetch = FetchType.EAGER)
    private Movie movie;
    @JoinColumn(name = "customer_id", referencedColumnName = "id", nullable = false)
    @ManyToOne(fetch = FetchType.EAGER)
    private Customer customer;
    @Column(name = "booking_date")
    @Temporal(TemporalType.TIMESTAMP)
    private Date bookingDate;
}
Movie:
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.io.Serializable;
@Entity
@Table(name = "movie", schema = "public")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder(toBuilder = true)
public class Movie implements Serializable {
    @Id
    @Basic(optional = false)
    @NotNull
    @Column(name = "id", nullable = false)
    private UUID id;
    @Basic(optional = false)
    @NotNull
    @Size(max = 100)
    @Column(name = "movie_name", nullable = false, length = 100)
    private String movieName;
}
Customer:
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.io.Serializable;
@Entity
@Table(name = "customer", schema = "public")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder(toBuilder = true)
public class Customer implements Serializable {
    @Id
    @Basic(optional = false)
    @NotNull
    @Column(name = "id", nullable = false)
    private UUID id;
    @Basic(optional = false)
    @NotNull
    @Size(max = 100)
    @Column(name = "full_name", nullable = false, length = 100)
    private String fullName;
}
Then I create a class with fields for the parameters I wish to filter by:
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.Date;
import java.util.UUID;
@Data
@AllArgsConstructor
public class TicketFilterParam {
    private UUID movieId;
    private UUID customerId;
    private Date start;
    private Date end;
}
Next I create a class to generate a Specification based on the filter parameters. Note the way nested objects are accessed, as well as the way ordering is added to the query.
import org.springframework.data.jpa.domain.Specification;
import javax.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class TicketSpecifications {
    public static Specification<Ticket> getFilteredTickets(TicketFilterParam params) {
        return (root, criteriaQuery, criteriaBuilder) -> {
            List<Predicate> predicates = new ArrayList<>();
            if (params.getMovieId() != null) {
                predicates.add(criteriaBuilder.equal(root.get("movie").<UUID> get("id"), params.getMarketerId()));
            }
            if (params.getCustomerId() != null) {
                predicates.add(criteriaBuilder.equal(root.get("customer").<UUID> get("id"), params.getDepotId()));
            }
            if (params.getStart() != null && params.getEnd() != null) {
                predicates.add(criteriaBuilder.between(root.get("bookingDate"), params.getStart(), params.getEnd()));
            }
            criteriaQuery.orderBy(criteriaBuilder.desc(root.get("bookingDate")));
            return criteriaBuilder.and(predicates.toArray(new Predicate[0]));
        };
    }
}
Next I define the Repository interface. This would have not only JpaRepository, but also JpaSpecificationExecutor:
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
@Repository
public interface TicketRepository extends JpaRepository<Ticket, UUID>, JpaSpecificationExecutor<Ticket> {
}
Finally, in some service class, I obtain results like this:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
@Service
public class TicketService {
    @Autowired
    private TicketRepository ticketRepository;
    public Page<Ticket> getTickets(TicketFilterParam params, PageRequest pageRequest) {
        Specification<Ticket> specification = TicketSpecifications.getFilteredTickets(params);
        return ticketRepository.findAll(specification, pageRequest);
    }
}
PageRequest and TicketFilterParam would probably be obtained from some parameters and values on a rest endpoint.