Building Type-Safe SQL Queries with Jule in Spring Boot: A Practical Guide
Jule provides a fluent DSL for building type-safe SQL queries in Spring Boot. This guide shows how to implement dynamic search repositories with conditional clauses, automatic parameter binding, and result mapping—plus the limits you'll hit in production.
21 Jan 2026, 23:15 UTC

The Problem: String Concatenation vs. Type Safety
Building dynamic SQL queries in Java often leads to fragile string concatenation, SQL injection vulnerabilities, and runtime errors from typos in column names. Jule solves this by providing a fluent DSL that generates parameterized SQL at runtime while keeping your query logic type-checked at compile time. The useful takeaway: Jule lets you write dynamic queries as Java code, not strings, with automatic parameter binding and result mapping.
Core Mechanism: Fluent Query Building
Jule's API centers on a QueryBuilder that assembles SELECT statements through method chaining. Each clause (where, join, orderBy) returns the builder, enabling conditional logic without string manipulation. Parameters are bound via positional or named placeholders, and results map to Java records or POJOs using column aliases.
Worked Example: Dynamic Search Repository
Consider a Spring Data JDBC repository that searches products by optional name, category, and price range. Without Jule, you'd concatenate WHERE clauses. With Jule, the repository implementation looks like this:
@Repository
public class ProductSearchRepository {
private final JdbcTemplate jdbcTemplate;
private final QueryBuilder qb = QueryBuilder.select()
.columns("id", "name", "category", "price", "created_at")
.from("products");
public ProductSearchRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public List<Product> search(Optional<String> name,
Optional<String> category,
Optional<BigDecimal> minPrice,
Optional<BigDecimal> maxPrice,
Pageable pageable) {
QueryBuilder query = qb;
name.ifPresent(n -> query.where("name ILIKE ?", "%" + n + "%"));
category.ifPresent(c -> query.where("category = ?", c));
minPrice.ifPresent(p -> query.where("price >= ?", p));
maxPrice.ifPresent(p -> query.where("price <= ?", p));
query.orderBy("created_at DESC")
.limit(pageable.getPageSize())
.offset(pageable.getOffset());
String sql = query.build();
Object[] params = query.getParameters().toArray();
return jdbcTemplate.query(sql, params, (rs, rowNum) -> new Product(
rs.getLong("id"),
rs.getString("name"),
rs.getString("category"),
rs.getBigDecimal("price"),
rs.getTimestamp("created_at").toLocalDateTime()
));
}
}
Where to run this: In a Spring Boot service class annotated with @Repository. Requires spring-boot-starter-jdbc and Jule dependency. Permissions: Standard application datasource credentials. Placeholders: Product is a record or POJO matching the selected columns; Pageable comes from Spring Data.
Generated SQL and Parameter Binding
For a call search(Optional.of("widget"), Optional.empty(), Optional.of(BigDecimal.TEN), Optional.empty(), PageRequest.of(0, 20)), Jule produces:
SELECT id, name, category, price, created_at
FROM products
WHERE name ILIKE ? AND price >= ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
With parameters: ["%widget%", 10.00, 20, 0]. The ILIKE operator is PostgreSQL-specific; for MySQL use LIKE with a case-insensitive collation.
Integration with Spring Data JDBC
Jule complements Spring Data JDBC's CrudRepository for read-heavy, dynamic queries. You keep save/delete on the standard repository and delegate complex searches to a custom Jule-backed implementation. Wire it via a @Query annotation on a repository interface method, or inject the custom repository as a separate bean.
public interface ProductRepository extends CrudRepository<Product, Long> {
// Standard CRUD from Spring Data JDBC
}
@Service
public class ProductSearchService {
private final ProductSearchRepository searchRepo;
public ProductSearchService(ProductSearchRepository searchRepo) {
this.searchRepo = searchRepo;
}
public Page<Product> searchProducts(ProductSearchCriteria criteria, Pageable pageable) {
List<Product> results = searchRepo.search(
criteria.name(), criteria.category(),
criteria.minPrice(), criteria.maxPrice(), pageable
);
// Count query would need a separate Jule builder
return new PageImpl<>(results, pageable, totalCount);
}
}
Limits and Common Mistakes
1. Read-Only by Design
Jule does not generate INSERT, UPDATE, or DELETE statements. For write operations, use Spring Data JDBC's CrudRepository or plain JdbcTemplate. Mixing Jule for reads and JdbcTemplate for writes in the same transaction is safe and common.
2. Runtime Query Building Overhead
Each query invocation constructs a new QueryBuilder chain and builds the SQL string. In high-throughput paths (thousands of QPS), this allocation adds measurable latency. Mitigation: cache built queries for static parameter structures, or use PreparedStatementCreator with pre-compiled SQL for the hottest paths.
3. Debugging Dynamic Queries
When a complex conditional query fails, the generated SQL isn't visible in stack traces. Enable debug logging for org.jule to see built SQL and bound parameters:
logging.level.org.jule=DEBUG
In application.yml. This logs each build() call with the final SQL and parameter array—essential for diagnosing WHERE clause logic errors.
4. Column Alias Mapping
Jule maps results by column name. If your query uses expressions (COUNT(*), price * 1.2 AS adjusted_price), you must alias them explicitly in .columns() or the result set mapper will miss them. Example:
.columns("id", "name", "COUNT(*) AS order_count", "price * 1.2 AS adjusted_price")
5. JDBC Driver Compatibility
Jule relies on standard JDBC PreparedStatement behavior. Older drivers (pre-JDBC 4.2) may mishandle LIMIT/OFFSET syntax or named parameters. Verify with your target database: PostgreSQL 14+, MySQL 8.0+, H2 2.1+, SQL Server 2019+ are tested. Run the verification steps below before adopting.
Verification Checklist
- Add dependency: In
pom.xml:<dependency><groupId>io.github.jule</groupId><artifactId>jule</artifactId><version>1.2.0</version></dependency>(check Maven Central for latest). - Compile check: Run
./mvnw compile— verify no version conflicts withspring-jdbc. - Integration test: Write a
@SpringBootTestthat exercises the search repository with all parameter combinations. Assert generated SQL via captured logs. - Load test: Use
jmeterorwrkagainst the search endpoint at expected QPS. Compare latency with a hand-writtenJdbcTemplatebaseline. - Security scan: Run OWASP ZAP or similar against the search endpoint; confirm no SQL injection vectors exist (Jule's parameter binding should neutralize them).
When to Choose Jule
Use Jule when: you need dynamic, read-only queries with many optional filters; your team prefers Java over SQL strings; you're on Spring Boot 2.7+ or 3.x with a supported database. Avoid Jule when: you need complex write operations, stored procedure calls, or microsecond-level latency on static queries.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.