A lightweight, annotation-driven toolkit that adds search, pagination, auditing, and soft-delete superpowers to any Spring Boot + MongoDB project.
| Module | What it does |
|---|---|
| π Search | Annotation-driven SearchCriteria β MongoDB Query translation with type-safe operators |
| π Pagination | Safe Pageable factory with clamped bounds + serializable MongoPageResult<T> DTO |
| π΅οΈ Auditing | Spring Securityβbacked AuditorAware with configurable fallback |
| ποΈ Soft Delete | Base document with softDelete() / restore() helpers |
- β Java 21
- π Maven (or use the included
./mvnwwrapper) - π Spring Boot 3.x project with
spring-boot-starter-data-mongodb
./mvnw test./mvnw verifyOpen the coverage report at target/site/jacoco/index.html.
Extend this to get automatic auditing and optimistic locking on every document.
@Document("products")
public class Product extends BaseDocument<String> {
private String name;
private double price;
}| Field | Type | Description |
|---|---|---|
id |
ID |
Document identifier (@Id) |
createdAt |
Instant |
Set automatically on insert |
updatedAt |
Instant |
Updated automatically on every save |
createdBy |
String |
Principal who created the document |
updatedBy |
String |
Principal who last modified the document |
version |
Long |
Optimistic lock version (auto-incremented) |
Extends BaseDocument and adds logical deletion β documents are never physically removed.
@Document("orders")
public class Order extends SoftDeleteDocument<String> {
private String customerId;
}order.softDelete(); // marks deleted, records timestamp
order.softDelete("admin"); // also records who deleted it
order.restore(); // clears all soft-delete fields| Field | Type | Description |
|---|---|---|
deleted |
boolean |
true when logically deleted (indexed) |
deletedAt |
Instant |
Timestamp of deletion |
deletedBy |
String |
Principal who deleted the document |
The toolkit ships a SpringSecurityAuditor that implements Spring Data's AuditorAware<String>.
It reads the current principal from SecurityContextHolder and falls back to a configurable name when no user is authenticated.
The auditor is registered automatically via Spring Boot auto-configuration β no @Bean needed.
javloom:
mongodb:
audit:
fallback-auditor: system # default; used for anonymous / unauthenticated requestsThe search module lets you translate a list of SearchCriteria objects into a MongoDB Query using a simple annotation-driven approach.
Mark each searchable field with the appropriate @Searchable* annotation.
@Document("products")
public class Product extends BaseDocument<String> {
@SearchableString // default operator: EQ
private String name;
@SearchableString(StringOperators.LIKE) // default operator: LIKE
private String description;
@SearchableNumber
private double price;
@SearchableBoolean
private boolean active;
@SearchableDate
private Instant launchedAt;
@SearchableEnum
private Category category;
@SearchableCollection
private List<String> tags;
}All annotations accept an optional alias to expose a different name in search criteria:
@SearchableString(alias = "q")
private String description; // search by "q" instead of "description"SearchQuery is the recommended fluent API. It resolves field types from your annotations at build time, so mismatched operators (e.g. calling .like() on a number field) throw an IllegalArgumentException immediately β no silent wrong queries.
Query query = SearchQuery.on(Product.class)
.where("name").like("monitor")
.and("price").between(100.0, 500.0)
.and("active").isTrue()
.build();
List<Product> results = mongoTemplate.find(query, Product.class);Query query = SearchQuery.on(Product.class)
.where("name").like("monitor")
.and("price").gt(50.0)
.pageable(MongoPageRequest.of(0, 20, "price", Sort.Direction.ASC))
.build();Chain .and(field) for $and conditions (the default) and .or(field) for $or conditions. Both can be mixed freely.
Query query = SearchQuery.on(Product.class)
.where("category").eq("ELECTRONICS") // AND
.and("price").lte(999.0) // AND
.or("name").like("monitor") // OR
.or("name").like("display") // OR
.build();| Method | Applicable types | Description |
|---|---|---|
.eq(value) |
All | Exact match |
.like(value) |
STRING |
Case-insensitive contains |
.startsWith(value) |
STRING |
Case-insensitive prefix match |
.endsWith(value) |
STRING |
Case-insensitive suffix match |
.gt(value) |
NUMBER |
Greater than |
.gte(value) |
NUMBER |
Greater than or equal |
.lt(value) |
NUMBER |
Less than |
.lte(value) |
NUMBER |
Less than or equal |
.between(from, to) |
NUMBER, DATE |
Inclusive range |
.after(value) |
DATE |
After the given instant |
.before(value) |
DATE |
Before the given instant |
.isTrue() |
BOOLEAN |
Equals true |
.isFalse() |
BOOLEAN |
Equals false |
.in(v1, v2, ...) |
STRING, ENUM, COLLECTION |
Value in the given set |
.nin(v1, v2, ...) |
STRING, ENUM, COLLECTION |
Value not in the given set |
.exists() |
All | Field is present in document |
.notExists() |
All | Field is absent from document |
π‘ Aliases work transparently. If a field is annotated with
alias = "q", use"q"in.where()/.and()/.or()and the correct MongoDB field name is resolved automatically.
If you need to build criteria programmatically (e.g. from a request DTO), you can pass a List<SearchCriteria> directly to CriteriaBuilder:
List<SearchCriteria> criteria = List.of(
SearchCriteria.of("name", "Monitor"), // shorthand EQ
SearchCriteria.builder()
.field("price")
.operator("BETWEEN")
.value(List.of(100.0, 500.0))
.build(),
SearchCriteria.builder()
.field("active")
.operator("EQ")
.value(true)
.logic(SearchLogic.AND)
.build()
);
Query query = CriteriaBuilder.buildQuery(Product.class, criteria);
// or with pagination:
Query query = CriteriaBuilder.buildQuery(Product.class, criteria, pageable);| Operator | Description |
|---|---|
EQ |
Exact match |
NEQ |
Not equal |
LIKE |
Contains substring (case-insensitive regex) |
STARTS_WITH |
Starts with prefix (case-insensitive) |
ENDS_WITH |
Ends with suffix (case-insensitive) |
IN |
Value is in a Collection |
NIN |
Value is not in a Collection |
EXISTS |
Field exists in document |
NOT_EXISTS |
Field does not exist in document |
| Operator | Description |
|---|---|
EQ |
Equal |
NEQ |
Not equal |
GT |
Greater than |
GTE |
Greater than or equal |
LT |
Less than |
LTE |
Less than or equal |
BETWEEN |
Inclusive range β value must be List.of(lower, upper) |
IN |
Value is in a Collection |
NIN |
Value is not in a Collection |
EXISTS |
Field exists |
| Operator | Description |
|---|---|
EQ |
Equal |
GT |
After |
GTE |
After or equal |
LT |
Before |
LTE |
Before or equal |
BETWEEN |
Inclusive range β value must be List.of(from, to) |
EXISTS |
Field exists |
NOT_EXISTS |
Field does not exist |
| Operator | Description |
|---|---|
EQ |
Equal |
EXISTS |
Field exists |
NOT_EXISTS |
Field does not exist |
| Operator | Description |
|---|---|
EQ |
Equal |
NEQ |
Not equal |
IN |
Value in a Collection |
NIN |
Value not in a Collection |
EXISTS |
Field exists |
NOT_EXISTS |
Field does not exist |
| Operator | Description |
|---|---|
IN |
Array field contains any of the given values |
NIN |
Array field contains none of the given values |
SIZE_EQ |
Array size equals the given int |
SIZE_GT |
Array size is greater than the given value |
EXISTS |
Field exists |
NOT_EXISTS |
Field does not exist |
Page index is clamped to >= 0. Page size is clamped to [1, 100].
// Basic
Pageable p = MongoPageRequest.of(0, 20);
// With sort
Pageable p = MongoPageRequest.of(0, 20, "createdAt", Sort.Direction.DESC);
// Multiple sort orders
Pageable p = MongoPageRequest.of(0, 20,
Sort.Order.desc("createdAt"),
Sort.Order.asc("name")
);
// Defaults: page 0, size 20, sorted by createdAt DESC
Pageable p = MongoPageRequest.defaultPageable();| Constant | Value |
|---|---|
DEFAULT_PAGE |
0 |
DEFAULT_SIZE |
20 |
MAX_SIZE |
100 |
DEFAULT_SORT |
"createdAt" |
Wraps a Spring Data Page<T> into a JSON-friendly response object (@JsonInclude(NON_NULL)).
Page<Product> page = mongoTemplate.find(query, Product.class); // or repository method
MongoPageResult<Product> result = MongoPageResult.of(page);{
"content": [...],
"page": 0,
"size": 20,
"totalElements": 142,
"totalPages": 8,
"first": true,
"last": false
}Unit tests and integration tests are included. Integration tests spin up an embedded MongoDB (Flapdoodle) β no external database required.
# Run all tests
./mvnw test
# Run all tests + generate JaCoCo coverage report
./mvnw verifyCoverage report: target/site/jacoco/index.html
GitHub Actions runs ./mvnw -B verify on every push and pull request.
Workflow definition: .github/workflows/ci.yml
| Technology | Version |
|---|---|
| Java | 21 |
| Spring Boot | 3.4.1 |
| Spring Data MongoDB | (managed by Boot) |
| Spring Security | (optional, managed by Boot) |
| Lombok | 1.18.38 |
| Jackson Databind | (managed by Boot) |
| Flapdoodle Embed Mongo | 4.22.0 (test) |
| JaCoCo | 0.8.14 |
This project is licensed under the MIT License.